Compare commits

...
9 Commits
Author SHA1 Message Date
Martino Ferrari 1d5b286611 crosshair: axis badges, clustered cursor labels, and the shared-y horizontal line
CI / debug (tests + all adapters) (push) Canceled after 0s
CI / coverage (floor + measured table) (push) Canceled after 0s
CI / asan (AddressSanitizer) (push) Canceled after 0s
- Axis badges: the y value in the y label strip and the x time in the x strip, each a
  full-opacity panel in the crosshair's colour, **centred on the line it names**. The tick
  text is flushed before the fill -- the over-text batch is flushed after every line batch,
  so without that flush the label behind printed through the panel (the "transparent
  backplate" defect).
- Cursor labels: collected across the cursor loop and clustered at its end, so two marks in
  one column share a panel with one line each instead of overprinting, and a label stays on
  its own mark's row.
- Figure: a split row's cells share one y (`set_share_row_y`), so the horizontal line is
  drawn in every cell of the hovered row -- one value, one scale, spanning the row -- and
  still in no other row.

Tests: `figure_crosshair_horizontal_spans_a_split_rows_cells`, plus the render-smoke
additions for the badge placement/flush and the clustered labels.
2026-11-05 17:58:49 +01:00
Martino Ferrari 2cb31cbb91 figure: x axes follow the column structure; split rows keep the right gutter on the last cell only 2026-11-04 07:00:52 +01:00
Martino Ferrari 69f0083fc0 figure: note same-axis-kind assumption in row y fit 2026-11-04 01:13:29 +01:00
Martino FerrariandClaude Opus 4.6 2b4b316ad8 figure: shared-y rows draw one y axis, on the first cell
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-04 00:43:41 +01:00
Martino Ferrari 25c3381551 figure: cells of a row can share one y range 2026-11-04 00:10:47 +01:00
Martino Ferrari 73f610b136 adapters: nearest-cell distance in double 2026-11-03 19:28:24 +01:00
Martino Ferrari e5b7faadec adapters: route pointer events by cell rect 2026-11-03 17:04:16 +01:00
Martino Ferrari 7d8c453232 figure: rows can hold several plots (cells), cell weights and column gap 2026-11-03 16:47:47 +01:00
Martino Ferrari f79a97be9b fix: a density grid is bounded in cells, not by the window's pixels
A grid was the axes rect in device pixels, so dragging a window wide made the
image wider with it -- and the per-frame pass over it 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. At 2x device scale a
1520x850 window's axes rect is 2877 device pixels wide, so four rows of one
trace are 4.5 M cells and 4.9 MB uploaded a frame; measured headless on
llvmpipe with a 2 s window, 12.9 ms of tick and 12.8 ms of draw a frame, every
frame under a time fade -- a cost that follows the window and has no ceiling.

The grid is now the axes rect up to kDensityCellCap (2^18 cells, a mebibyte of
counts), scaled down uniformly past it so cells stay square and the picture
keeps the axes' aspect, and magnified back over the rect with a linear filter:
a cell is a count, so a fragment between two of them is between their two ink
levels, which a point sample cannot give once a cell is several pixels wide.
The same four rows at the cap are 1.05 M cells, 3.9 ms of tick and 5.6 ms of
draw and 1.0 MB uploaded, while a grid under the cap is untouched -- a 640x360
viewport's 573x313 axes is still 179 349 cells, one a device pixel.

The image's footprint is the axes rect rounded up to whole device pixels on
both sides of the pipe, so a grid under the cap keeps the exact
one-texel-a-pixel mapping it has always had (sx/sy come out exactly 1) and only
a capped grid reads its cell index through a scale. Moving the decay itself to
the GPU was considered and left out: the CPU grid is what ink()/peak() read,
what the deposit adds to and what the per-sweep dedupe keys on, so a GPU image
would have to be reconciled with it per frame -- a larger change than this
bound, and not one to half-do.

Tests: a_density_grid_is_capped_whatever_the_window reads the bound off a
3840x2160 viewport (261 504 cells of a 3784x2138 axes, aspect kept),
a_capped_density_is_magnified_over_the_axes_rect reads the screen side of it
(569x460 cells covering all 832 of the rect's columns), and the density cases
now assert their capped figures. 399 cases, 0 failed.
2026-10-16 21:09:07 +02:00
22 changed files with 2110 additions and 332 deletions

No files matched your search

+13 -9
View File
@@ -59,15 +59,19 @@ run touched a GPU, so read it as an upper bound and a record of the software pat
(`FrameStats::slope_traces`), so the look does not change as a window narrows past the (`FrameStats::slope_traces`), so the look does not change as a window narrows past the
threshold; threshold;
- a **phosphor density** a host can turn on for a plot (`Plot::set_density()`, an image rather - 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, one `f32` than a set of retained traces): **one grid per trace**, in that trace's own colour, the axes
cell per device pixel of the axes rect, drawn *in place of* the traces -- a host may not also rect in device pixels, drawn *in place of* the traces -- a host may not also draw them over it --
draw them over it -- into which each acquisition is deposited, 1.0 per cell a trace crosses, so into which each acquisition is deposited, 1.0 per cell a trace crosses, so brightness is how
brightness is how often it passed. It fades either by a **time constant** in stream seconds or by often it passed. It fades either by a **time constant** in stream seconds or by a
a **per-acquisition factor**, and the newest acquisition is deposited like every other, so it **per-acquisition factor**, and the newest acquisition is deposited like every other, so it
fades too. Memory is the screen's times the traces on screen (a 640x360 viewport's axes is fades too. The resolution is bounded (`kDensityCellCap`, 262 144 cells -- a mebibyte of counts --
179 349 cells per trace, 717 kB), the deposit walks the axes' columns rather than a grid, past which the rect is scaled down uniformly and the image magnified back over it with a
the samples, and a frame whose grids did not move costs one draw call per trace linear filter), because the per-frame pass is `O(cells)` and the window is not bounded at all:
(`FrameStats::density_cells`/`density_upload_bytes`); see `docs/data-and-performance.md`; memory is the cap times the traces on screen times 4 bytes, whatever the window (a 640x360
viewport's axes is 179 349 cells per trace, 717 kB, under it, while four rows of one trace in a
3040x1700 one used to be 4.5 M cells and 18 MB between them). The deposit walks the axes'
columns rather than the samples, and a frame whose grids did not move costs one draw call per
trace (`FrameStats::density_cells`/`density_upload_bytes`); see `docs/data-and-performance.md`;
- sample **markers** when a window is sparse enough that its samples are the shape - sample **markers** when a window is sparse enough that its samples are the shape
(`Style::marker_below_samples`, 50 by default, `marker_px` across, in the trace's own colour); (`Style::marker_below_samples`, 50 by default, `marker_px` across, in the trace's own colour);
- grid with independent major/minor line classes (`Style::grid_major_visible`, - grid with independent major/minor line classes (`Style::grid_major_visible`,
+27 -22
View File
@@ -455,9 +455,11 @@ void Host::set_figure(Figure* figure) noexcept {
row_rects_.clear(); // no figure, no rows: the handlers fall back to `controller_` row_rects_.clear(); // no figure, no rows: the handlers fall back to `controller_`
return; return;
} }
const u32 rows = figure_->row_count(); // One controller per **plot** (a row of several cells has one for each), indexed by the
row_controllers_.reserve(rows); // flattened plot index the figure uses.
for (u32 i = 0; i < rows; ++i) { const u32 plots = figure_->plot_count();
row_controllers_.reserve(plots);
for (u32 i = 0; i < plots; ++i) {
row_controllers_.push_back(std::make_unique<input::Controller>(figure_->plot(i))); row_controllers_.push_back(std::make_unique<input::Controller>(figure_->plot(i)));
} }
// The new figure's rects go into the cache now, not at the next frame: an event // The new figure's rects go into the cache now, not at the next frame: an event
@@ -469,8 +471,10 @@ void Host::set_figure(Figure* figure) noexcept {
void Host::refresh_row_rects() noexcept { void Host::refresh_row_rects() noexcept {
// The one `Figure::layout()` per call: every event handler reads `row_rects_` // The one `Figure::layout()` per call: every event handler reads `row_rects_`
// instead of paying this again, which is the whole point of the cache (R7). // instead of paying this again, which is the whole point of the cache (R7). The rects
row_rects_ = figure_ != nullptr ? figure_->layout().rows : std::vector<Rect>{}; // are the figure's **cells** (one per plot, flattened), which for a figure with one
// cell per row are exactly its row rects.
row_rects_ = figure_ != nullptr ? figure_->layout().cells : std::vector<Rect>{};
} }
input::Controller* Host::row_controller(u32 row) noexcept { input::Controller* Host::row_controller(u32 row) noexcept {
@@ -486,8 +490,7 @@ input::Controller& Host::controller_at(f32 x, f32 y) noexcept {
// frame stale, which is the documented worst case. // frame stale, which is the documented worst case.
const std::vector<Rect>& rows = row_rects_; const std::vector<Rect>& rows = row_rects_;
if (rows.empty() || rows.size() != row_controllers_.size()) return controller_; if (rows.empty() || rows.size() != row_controllers_.size()) return controller_;
(void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in() const u32 row = row_in(rows, x, y);
const u32 row = row_in(rows, y);
last_row_ = row; last_row_ = row;
input::Controller& controller = *row_controllers_[row]; input::Controller& controller = *row_controllers_[row];
// The row's own viewport, so the geometry this controller hit-tests is the geometry // The row's own viewport, so the geometry this controller hit-tests is the geometry
@@ -506,7 +509,6 @@ void Host::feed_pointer_event(const input::PointerEvent& e) noexcept {
input::Controller& Host::pointer_target(f32 x, f32 y, input::Button button, bool pressed, input::Controller& Host::pointer_target(f32 x, f32 y, input::Button button, bool pressed,
bool released) noexcept { bool released) noexcept {
(void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in()
if (figure_ == nullptr || row_controllers_.empty()) return controller_; if (figure_ == nullptr || row_controllers_.empty()) return controller_;
const std::vector<Rect>& rows = row_rects_; const std::vector<Rect>& rows = row_rects_;
if (rows.empty() || rows.size() != row_controllers_.size()) return controller_; if (rows.empty() || rows.size() != row_controllers_.size()) return controller_;
@@ -523,7 +525,7 @@ input::Controller& Host::pointer_target(f32 x, f32 y, input::Button button, bool
// drag, and its press must not steal the capture to the row it landed in. // drag, and its press must not steal the capture to the row it landed in.
if (released && button == captured_button_) captured_ = false; if (released && button == captured_button_) captured_ = false;
} else { } else {
row = row_in(rows, y); row = row_in(rows, x, y);
if (pressed) { if (pressed) {
captured_ = true; captured_ = true;
captured_row_ = row; captured_row_ = row;
@@ -541,27 +543,30 @@ input::Controller& Host::pointer_target(f32 x, f32 y, input::Button button, bool
u32 Host::pointer_row(const std::vector<Rect>& rows) noexcept { u32 Host::pointer_row(const std::vector<Rect>& rows) noexcept {
if (captured_ && captured_row_ < rows.size()) return captured_row_; if (captured_ && captured_row_ < rows.size()) return captured_row_;
return row_in(rows, last_pointer_y_); return row_in(rows, last_pointer_x_, last_pointer_y_);
} }
u32 Host::row_in(const std::vector<Rect>& rows, f32 y) noexcept { u32 Host::row_in(const std::vector<Rect>& rows, f32 x, f32 y) noexcept {
if (rows.empty()) return 0u; if (rows.empty()) return 0u;
// The row the pixel is in, or -- when it is in none of them, which is the row gap a // The cell the pixel is in, or -- when it is in none of them, which is the row gap or
// host has asked for with `Figure::set_row_gap()` or the figure's own margins -- the // column gap a host has asked for with `Figure::set_row_gap()`/`set_col_gap()` or the
// row whose rect is nearest. Selection by distance rather than by "the first that // figure's own margins -- the cell whose rect is nearest (squared distance from the
// matches" is what makes a pixel in a gap belong to the row it is *near*: the rows // pixel to the rect). Selection by distance rather than by "the first that matches" is
// tile the figure at the default gap of 0, so this is unreachable until a host asks // what makes a pixel in a gap belong to the cell it is *near*, and a gesture in a
// for one, and a gesture in a visible gap must not be routed to row 0 by default. // visible gap must not be routed to plot 0 by default. For a figure with one cell per
// row every cell spans the figure's width, so this reduces to the nearest row by y.
u32 row = 0u; u32 row = 0u;
f32 best = std::numeric_limits<f32>::infinity(); double best = std::numeric_limits<double>::infinity();
for (u32 i = 0; i < rows.size(); ++i) { for (u32 i = 0; i < rows.size(); ++i) {
const f32 top = rows[i].y; const Rect& r = rows[i];
const f32 bottom = rows[i].y + rows[i].h; // In double: in f32 the dy^2 term can be swallowed by a large dx^2 (pointer far outside).
const f32 distance = y < top ? top - y : (y >= bottom ? y - bottom : 0.0f); const double dx = x < r.x ? double(r.x) - x : (x >= r.right() ? double(x) - r.right() : 0.0);
const double dy = y < r.y ? double(r.y) - y : (y >= r.bottom() ? double(y) - r.bottom() : 0.0);
const double distance = dx * dx + dy * dy;
if (distance < best) { if (distance < best) {
best = distance; best = distance;
row = i; row = i;
if (distance == 0.0f) break; // inside this row: nothing can be nearer if (distance == 0.0) break; // inside this cell: nothing can be nearer
} }
} }
return row; return row;
+19 -15
View File
@@ -121,18 +121,21 @@ class Host {
/// `plot_` is then untouched by any of this. /// `plot_` is then untouched by any of this.
void set_figure(Figure* figure) noexcept; void set_figure(Figure* figure) noexcept;
Figure* figure() noexcept { return figure_; } Figure* figure() noexcept { return figure_; }
/// One figure row's controller -- the one a gesture on that row reaches -- or /// One figure plot's controller -- the one a gesture on that plot reaches -- or null
/// null while no figure is set or the row does not exist. A host reads a drag /// while no figure is set or it does not exist. `row` is the **flattened plot index**
/// (`Figure::plot_count()` of them: the row count when no row holds several cells). A host reads a drag
/// mode or a hover out of it (`controller()` is the single-plot loop's, and is /// mode or a hover out of it (`controller()` is the single-plot loop's, and is
/// not the one figure gestures go to). /// not the one figure gestures go to).
input::Controller* row_controller(u32 row) noexcept; input::Controller* row_controller(u32 row) noexcept;
/// The controller an event at framebuffer pixel (x, y) belongs to: `controller_` -- /// The controller an event at framebuffer pixel (x, y) belongs to: `controller_` --
/// the single-plot loop -- while no figure is set, and the controller of the figure /// the single-plot loop -- while no figure is set, and the controller of the figure
/// row that pixel lands in otherwise. The row rects come from the cache /// plot (cell) that pixel lands in otherwise -- x and y both pick it, as a row may hold
/// several cells side by side. The cell rects come from the cache
/// `refresh_row_rects()` fills -- the same ones the renderer draws -- so a gesture /// `refresh_row_rects()` fills -- the same ones the renderer draws -- so a gesture
/// lands on the row it was aimed at and no event lays the figure out; a pixel /// lands on the row it was aimed at and no event lays the figure out; a pixel
/// inside no row -- the row gap a host asked for with `Figure::set_row_gap()`, /// inside no cell -- the row or column gap a host asked for with
/// or a margin -- is the *nearest* row rather than dropped or defaulted, so a drag /// `Figure::set_row_gap()`/`set_col_gap()`, or a margin -- is the *nearest* cell rather than
/// dropped or defaulted, so a drag
/// that leaves the data area still ends where it should. /// that leaves the data area still ends where it should.
/// ///
/// It also hands that row its own viewport, which is what keeps the controller's /// It also hands that row its own viewport, which is what keeps the controller's
@@ -217,11 +220,12 @@ class Host {
void on_scroll(f64 dx, f64 dy) noexcept; void on_scroll(f64 dx, f64 dy) noexcept;
void on_key(int key, int action, int mods) noexcept; void on_key(int key, int action, int mods) noexcept;
/// The index of the row of `rows` a framebuffer y belongs to: the row it is in, or the nearest /// The index of the cell of `rows` (the figure's flattened cell rects) a framebuffer pixel
/// row when it is in none (a row gap, a margin outside the figure). One rule, shared by the event /// belongs to: the cell it is in, or the nearest cell when it is in none (a row or column gap,
/// routing and the crosshair sync -- and taking the rects rather than reading them, so a caller /// a margin outside the figure). One rule, shared by the event routing and the crosshair sync
/// that has just laid the figure out does not make it lay out again for this. /// -- and taking the rects rather than reading them, so a caller that has just laid the figure
static u32 row_in(const std::vector<Rect>& rows, f32 y) noexcept; /// out does not make it lay out again for this.
static u32 row_in(const std::vector<Rect>& rows, f32 x, f32 y) noexcept;
/// The controller a pointer event at (x, y) belongs to, applying the button capture: /// The controller a pointer event at (x, y) belongs to, applying the button capture:
/// a press claims the row it landed in (`captured_row_` + `captured_button_`) and /// a press claims the row it landed in (`captured_row_` + `captured_button_`) and
@@ -231,10 +235,10 @@ class Host {
input::Controller& pointer_target(f32 x, f32 y, input::Button button, bool pressed, input::Controller& pointer_target(f32 x, f32 y, input::Button button, bool pressed,
bool released) noexcept; bool released) noexcept;
/// The row the pointer's events currently go to: the captured row while a drag owns /// The plot (flattened index) the pointer's events currently go to: the captured one
/// the button, the row under the pointer otherwise. The run loop uses it to clear /// while a drag owns the button, the one under the pointer otherwise. The run loop uses
/// every *other* row's hot handles, so a row the pointer left cannot keep drawing a /// it to clear every *other* plot's hot handles, so one the pointer left cannot keep
/// hover or a grab it last published. /// drawing a hover or a grab it last published.
u32 pointer_row(const std::vector<Rect>& rows) noexcept; u32 pointer_row(const std::vector<Rect>& rows) noexcept;
/// Spreads the hover over a whole `Figure` (spec §15). /// Spreads the hover over a whole `Figure` (spec §15).
@@ -292,7 +296,7 @@ class Host {
u32 captured_row_ = 0u; u32 captured_row_ = 0u;
input::Button captured_button_ = input::Button::Left; input::Button captured_button_ = input::Button::Left;
/// The figure's row rects as of the last `refresh_row_rects()` -- the cache the /// The figure's cell rects (one per plot, flattened; the row rects when no row is split) as of the last `refresh_row_rects()` -- the cache the
/// event routing reads instead of laying the figure out per event (R7). Filled by /// event routing reads instead of laying the figure out per event (R7). Filled by
/// `set_figure()` and once per frame by the run loop; empty while no figure is /// `set_figure()` and once per frame by the run loop; empty while no figure is
/// set, which is the state `controller_at()`/`pointer_target()` fall back to the /// set, which is the state `controller_at()`/`pointer_target()` fall back to the
+36 -30
View File
@@ -245,9 +245,11 @@ void PlotArea::set_figure(Figure* figure) noexcept {
row_rects_.clear(); // no figure, no rows: the routing falls back to `controller_` row_rects_.clear(); // no figure, no rows: the routing falls back to `controller_`
return; return;
} }
const u32 rows = figure_->row_count(); // One controller per **plot** (a row of several cells has one for each), indexed by the
row_controllers_.reserve(rows); // flattened plot index the figure uses.
for (u32 i = 0; i < rows; ++i) { const u32 plots = figure_->plot_count();
row_controllers_.reserve(plots);
for (u32 i = 0; i < plots; ++i) {
row_controllers_.push_back(std::make_unique<input::Controller>(figure_->plot(i))); row_controllers_.push_back(std::make_unique<input::Controller>(figure_->plot(i)));
} }
// The new figure's geometry goes into the cache now, not at the next frame: an event // The new figure's geometry goes into the cache now, not at the next frame: an event
@@ -269,8 +271,10 @@ void PlotArea::lay_figure_out(Rect px) noexcept {
void PlotArea::refresh_row_rects() noexcept { void PlotArea::refresh_row_rects() noexcept {
// The one `Figure::layout()` per call: every event handler reads `row_rects_` // The one `Figure::layout()` per call: every event handler reads `row_rects_`
// instead of paying this again, which is the whole point of the cache. // instead of paying this again, which is the whole point of the cache. The rects are
row_rects_ = figure_ != nullptr ? figure_->layout().rows : std::vector<Rect>{}; // the figure's **cells** (one per plot, flattened), which for a figure with one cell
// per row are exactly its row rects.
row_rects_ = figure_ != nullptr ? figure_->layout().cells : std::vector<Rect>{};
} }
input::Controller* PlotArea::row_controller(u32 row) noexcept { input::Controller* PlotArea::row_controller(u32 row) noexcept {
@@ -283,8 +287,7 @@ input::Controller& PlotArea::controller_at(f32 x, f32 y) noexcept {
// -- and not a fresh `Figure::layout()`: an event pays no layout, or a burst of // -- and not a fresh `Figure::layout()`: an event pays no layout, or a burst of
// pointer motion over a multi-row figure would re-lay every row out per move. // pointer motion over a multi-row figure would re-lay every row out per move.
if (row_rects_.empty() || row_rects_.size() != row_controllers_.size()) return controller_; if (row_rects_.empty() || row_rects_.size() != row_controllers_.size()) return controller_;
(void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in() const u32 row = row_in(row_rects_, x, y);
const u32 row = row_in(row_rects_, y);
input::Controller& controller = *row_controllers_[row]; input::Controller& controller = *row_controllers_[row];
controller.set_viewport(row_rects_[row]); controller.set_viewport(row_rects_[row]);
return controller; return controller;
@@ -292,11 +295,11 @@ input::Controller& PlotArea::controller_at(f32 x, f32 y) noexcept {
u32 PlotArea::row_at(double x, double y) const noexcept { u32 PlotArea::row_at(double x, double y) const noexcept {
if (figure_ == nullptr || row_rects_.empty()) return 0; if (figure_ == nullptr || row_rects_.empty()) return 0;
(void)x; const Vec2 p = to_device(x, y);
return row_in(row_rects_, to_device(x, y).y); return row_in(row_rects_, p.x, p.y);
} }
input::Controller& PlotArea::target_controller(f32 y) noexcept { input::Controller& PlotArea::target_controller(f32 x, f32 y) noexcept {
if (figure_ == nullptr || row_controllers_.empty()) return controller_; if (figure_ == nullptr || row_controllers_.empty()) return controller_;
if (row_rects_.empty() || row_rects_.size() != row_controllers_.size()) return controller_; if (row_rects_.empty() || row_rects_.size() != row_controllers_.size()) return controller_;
// The capture: while a press is open the gesture belongs to the row that press // The capture: while a press is open the gesture belongs to the row that press
@@ -304,7 +307,7 @@ input::Controller& PlotArea::target_controller(f32 y) noexcept {
// the release to the row the pointer is over *now* would leave the first row's drag // the release to the row the pointer is over *now* would leave the first row's drag
// open for ever -- its controller never sees the release that ends it -- and let a // open for ever -- its controller never sees the release that ends it -- and let a
// later gesture resume it from the stale anchor. // later gesture resume it from the stale anchor.
u32 row = press_open_ ? press_row_ : row_in(row_rects_, y); u32 row = press_open_ ? press_row_ : row_in(row_rects_, x, y);
// Re-read against this frame's rects: the widget can be resized mid-drag, and the // Re-read against this frame's rects: the widget can be resized mid-drag, and the
// guard keeps a stale index from reaching past the controller list. // guard keeps a stale index from reaching past the controller list.
if (row >= row_controllers_.size()) row = 0; if (row >= row_controllers_.size()) row = 0;
@@ -314,23 +317,26 @@ input::Controller& PlotArea::target_controller(f32 y) noexcept {
return controller; return controller;
} }
u32 PlotArea::row_in(const std::vector<Rect>& rows, f32 y) noexcept { u32 PlotArea::row_in(const std::vector<Rect>& rows, f32 x, f32 y) noexcept {
if (rows.empty()) return 0; if (rows.empty()) return 0;
// The row the pixel is in, or -- when it is in none of them, which is the row gap a // The cell the pixel is in, or -- when it is in none of them, which is the row or
// host asked for with `Figure::set_row_gap()` or the figure's own margins -- the row // column gap a host asked for with `Figure::set_row_gap()`/`set_col_gap()` or the
// whose rect is nearest. By distance rather than by "the first that matches": the // figure's own margins -- the cell whose rect is nearest (squared distance from the
// rows tile the figure at the default gap of 0, so this is unreachable until a host // pixel to the rect). By distance rather than by "the first that matches": a gesture
// asks for a gap, and a gesture in a visible gap must not default to row 0. // in a visible gap must not default to plot 0. For a figure with one cell per row
// every cell spans the figure's width, so this reduces to the nearest row by y.
u32 row = 0; u32 row = 0;
f32 best = std::numeric_limits<f32>::infinity(); double best = std::numeric_limits<double>::infinity();
for (u32 i = 0; i < rows.size(); ++i) { for (u32 i = 0; i < rows.size(); ++i) {
const f32 top = rows[i].y; const Rect& r = rows[i];
const f32 bottom = rows[i].y + rows[i].h; // In double: in f32 the dy^2 term can be swallowed by a large dx^2 (pointer far outside).
const f32 distance = y < top ? top - y : (y >= bottom ? y - bottom : 0.0f); const double dx = x < r.x ? double(r.x) - x : (x >= r.right() ? double(x) - r.right() : 0.0);
const double dy = y < r.y ? double(r.y) - y : (y >= r.bottom() ? double(y) - r.bottom() : 0.0);
const double distance = dx * dx + dy * dy;
if (distance < best) { if (distance < best) {
best = distance; best = distance;
row = i; row = i;
if (distance == 0.0f) break; // inside this row: nothing can be nearer if (distance == 0.0) break; // inside this cell: nothing can be nearer
} }
} }
return row; return row;
@@ -341,7 +347,7 @@ u32 PlotArea::pointer_row() const noexcept {
// The captured row while a press owns the gesture, so an in-flight drag keeps its // The captured row while a press owns the gesture, so an in-flight drag keeps its
// highlight even after the pointer has left the row it began in. // highlight even after the pointer has left the row it began in.
if (press_open_ && press_row_ < row_rects_.size()) return press_row_; if (press_open_ && press_row_ < row_rects_.size()) return press_row_;
return row_in(row_rects_, pointer_.y); return row_in(row_rects_, pointer_.x, pointer_.y);
} }
void PlotArea::sync_selection_frames() noexcept { 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 // The figure row this gesture belongs to for its whole life, claimed from the press's
// own position *before* the event is dispatched (see `target_controller()`). Harmless // own position *before* the event is dispatched (see `target_controller()`). Harmless
// and unread while no figure is set. // and unread while no figure is set.
press_row_ = figure_ != nullptr ? row_in(row_rects_, p.y) : 0; press_row_ = figure_ != nullptr ? row_in(row_rects_, p.x, p.y) : 0;
press_open_ = true; press_open_ = true;
press_button_ = button; press_button_ = button;
press_mods_ = mods; press_mods_ = mods;
@@ -634,7 +640,7 @@ void PlotArea::pointer_press(input::Button button, u32 mods, double x, double y,
e.down = true; e.down = true;
e.pressed = true; e.pressed = true;
e.time_s = pointer_time_s(time_ms); // the double-click detector differences these e.time_s = pointer_time_s(time_ms); // the double-click detector differences these
target_controller(p.y).on_pointer(e); target_controller(p.x, p.y).on_pointer(e);
gtk_gl_area_queue_render(GTK_GL_AREA(widget_)); gtk_gl_area_queue_render(GTK_GL_AREA(widget_));
} }
@@ -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 // Resolved while the press is still open, so the release reaches the row the press
// claimed rather than the row the pointer ended over -- the controller that owns the // claimed rather than the row the pointer ended over -- the controller that owns the
// drag is the only one that can close it. // drag is the only one that can close it.
input::Controller& target = target_controller(position.y); input::Controller& target = target_controller(position.x, position.y);
press_open_ = false; press_open_ = false;
input::PointerEvent e; input::PointerEvent e;
@@ -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))); gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture)));
// The press's row, not the row the pointer has reached: `press_open_` is true here, // The press's row, not the row the pointer has reached: `press_open_` is true here,
// so a drag that crosses a row boundary stays with the controller that began it. // so a drag that crosses a row boundary stays with the controller that began it.
area->target_controller(sample.position.y).on_pointer(e); area->target_controller(sample.position.x, sample.position.y).on_pointer(e);
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_)); gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
} }
@@ -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_); static_cast<f32>(scale), Vec2{static_cast<f32>(cx), static_cast<f32>(cy)}, area->mapping_);
// The folded event carries the focal point, which is the position this pinch belongs // The folded event carries the focal point, which is the position this pinch belongs
// to; while a press is open the capture overrides it, as it does for a drag. // to; while a press is open the capture overrides it, as it does for a drag.
area->target_controller(g.y).on_gesture(g); area->target_controller(g.x, g.y).on_gesture(g);
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_)); gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
} }
@@ -834,7 +840,7 @@ int PlotArea::on_scroll(GtkEventControllerScroll* scroll, double dx, double dy,
e.dx = static_cast<f32>(scroll_notches(dx, unit)); e.dx = static_cast<f32>(scroll_notches(dx, unit));
e.dy = static_cast<f32>(notches); e.dy = static_cast<f32>(notches);
e.mods = gesture_mods(GTK_EVENT_CONTROLLER(scroll)); e.mods = gesture_mods(GTK_EVENT_CONTROLLER(scroll));
area->target_controller(at.y).on_wheel(e); area->target_controller(at.x, at.y).on_wheel(e);
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_)); gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
return TRUE; // consumed: the plot zoomed return TRUE; // consumed: the plot zoomed
} }
@@ -858,7 +864,7 @@ void PlotArea::on_motion(GtkEventControllerMotion* motion, double x, double y, v
e.down = false; e.down = false;
e.time_s = PlotArea::pointer_time_s( e.time_s = PlotArea::pointer_time_s(
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(motion))); gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(motion)));
area->target_controller(p.y).on_pointer(e); area->target_controller(p.x, p.y).on_pointer(e);
} }
// --- keyboard ------------------------------------------------------------- // --- keyboard -------------------------------------------------------------
+21 -17
View File
@@ -260,15 +260,17 @@ class PlotArea {
Figure* figure() noexcept { return figure_; } Figure* figure() noexcept { return figure_; }
const Figure* figure() const noexcept { return figure_; } const Figure* figure() const noexcept { return figure_; }
/// One figure row's controller -- the one a gesture on that row reaches -- or null /// One figure plot's controller -- the one a gesture on that plot reaches -- or null
/// while no figure is set or the row does not exist. A host reads a drag mode or a /// while no figure is set or it does not exist. `row` is the **flattened plot index**
/// (`Figure::plot_count()` of them: the row count when no row holds several cells). A host reads a drag mode or a
/// hover out of it. /// hover out of it.
input::Controller* row_controller(u32 row) noexcept; input::Controller* row_controller(u32 row) noexcept;
/// The controller an event at device pixel `(x, y)` belongs to: `controller()` /// The controller an event at device pixel `(x, y)` belongs to: `controller()`
/// while no figure is set, and otherwise the controller of the figure row that /// while no figure is set, and otherwise the controller of the figure plot (cell)
/// pixel lands in -- a pixel inside no row (a `Figure::set_row_gap()` gap, a /// that pixel lands in -- x and y both pick it, as a row may hold several cells side
/// margin) is the *nearest* row rather than dropped, so a drag that leaves the data /// by side. A pixel inside no cell (a `Figure::set_row_gap()`/`set_col_gap()` gap, a
/// margin) is the *nearest* cell rather than dropped, so a drag that leaves the data
/// area still ends where it should. It also hands that row its own viewport, which /// area still ends where it should. It also hands that row its own viewport, which
/// is what keeps the controller's hit-test geometry in step with the layout between /// is what keeps the controller's hit-test geometry in step with the layout between
/// two frames. /// two frames.
@@ -277,10 +279,10 @@ class PlotArea {
/// press capture, so a host may call it freely. /// press capture, so a host may call it freely.
input::Controller& controller_at(f32 x, f32 y) noexcept; input::Controller& controller_at(f32 x, f32 y) noexcept;
/// The figure row a point in the widget's **logical** coordinates lands in -- the /// The flattened plot index a point in the widget's **logical** coordinates lands in -- the
/// coordinates every GTK event controller reports, so a host's own drop target or /// coordinates every GTK event controller reports, so a host's own drop target or
/// context menu can name the row a pointer is over without converting anything. The /// context menu can name the plot a pointer is over without converting anything. The
/// same nearest-row rule as `controller_at()`, and 0 while no figure is set. /// same nearest-cell rule (x and y) as `controller_at()`, and 0 while no figure is set.
u32 row_at(double x, double y) const noexcept; u32 row_at(double x, double y) const noexcept;
/// Re-reads the figure's row rects into the cache the event routing answers from -- /// Re-reads the figure's row rects into the cache the event routing answers from --
@@ -376,20 +378,21 @@ class PlotArea {
/// into device pixels. The one place that mapping exists. /// into device pixels. The one place that mapping exists.
Vec2 to_device(double x, double y) const noexcept; Vec2 to_device(double x, double y) const noexcept;
/// The controller one event of this area's goes to, given the event's device y. /// The controller one event of this area's goes to, given the event's device (x, y).
/// `controller_` while no figure is set; otherwise the row the gesture *belongs* /// `controller_` while no figure is set; otherwise the row the gesture *belongs*
/// to: the row the open press claimed while a press is open, and the row under `y` /// to: the row the open press claimed while a press is open, and the row under `y`
/// between gestures. That is the capture `controller_at()` deliberately omits, and /// between gestures (x and y pick the cell). That is the capture `controller_at()` deliberately omits, and
/// it is why this -- not `controller_at()` -- is what every handler below calls. /// it is why this -- not `controller_at()` -- is what every handler below calls.
/// ///
/// Hands the chosen row its own viewport, so the geometry it hit-tests is the /// Hands the chosen row its own viewport, so the geometry it hit-tests is the
/// geometry the last frame drew. /// geometry the last frame drew.
input::Controller& target_controller(f32 y) noexcept; input::Controller& target_controller(f32 x, f32 y) noexcept;
/// The row of `row_rects_` a device y belongs to: the row it is in, or the nearest /// The cell of `row_rects_` (the figure's flattened cell rects) a device pixel belongs
/// one when it is in none (a row gap, a margin outside the figure). Selection by /// to: the cell it is in, or the nearest one when it is in none (a row or column gap,
/// distance rather than "the first that matches" is what makes a pixel in a visible /// a margin outside the figure). Selection by distance rather than "the first that
/// gap belong to the row it is *near* instead of to row 0. /// matches" is what makes a pixel in a visible gap belong to the cell it is *near*
static u32 row_in(const std::vector<Rect>& rows, f32 y) noexcept; /// instead of to plot 0.
static u32 row_in(const std::vector<Rect>& rows, f32 x, f32 y) noexcept;
/// The row this area's events currently go to: the press's row while one is open, /// The row this area's events currently go to: the press's row while one is open,
/// so an in-flight gesture keeps its highlight, and the row under the last pointer /// so an in-flight gesture keeps its highlight, and the row under the last pointer
/// position otherwise. 0 with no rows. /// position otherwise. 0 with no rows.
@@ -457,7 +460,8 @@ class PlotArea {
/// `set_figure()` is called, and the single-plot path reads neither. /// `set_figure()` is called, and the single-plot path reads neither.
Figure* figure_ = nullptr; Figure* figure_ = nullptr;
std::vector<std::unique_ptr<input::Controller>> row_controllers_; std::vector<std::unique_ptr<input::Controller>> row_controllers_;
/// The figure's row rects as of the last `refresh_row_rects()`: the cache the event /// The figure's cell rects (one per plot, flattened; the row rects when no row is split)
/// as of the last `refresh_row_rects()`: the cache the event
/// routing reads instead of laying the figure out per event. Empty while no figure /// routing reads instead of laying the figure out per event. Empty while no figure
/// is set, which is the state the routing falls back to `controller_` on. /// is set, which is the state the routing falls back to `controller_` on.
std::vector<Rect> row_rects_; std::vector<Rect> row_rects_;
+52 -19
View File
@@ -339,15 +339,31 @@ The split from the grading above is what it costs: grading measures *one* frame'
throws it away, while the density **keeps** what it measured — that is the whole point of it, 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. also why it cannot be a per-frame accumulator.
- **The grid is the screen's, not the data's, and there is one per trace.** One `f32` cell per - **The grid is the screen's, not the data's — bounded — and there is one per trace.** The axes
device pixel of the axes rect (`DensityGrid`, `plot/plot/density.hpp`): the axes of a 640x360 rect in device pixels (`DensityGrid`, `plot/plot/density.hpp`), one `f32` cell each, **up to
viewport is a **584x458** grid of **267 472 cells** and **1 069 888 bytes**, and a thousand `kDensityCellCap` = 262 144 cells** (2^18, a mebibyte of counts): under the cap the grid *is* the
acquisitions through it cost the same. A plot holds one of these for **each trace it draws**, so rect — the axes of a 640x360 viewport is a **573x313** grid of **179 349 cells** and **717 396
the display's memory is that figure times the traces on screen — four signals in one 800x480 row bytes**, and a thousand acquisitions through it cost the same — and past it the rect is scaled
are four grids, 6 MB; a row of eight at 1920x1080 is 66 MB. The colour is the reason: a density down *uniformly*, so a cell stays square and the picture keeps the axes' aspect. A plot holds one
that had one hue for the plot could not say *which* trace put the ink there, and two signals of these for **each trace it draws**, so the display's memory is that figure times the traces on
agreeing in a cell would read as one blended pixel. `Plot::density_grid(trace)` returns a trace's screen; the bound is per *grid*, so four traces hold at most 1 MiB each whatever the window is.
image; a trace with no series (an unbound slot) holds no grid at all. 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 - **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**. `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 Counting the hits instead puts a single dense sweep at the ceiling of the brightness scale and
@@ -383,20 +399,37 @@ also why it cannot be a per-frame accumulator.
reads back through `DensityGrid::ink()`/`peak()` and the picture it sees come from the same reads back through `DensityGrid::ink()`/`peak()` and the picture it sees come from the same
numbers. numbers.
- **Per frame, it is one texture upload and one draw call *per trace* — or one draw call each.** - **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); the one texture is re-filled per The upload is one byte per cell (**179 kB** for the axes above, and at most 256 kB a trace at
trace, and a frame whose grids did not move skips the conversion and the uploads entirely and `kDensityCellCap`, whatever the window); the one texture is re-filled per trace, and a frame whose
pays the quads alone (`FrameStats::density_upload_bytes` reads 0 for it). The decay is what makes grids did not move skips the conversion and the uploads entirely and pays the quads alone
the grids move every frame under `TimeConstant`, and what makes them stand still between (`FrameStats::density_upload_bytes` reads 0 for it). The decay is what makes the grids move every
acquisitions under `PerAcquisition`. 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 `a_density_grid_is_the_axes_in_device_pixels` reads the resolution and the memory back
(**584x458 = 267 472 cells, 1 069 888 bytes**, `ink=584 peak=1` for one flat sweep — the ink of (**578x453 = 261 834 cells, 1 047 336 bytes** on its 640x480 viewport, which is past the cap, and
one sweep *is* the columns it covers), 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 `a_density_halves_per_acquisition_and_keeps_the_newest_brightest` reads the count schedule
(584 → 876 → 1022 on a 584-column grid, i.e. `1 + 1/2 + 1/4` of the width, `peak_steady=2.000`), (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_fades_by_its_time_constant` reads `exp(-dt/tau)` off the ink,
`a_density_holds_one_grid_per_trace` reads two traces' grids apart (**267 472 cells each, `a_density_holds_one_grid_per_trace` reads two traces' grids apart (**261 834 cells each,
ink 584/584**, in two different rows), and 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` `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 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 trace's own colour exactly (`full=224`, the trace's `0xE0` green, against `bg=25`, where the old
+10 -6
View File
@@ -102,12 +102,16 @@ struct Axis {
/// group stops following or the row leaves it. /// group stops following or the row leaves it.
bool live_owner = false; bool live_owner = false;
f64 live_owner_newest = 0.0; f64 live_owner_newest = 0.0;
/// Set while this plot is a member of a `Figure` x link group (`Figure::link_x()` /// Set while this axis is a member of a `Figure` link group. On an x axis that is
/// writes it, `unlink_row()` clears it). The group owns the shared x window *and* /// an x link group (`Figure::link_x()` writes it, `unlink_row()` clears it): the
/// its follow state -- `Figure::link_x()` refuses a `Live` member joiner -- so /// group owns the shared x window *and* its follow state -- `Figure::link_x()`
/// `Plot::set_x_follow(Live)` on this axis is refused after linking too, rather /// refuses a `Live` member joiner -- so `Plot::set_x_follow(Live)` on this axis is
/// than letting a later call put the member's axis back into a state the group /// refused after linking too, rather than letting a later call put the member's
/// cannot drive (see `Plot::set_x_follow()`). /// axis back into a state the group cannot drive (see `Plot::set_x_follow()`).
/// On a y axis it is the row's shared-y group (`Figure::set_share_row_y()`): the
/// group, not the plot, owns the range *and* the auto scale state, so
/// `Plot::autoscale_axes()` skips the axis and the figure fits the union of the
/// members' traces instead.
bool link_member = false; bool link_member = false;
/// Ticks for the last `refresh()`, ascending, majors and (when asked) minors. /// Ticks for the last `refresh()`, ascending, majors and (when asked) minors.
std::vector<Tick> ticks; std::vector<Tick> ticks;
+19
View File
@@ -91,6 +91,14 @@ struct Crosshair {
/// On by default, and free to leave on: a plot with no visible trace has no rows to /// On by default, and free to leave on: a plot with no visible trace has no rows to
/// measure and the panel is then not placed at all. /// measure and the panel is then not placed at all.
bool show_readout = true; bool show_readout = true;
/// Write the two values the crosshair's lines meet the axes at as small badges in the
/// label strips -- the y value beside the y ticks, the x value beside the x ticks, each
/// in its axis' own format. On by default: it is the axis reading an oscilloscope puts
/// where the eye already is, and a host that does not want it turns it off
/// (`Plot::set_crosshair_axis_labels()`). The badges are placed only when the strip they
/// belong to exists and the line they belong to is drawn (a vertical-only crosshair has
/// no y to badge, and a row whose x strip went to a neighbour badges nothing).
bool axis_labels = true;
/// The data x of the vertical line, on x axis 0. /// The data x of the vertical line, on x axis 0.
f64 x = 0.0; f64 x = 0.0;
/// The data y of the horizontal line, on y axis 0. Meaningless for a vertical-only /// The data y of the horizontal line, on y axis 0. Meaningless for a vertical-only
@@ -277,6 +285,17 @@ struct CrosshairLayout {
/// `Crosshair::show_readout` is off, or when the panel could not be placed inside the /// `Crosshair::show_readout` is off, or when the panel could not be placed inside the
/// axes at all. /// axes at all.
Rect panel{}; Rect panel{};
/// The two axis badges, when `Crosshair::axis_labels` is on and the crosshair is active:
/// the y value in the **y label strip**, right-aligned on the axis line at the horizontal
/// line's height, and the x value in the **x label strip**, centred on the vertical
/// line's column -- each in its axis' own format, so a badge reads exactly like the tick
/// labels beside it. Zero-sized (and the text empty) when the flag is off, the crosshair
/// is inactive, the line they belong to is not drawn, their strip does not exist (a row
/// whose x strip went to the row below), or there is no text backend to measure them.
Rect y_axis_badge{};
std::string y_axis_text;
Rect x_axis_badge{};
std::string x_axis_text;
/// One row per visible trace, in trace order -- see `CrosshairRow`. Present whether or /// One row per visible trace, in trace order -- see `CrosshairRow`. Present whether or
/// not the panel could list them, so the markers are drawn even in a plot too small to /// not the panel could list them, so the markers are drawn even in a plot too small to
/// hold a panel. /// hold a panel.
+52 -19
View File
@@ -9,6 +9,29 @@
namespace plot { 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. /// 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 /// The two are answers to two different questions, which is why the choice belongs to the
@@ -89,17 +112,23 @@ struct DensityConfig {
f32 gamma = 1.0f; f32 gamma = 1.0f;
}; };
/// The density's own image: **one `f32` cell per device pixel of the axes rect**, so its /// The density's own image: **the axes rect in device pixels, up to `kDensityCellCap` cells**, so
/// resolution is the screen's and not the data's -- the whole point of accumulating into a grid /// 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. Memory is therefore /// rather than keeping the acquisitions that were drawn into it -- while still being bounded by
/// `columns() * rows() * 4` bytes whatever the trigger has done: an 800x480 axes is 1.5 MB and a /// something other than the window a user has dragged. Under the cap the grid *is* the axes rect and
/// 1920x1080 one is 8.3 MB, and a thousand acquisitions cost the same as one. A plot holds **one /// a cell is a device pixel; past it the rect is scaled down uniformly (see `kDensityCellCap` for
/// grid per trace** (see `DensityConfig`), so a display's memory is that figure times the number of /// the number and the frame budget it comes from), and the renderer magnifies the image back over
/// traces actually drawn -- a plot's unbound slots hold no grid -- and four signals in one 800x480 /// the rect with a linear filter, because a cell is a count of sweeps and not a pixel of a picture.
/// row are four grids, 6 MB, while a 1920x1080 row of eight is 66 MB. The renderer adds one byte ///
/// per cell as the luminance it uploads (`DensityGrid::cells()` /// The bound is what makes the memory and the per-frame cost *numbers* rather than functions of the
/// is `f32` because a **count** is what decays and what `ink()`/`peak()` report; the byte is /// window: `columns() * rows() * 4` bytes whatever the trigger has done **and** whatever the
/// derived per changed frame, not stored here). /// 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 /// 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 /// during one `Plot::accumulate_density()` leaves 1.0 in it -- once, however many of that trace's
@@ -109,21 +138,25 @@ struct DensityConfig {
/// the whole image, so a cell's value is a decaying count of sweeps and not a bounded score. /// 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 /// 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 pixel `(axes.x + c, axes.y + r)`, where `axes` is /// `r * columns() + c` and covers the device span `[axes.x + c*w/columns(), axes.x + (c+1)*w/columns())`
/// the rect the grid was sized to. `revision()` moves on every change, which is what lets the /// by `[axes.y + r*h/rows(), axes.y + (r+1)*h/rows())` -- the device pixel `(axes.x + c, axes.y + r)`
/// renderer skip the upload -- and the whole conversion -- on a frame the grid did not move. /// 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 { class DensityGrid {
public: public:
/// Grid width in cells: the axes rect's width in device pixels, rounded up. 0 until the first /// Grid width in cells: the axes rect's width in device pixels, rounded up, scaled down when the
/// accumulation on a laid-out plot. /// rect is past `kDensityCellCap` (see the class comment). 0 until the first accumulation on a
/// laid-out plot.
u32 columns() const noexcept { return columns_; } u32 columns() const noexcept { return columns_; }
/// Grid height in cells, the axes rect's height in device pixels: `columns() * rows()` cells. /// 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_; } u32 rows() const noexcept { return rows_; }
usize cell_count() const noexcept { usize cell_count() const noexcept {
return static_cast<usize>(columns_) * static_cast<usize>(rows_); return static_cast<usize>(columns_) * static_cast<usize>(rows_);
} }
/// The cells, row-major from the top-left, one per device pixel -- see the class comment for /// The cells, row-major from the top-left, one per cell of the axes rect -- see the class comment
/// the resolution and what one cell's value means. /// for the resolution and what one cell's value means.
std::span<const f32> cells() const noexcept { return cells_; } std::span<const f32> cells() const noexcept { return cells_; }
/// One cell, or 0.0 outside the grid. /// One cell, or 0.0 outside the grid.
f32 at(u32 column, u32 row) const noexcept { f32 at(u32 column, u32 row) const noexcept {
+129 -30
View File
@@ -27,13 +27,21 @@ struct FigureLayout {
/// strips and axes come from its own `Plot::layout()` against this rect, so a row /// strips and axes come from its own `Plot::layout()` against this rect, so a row
/// with a y title keeps its column and no two rows can disagree about chrome. /// with a y title keeps its column and no two rows can disagree about chrome.
std::vector<Rect> rows; std::vector<Rect> rows;
/// The strip the figure's one shared x axis is drawn in: the bottom row's own /// One rect per plot, flattened row-major (row 0's cells left to right, then row 1's,
/// `PlotLayout::x_label_area`, at the bottom of the bottom row's rect and so as /// ...): the **viewport** each `Plot` was given. A row of one cell has exactly its row
/// wide as that row's axes rather than the whole figure. Read back from the row /// rect here; a row of several tiles its rect left to right with `col_gap` between
/// cells, the cells' widths summing to the row's less the gaps.
std::vector<Rect> cells;
/// The strip the figure's bottom x axis is drawn in: the bottom row's own
/// `PlotLayout::x_label_area` (for a bottom row split into several cells, only the **last
/// cell's** strip -- the others' strips are their own `plot(i).layout().x_label_area`, which
/// is where the controller's axis-zoom hit test reads each cell's gestures from), at the
/// bottom of the bottom row's rect and so as wide as that row's axes rather than the whole
/// figure. Read back from the row
/// rather than derived here, so a host and the frame cannot disagree about where /// rather than derived here, so a host and the frame cannot disagree about where
/// the axis is. It is nonzero whenever the figure has rows and the bottom row /// the axis is. It is nonzero whenever the figure has rows and the bottom row
/// reserves a label strip -- including the case where every row labels itself, in /// reserves a label strip. A row above whose columns differ from the row below keeps
/// which case it is simply the bottom row's own strip, one of several. /// a strip of its own (see `Figure`), which is its plots' `x_label_area`, not this.
/// ///
/// It is the rect the controller's axis-zoom hit test reads (`x_label_area` is what /// It is the rect the controller's axis-zoom hit test reads (`x_label_area` is what
/// a middle drag in a label strip enters `DragMode::AxisZoomX` from), which is why /// a middle drag in a label strip enters `DragMode::AxisZoomX` from), which is why
@@ -44,6 +52,10 @@ struct FigureLayout {
/// figure edge to edge, which is the default; it is dropped for a figure too /// figure edge to edge, which is the default; it is dropped for a figure too
/// short to hold it, so the rows never lose their height to a gap. /// short to hold it, so the rows never lose their height to a gap.
f32 row_gap = 0.0f; f32 row_gap = 0.0f;
/// The horizontal gap between two cells of a row, in device pixels. `set_col_gap()`'s
/// value, or 0 when any row holding several cells was too narrow to hold it and
/// dropped it, so the cells never lose their width to a gap.
f32 col_gap = 0.0f;
}; };
/// Several plots stacked in one column, sharing one x axis. /// Several plots stacked in one column, sharing one x axis.
@@ -59,12 +71,15 @@ struct FigureLayout {
/// **The shared x axis.** Row heights are an equal division of the figure by /// **The shared x axis.** Row heights are an equal division of the figure by
/// default, `set_row_weights()` gives a row more of it, and every row keeps a floor /// default, `set_row_weights()` gives a row more of it, and every row keeps a floor
/// height so chrome can never erase a plot. When `set_x_labels_only_on_bottom()` is /// height so chrome can never erase a plot. When `set_x_labels_only_on_bottom()` is
/// on -- the default -- the figure's one x axis is the **bottom row's own**: the rows /// on -- the default -- the x axis is the **bottom row's own** for every run of rows
/// above reserve no x chrome at all, and the bottom row keeps its own label and title /// with the *same columns* (the same number of cells and the same normalised cell
/// strips, so those labels and that title are drawn once, from the bottom row's own /// weights): those rows above reserve no x chrome at all, and the bottom row of the run
/// ticks and transform. That places the shared axis *inside* a row, where the /// keeps its own label and title strips, so those labels and that title are drawn once,
/// controller's axis-zoom hit test can reach it, instead of in a band outside every /// from its own ticks and transform. A row whose columns differ from the row below keeps
/// row that no gesture could land in. /// its own strip, drawn under each of its cells, because the axis below is at another
/// scale and width. The rule is structural, never a function of what a plot holds. That
/// places a shared axis *inside* a row, where the controller's axis-zoom hit test can
/// reach it, instead of in a band outside every row that no gesture could land in.
/// ///
/// **The x link.** `plot(i).set_x_range()` and a controller gesture both reach a /// **The x link.** `plot(i).set_x_range()` and a controller gesture both reach a
/// member's x range the same way, so a figure cannot see either of them happen. /// member's x range the same way, so a figure cannot see either of them happen.
@@ -94,13 +109,30 @@ class Figure {
/// which is the *unlinked* group: rows that share nothing, each with the range /// which is the *unlinked* group: rows that share nothing, each with the range
/// its own plot was given. /// its own plot was given.
explicit Figure(u32 rows); explicit Figure(u32 rows);
/// A figure of `cells_per_row.size()` rows, row `r` holding `cells_per_row[r]` plots side
/// by side (a count of 0 is clamped to 1). Every row of one cell makes this the same
/// figure as `Figure(rows)`. There is deliberately no `initializer_list` constructor, so
/// `Figure{3u}` keeps meaning three rows.
explicit Figure(std::span<const u32> cells_per_row);
u32 row_count() const noexcept { return static_cast<u32>(plots_.size()); } /// The number of figure rows -- the bands stacked top to bottom -- which equals
/// One row's plot. A row that does not exist yields a default-constructed `Plot` /// `plot_count()` for a figure no row of which is split.
/// rather than reading out of bounds -- the rule every accessor on `Plot` follows u32 row_count() const noexcept { return static_cast<u32>(cells_per_row_.size()); }
/// -- so a stale row can only ever affect itself. /// The number of plots, i.e. cells over all rows. `plot()`, `link_x()`, `x_link()` and
Plot& plot(u32 row) noexcept; /// the hovered plot of `set_crosshair()` index this flattened (row-major) range.
const Plot& plot(u32 row) const noexcept; u32 plot_count() const noexcept { return static_cast<u32>(plots_.size()); }
/// How many cells row `row` holds; 0 for a row that does not exist.
u32 cells_in_row(u32 row) const noexcept;
/// The flattened index of row `row`'s first cell; `plot_count()` for `row ==
/// row_count()` and for anything beyond.
u32 first_plot_of_row(u32 row) const noexcept;
/// The row that holds flattened plot `plot` (the last row for an index past the end).
u32 row_of_plot(u32 plot) const noexcept;
/// One plot, by flattened cell index. A plot that does not exist yields a
/// default-constructed `Plot` rather than reading out of bounds -- the rule every
/// accessor on `Plot` follows -- so a stale index can only ever affect itself.
Plot& plot(u32 index) noexcept;
const Plot& plot(u32 index) const noexcept;
// --- geometry --------------------------------------------------------- // --- geometry ---------------------------------------------------------
@@ -119,6 +151,21 @@ class Figure {
void clear_row_weights() noexcept { row_weights_.clear(); } void clear_row_weights() noexcept { row_weights_.clear(); }
bool has_row_weights() const noexcept { return !row_weights_.empty(); } bool has_row_weights() const noexcept { return !row_weights_.empty(); }
/// How much of row `row`'s width each of its cells gets: `weights[i]` is cell i's
/// share of what is left after every cell has its floor width (64 logical pixels,
/// capped at an equal share of a narrow row). Normalised. Refused, changing nothing,
/// when the row does not exist, the count is not the row's cell count, or a weight is
/// not a positive finite number. A row with no weights divides equally.
Status set_cell_weights(u32 row, std::span<const f32> weights);
/// Back to an equal division of row `row`; a row that does not exist is ignored.
void clear_cell_weights(u32 row) noexcept;
bool has_cell_weights(u32 row) const noexcept;
/// The horizontal gap left between two cells of a row, in device pixels (0.0 by
/// default). Negative values are clamped to 0, and a gap too wide for a row to hold is
/// dropped for that layout rather than squeezing the cells to nothing.
void set_col_gap(f32 device_px) noexcept { col_gap_ = device_px > 0.0f ? device_px : 0.0f; }
f32 col_gap() const noexcept { return col_gap_; }
/// The gap left between two rows, in device pixels (0.0 by default, so the rows /// The gap left between two rows, in device pixels (0.0 by default, so the rows
/// tile the figure). Negative values are clamped to 0, and a gap too large for the /// tile the figure). Negative values are clamped to 0, and a gap too large for the
/// figure to hold is dropped for that layout rather than squeezing the rows to /// figure to hold is dropped for that layout rather than squeezing the rows to
@@ -143,14 +190,17 @@ class Figure {
/// than row by row for the left gutter's reason: the rows' axes have to line up, and /// 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 /// 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. /// `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 { void set_right_gutter(f32 device_px) noexcept {
right_gutter_ = device_px > 0.0f ? device_px : 0.0f; right_gutter_ = device_px > 0.0f ? device_px : 0.0f;
} }
f32 right_gutter() const noexcept { return right_gutter_; } f32 right_gutter() const noexcept { return right_gutter_; }
/// Whether the shared x axis is the figure's: every row reserves no x label strip /// Whether rows with the same columns as the row below hand it their x axis (the
/// and one shared axis is drawn under the last row (the default), or every row /// default; a row with different columns keeps its own), or every row draws its own
/// draws its own labels and the figure draws nothing extra. /// labels and the figure draws nothing extra.
void set_x_labels_only_on_bottom(bool on) noexcept { labels_bottom_only_ = on; } void set_x_labels_only_on_bottom(bool on) noexcept { labels_bottom_only_ = on; }
bool x_labels_only_on_bottom() const noexcept { return labels_bottom_only_; } bool x_labels_only_on_bottom() const noexcept { return labels_bottom_only_; }
@@ -195,17 +245,21 @@ class Figure {
/// is not a number; such a call therefore leaves every row's crosshair as it was. /// is not a number; such a call therefore leaves every row's crosshair as it was.
void set_crosshair(f64 x, bool active); void set_crosshair(f64 x, bool active);
/// Places the crosshair with the pointer's **row** and that row's local y (spec §17): the /// Places the crosshair with the pointer's **plot** and that plot's local y (spec §17): the
/// shared x goes to every row -- vertical lines everywhere, as the plain overload does -- and /// shared x goes to every plot -- vertical lines everywhere, as the plain overload does -- and
/// the horizontal line is drawn **only in `hovered_row`**, where its y is a value of *that* /// the horizontal line is drawn in `hovered_row` -- the **flattened plot index**, not the
/// row's own scale. It is the fix for the reported defect -- a figure showing no horizontal /// figure-row index; they differ when a row holds several cells -- where its y is a value of
/// *that* plot's own scale. **When the row is a split whose cells share one y
/// (`set_share_row_y`), the line is drawn in every cell of that row**: it is one value on one
/// scale, so stopping it at the divider would hide the number the neighbouring cell is read
/// against. Rows never share y with each other, so no other row gets a line. It is the fix for the reported defect -- a figure showing no horizontal
/// line at all -- without inventing a figure-wide y, which would mean a different value in /// line at all -- without inventing a figure-wide y, which would mean a different value in
/// every row: the line appears where the pointer is, and no row ever shows another row's y. /// every row: the line appears where the pointer is, and no row ever shows another row's y.
/// ///
/// `y_in_row` is a data coordinate on `hovered_row`'s y axis 0, exactly as `Crosshair::y`'s /// `y_in_row` is a data coordinate on `hovered_row`'s y axis 0, exactly as `Crosshair::y`'s
/// two-coordinate placement is; a host reads it from the row's own y transform at the /// two-coordinate placement is; a host reads it from the row's own y transform at the
/// pointer's pixel row. `hovered_row` outside `[0, row_count())` is treated as "no row under /// pointer's pixel row. `hovered_row` outside `[0, plot_count())` is treated as "no plot under
/// the pointer": every row gets the vertical line and none gets a horizontal one, the same /// the pointer": every plot gets the vertical line and none gets a horizontal one, the same
/// state the single-coordinate overload produces. /// state the single-coordinate overload produces.
/// ///
/// Refused, changing nothing, when `x` is not a number -- matching `set_crosshair(x, active)` /// Refused, changing nothing, when `x` is not a number -- matching `set_crosshair(x, active)`
@@ -226,9 +280,10 @@ class Figure {
/// How many groups there are, excluding the unlinked group 0. /// How many groups there are, excluding the unlinked group 0.
u32 x_link_count() const noexcept { return static_cast<u32>(links_.size()); } u32 x_link_count() const noexcept { return static_cast<u32>(links_.size()); }
/// Moves one row into a link group and applies that group's range to it at once, /// Moves one plot into a link group and applies that group's range to it at once; `row`
/// so the row is on the group's window from this call rather than from the next /// is the **flattened plot index** (see `plot_count()`), which is the row index only for a
/// frame. `group` 0 takes the row back out of its group, leaving it with the range /// figure with one cell per row. The plot is on the group's window from this call rather
/// than from the next frame. `group` 0 takes the plot back out of its group, leaving it with the range
/// it holds now. /// it holds now.
/// ///
/// A group with no range yet -- one that no row and no `set_x_link_range()` has /// A group with no range yet -- one that no row and no `set_x_link_range()` has
@@ -242,7 +297,7 @@ class Figure {
/// per-row window. `XFollow::Off` is the group's to set /// per-row window. `XFollow::Off` is the group's to set
/// (`set_x_link_follow()`), not the axis'. /// (`set_x_link_follow()`), not the axis'.
Status link_x(u32 row, u32 group); Status link_x(u32 row, u32 group);
/// The group a row is in, or 0 when it is in none. /// The group the plot with flattened index `row` is in, or 0 when it is in none.
u32 x_link(u32 row) const noexcept; u32 x_link(u32 row) const noexcept;
/// Sets a group's shared range and pushes it to every member. /// Sets a group's shared range and pushes it to every member.
@@ -269,6 +324,22 @@ class Figure {
/// A group's follow state. `Off` for group 0 and for a group that does not exist. /// A group's follow state. `Off` for group 0 and for a group that does not exist.
XFollow x_link_follow(u32 group) const noexcept; XFollow x_link_follow(u32 group) const noexcept;
/// Makes the cells of every split row (two or more) share their y axis 0: one range
/// and one auto scale state per row, default off. A range set on any member -- a
/// wheel, a drag, `Plot::set_y_range()` -- becomes the row's and is pushed to all of
/// its cells at the next `layout()`; the row is auto or manual as one, and while it is
/// auto it fits the union of every cell's visible traces (a cell with none is ignored;
/// padding and log handling are the plot's own, via `Plot::fit_y_bounds()`). The group,
/// not the plot, owns auto scale: members' own autoscale is suppressed, because each
/// would fit only its own traces and the cells would fight over the range. Turning it
/// on or off forgets the groups' state; a row of one cell is never a group.
void set_share_row_y(bool on);
bool share_row_y() const noexcept { return share_row_y_; }
/// Whether figure row `row` is auto scaled as a group. True for a row that is not a
/// group -- fewer than two cells, sharing off, or a row that does not exist -- so
/// "auto" is also the answer when there is no shared state to be manual.
bool row_y_auto(u32 row) const noexcept;
private: private:
/// One link group: the range its members share, the scroll state behind that /// One link group: the range its members share, the scroll state behind that
/// range while it follows, the rows in it (ascending) and, in step with them, the /// range while it follows, the rows in it (ascending) and, in step with them, the
@@ -325,7 +396,35 @@ class Figure {
/// a following group's window, push both to all members. /// a following group's window, push both to all members.
void sync_x_links(); void sync_x_links();
/// A split row's shared y axis: the range last pushed to its cells (the value the
/// next frame compares each cell against, so a gesture is told from the group's own
/// write -- the same device as `XLink::pushed`), whether the group has been
/// initialised from its first cell, and whether it is auto scaled.
struct RowY {
Range pushed{};
bool have = false;
bool auto_scale = true;
};
/// Advances every split row's y group for this frame: adopt what a cell was changed
/// to (a range or an auto flag), fit the union of the cells' traces while the group
/// is auto, push the result to all cells. Runs before any `Plot::layout()`, which is
/// where a plot would otherwise autoscale on its own.
void sync_row_y();
/// Fills the row bookkeeping (`cells_per_row_`, `row_first_`, `cell_weights_`) from a
/// per-row cell count, clamping each to at least one. Does not touch `plots_`, which the
/// constructors size from `total_cells()`.
void build_cells(std::span<const u32> cells);
std::vector<Plot> plots_; std::vector<Plot> plots_;
std::vector<u32> cells_per_row_;
/// Prefix sums of `cells_per_row_`: `rows + 1` entries, `row_first_[r]` the flattened
/// index of row r's first cell and the last entry the plot count.
std::vector<u32> row_first_;
std::vector<std::vector<f32>> cell_weights_;
bool share_row_y_ = false;
std::vector<RowY> row_y_;
f32 col_gap_ = 0.0f;
/// What `plot()` yields for a row that does not exist, so a stale row can be used /// What `plot()` yields for a row that does not exist, so a stale row can be used
/// without reading out of bounds. Owned rather than a static, so one figure's /// without reading out of bounds. Owned rather than a static, so one figure's
/// stray row can never write into another's. /// stray row can never write into another's.
+63 -14
View File
@@ -40,6 +40,13 @@ namespace plot {
/// are for a host that wants one without the other. /// are for a host that wants one without the other.
enum class XStrip { Both, LabelsOnly, TitleOnly, None }; enum class XStrip { Both, LabelsOnly, TitleOnly, None };
/// Which parts of the left y chrome `Plot::layout()` reserves. `Both` is a plot drawn on
/// its own. `None` reserves no label width, no title column and ignores the figure's common
/// gutter: it is the mode of every cell of a split row but the first when the figure shares
/// the row's y axis (`Figure::set_share_row_y`), where the first cell draws the one axis.
/// The cell's ticks still refresh and its grid still builds.
enum class YStrip { Both, None };
/// Whether a strip mode reserves the label strip. One rule, read by `Plot::layout()` /// Whether a strip mode reserves the label strip. One rule, read by `Plot::layout()`
/// when it sizes the strip and by the renderer when it decides whether to stage the /// when it sizes the strip and by the renderer when it decides whether to stage the
/// labels -- a row that reserves none would otherwise stage them over its own data. /// labels -- a row that reserves none would otherwise stage them over its own data.
@@ -238,6 +245,12 @@ class Plot {
void set_x_strip_mode(XStrip mode) noexcept { x_strip_ = mode; } void set_x_strip_mode(XStrip mode) noexcept { x_strip_ = mode; }
XStrip x_strip_mode() const noexcept { return x_strip_; } XStrip x_strip_mode() const noexcept { return x_strip_; }
/// Which parts of the left y chrome `layout()` reserves; `YStrip::Both` by default. With
/// `YStrip::None` the y label width, the y title column and the figure's common gutter are
/// all zero, and the renderer stages no y labels or title (`y_label_area.w == 0`).
void set_y_strip_mode(YStrip mode) noexcept { y_strip_ = mode; }
YStrip y_strip_mode() const noexcept { return y_strip_; }
/// The font layout() measures tick labels with. Borrowed, not owned, and may be /// The font layout() measures tick labels with. Borrowed, not owned, and may be
/// null -- then the label areas fall back to PlotLayout's defaults and no string /// null -- then the label areas fall back to PlotLayout's defaults and no string
/// is measured: a plot with no renderer lays out with whatever the host sets /// is measured: a plot with no renderer lays out with whatever the host sets
@@ -435,6 +448,20 @@ class Plot {
/// does not exist. Not for callers outside the figure: a `Plot` that is not in a /// does not exist. Not for callers outside the figure: a `Plot` that is not in a
/// figure never has a linked axis. /// figure never has a linked axis.
void set_x_link_member(u32 axis, bool linked) noexcept; void set_x_link_member(u32 axis, bool linked) noexcept;
/// The y-side twin of `set_x_link_member()`: publishes that this y axis is a member
/// of its row's shared-y group (`Figure::set_share_row_y()`). A linked y axis is
/// never fitted by `layout()` -- the group owns its range and auto scale, and fits
/// the union of every member's traces -- but a gesture or `set_y_range()` still
/// writes it, which is how the group learns of a change. A no-op for an axis that
/// does not exist. Not for callers outside the figure.
void set_y_link_member(u32 axis, bool linked) noexcept;
/// Union of the bounds of the visible traces bound to y axis `axis`: what a shared-y
/// group collects from each member. False -- and `out` untouched -- when none.
bool y_data_bounds(u32 axis, Range& out) const;
/// Fits y axis `axis` to `bounds` the way `auto_fit_y()` fits it to its own traces
/// (same padding, same log handling), leaving `auto_scale` alone. False -- range
/// untouched -- when the axis does not exist or cannot be fitted to `bounds`.
bool fit_y_bounds(u32 axis, Range bounds);
/// Data -> pixels for one axis, built from the axis' range, log flag and the /// Data -> pixels for one axis, built from the axis' range, log flag and the
/// current layout. Always invertible: a collapsed range or a collapsed axes /// current layout. Always invertible: a collapsed range or a collapsed axes
@@ -530,6 +557,14 @@ class Plot {
/// belongs to. An id that names no cursor is ignored, like every other setter here. /// belongs to. An id that names no cursor is ignored, like every other setter here.
void set_cursor_color(u32 id, Color color); void set_cursor_color(u32 id, Color color);
/// A cursor's caption (see `Cursor::label`): a vertical marker's badge text -- a horizontal
/// ruler writes its own value and needs no caption to be readable. Rendered on a
/// backplate, and labels whose backplates meet are merged into one panel with one line per
/// label (see the renderer), which is what keeps a marker pair half a division apart
/// readable instead of a smear of two texts over one column. An empty string removes the
/// caption; an id that names no cursor is ignored, like every other setter here.
void set_cursor_label(u32 id, std::string label);
/// A cursor's stroke: its logical width (`<= 0` for the style's default) and its dash /// A cursor's stroke: its logical width (`<= 0` for the style's default) and its dash
/// (`dash.off <= 0` for solid). The instrument's own, like its colour -- see `Cursor::width`. /// (`dash.off <= 0` for solid). The instrument's own, like its colour -- see `Cursor::width`.
void set_cursor_stroke(u32 id, f32 width, Vec2 dash); void set_cursor_stroke(u32 id, f32 width, Vec2 dash);
@@ -590,8 +625,8 @@ class Plot {
/// default -- is the plot exactly as it was: no grid is allocated, no geometry is deposited /// default -- is the plot exactly as it was: no grid is allocated, no geometry is deposited
/// and no frame uploads anything. /// and no frame uploads anything.
/// ///
/// On, the plot keeps a **screen-sized intensity grid behind each of its traces**, in that /// On, the plot keeps an intensity grid behind each of its traces, in that trace's own colour
/// trace's own colour (see `DensityGrid` for the resolution and per-cell meaning, and /// (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 /// `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 /// 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 /// renderer draws **no solid trace** over it: the trace ink *is* the image, so the newest
@@ -603,8 +638,10 @@ class Plot {
/// ///
/// Like `set_invalid_spans()` it reserves **no layout space** and nothing on it can be picked: /// 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 is drawn inside the axes it was sized to, and a pointer neither finds it nor is found by
/// it. Unlike a band it needs no one-pixel floor on its own size -- a cell *is* a device pixel /// it. A cell is a device pixel of that axes rect while the rect is under `kDensityCellCap` and
/// by construction, which is the smallest mark the rule could ask for. /// 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 /// **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 /// has stopped paying for the display is not left holding a stale image of it, which is the
@@ -640,11 +677,12 @@ class Plot {
/// a layout: a plot that has never been laid out has no axes rect to size the grid to and /// a layout: a plot that has never been laid out has no axes rect to size the grid to and
/// deposits nothing. /// deposits nothing.
/// ///
/// The grid is sized to that axes rect, **one `f32` cell per device pixel**, and a grid of the /// The grid is sized to that axes rect, **the rect in device pixels up to `kDensityCellCap`
/// wrong size is reallocated -- which drops what it held, because the image is a screen-sized /// cells** (`DensityGrid`), and a grid of the wrong size is reallocated -- which drops what it
/// one and a new screen is a new image. Under `DensityDecay::PerAcquisition` the fade happens /// held, because the image is of the axes rect and a new rect is a new image. Under
/// here, before the deposit, so the acquisition just drawn is the brightest. A NaN sample is a /// `DensityDecay::PerAcquisition` the fade happens here, before the deposit, so the acquisition
/// gap: it deposits nothing and joins nothing, the library-wide policy. /// just drawn is the brightest. A NaN sample is a gap: it deposits nothing and joins nothing,
/// the library-wide policy.
void accumulate_density(); void accumulate_density();
/// Fades the grid by `dt_s` of **stream seconds** -- the clock the x axis reads, so a caller /// Fades the grid by `dt_s` of **stream seconds** -- the clock the x axis reads, so a caller
@@ -858,6 +896,14 @@ class Plot {
/// **not** part of `set_crosshair()`, so placing a hover cannot silently change it. /// **not** part of `set_crosshair()`, so placing a hover cannot silently change it.
void set_crosshair_readout(bool on) noexcept { crosshair_.show_readout = on; } void set_crosshair_readout(bool on) noexcept { crosshair_.show_readout = on; }
/// Writes or clears the two **axis badges** (see `Crosshair::axis_labels`): the y value in
/// the y label strip and the x value in the x label strip, at the heights and columns the
/// crosshair's lines meet the axes at. Takes effect on the next `layout()`. **Not** part of
/// `set_crosshair()`, for the same reason the readout flag is not.
void set_crosshair_axis_labels(bool on) noexcept { crosshair_.axis_labels = on; }
/// The axis-badge flag, as set.
bool crosshair_axis_labels() const noexcept { return crosshair_.axis_labels; }
/// Overrides this plot's crosshair stroke and dash, in logical pixels, without touching the /// Overrides this plot's crosshair stroke and dash, in logical pixels, without touching the
/// global style every other plot draws with (spec §17): each field left negative in `s` /// global style every other plot draws with (spec §17): each field left negative in `s`
/// defers to the matching `Style::crosshair_*` metric, so `CrosshairStyle{}` (the default) /// defers to the matching `Style::crosshair_*` metric, so `CrosshairStyle{}` (the default)
@@ -1057,6 +1103,8 @@ class Plot {
std::string x_note_; std::string x_note_;
/// Which parts of the bottom x chrome are reserved at all; see set_x_strip_mode(). /// Which parts of the bottom x chrome are reserved at all; see set_x_strip_mode().
XStrip x_strip_ = XStrip::Both; XStrip x_strip_ = XStrip::Both;
/// Which parts of the left y chrome are reserved at all; see set_y_strip_mode().
YStrip y_strip_ = YStrip::Both;
/// The floor a figure imposes on the left gutter; see set_min_y_gutter(). 0.0 means /// The floor a figure imposes on the left gutter; see set_min_y_gutter(). 0.0 means
/// none, which is what a plot standing on its own keeps. /// none, which is what a plot standing on its own keeps.
f32 min_y_gutter_ = 0.0f; f32 min_y_gutter_ = 0.0f;
@@ -1075,11 +1123,12 @@ class Plot {
std::vector<Region> regions_; std::vector<Region> regions_;
std::vector<InvalidSpan> invalid_spans_; std::vector<InvalidSpan> invalid_spans_;
/// The phosphor density: its configuration, the grids it accumulates into, and the scratch the /// 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 /// one visible window being deposited is gathered into. **One grid per trace** -- the image is of
/// screen-sized but each trace's is tinted with that trace's own colour (see `DensityConfig`), so /// 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. The vector tracks /// the memory is `traces * columns * rows * 4` bytes and not one plot's worth -- and each is
/// `traces_` and outlives the frames its images were drawn in, which is the whole point of /// bounded by `kDensityCellCap`. The vector tracks `traces_` and outlives the frames its images
/// accumulating into a screen-sized image rather than retaining traces. /// were drawn in, which is the whole point of accumulating into an image rather than retaining
/// traces.
DensityConfig density_{}; DensityConfig density_{};
std::vector<DensityGrid> density_grids_; std::vector<DensityGrid> density_grids_;
/// Scratch for `deposit_density_trace()`: interleaved (x, y) of one gathered window, re-used /// Scratch for `deposit_density_trace()`: interleaved (x, y) of one gathered window, re-used
+5 -4
View File
@@ -104,10 +104,11 @@ class DensityProgram {
/// `u_viewport`: render target size in pixels. /// `u_viewport`: render target size in pixels.
void set_viewport(Vec2 px) const noexcept; 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 /// `u_origin` and `u_size`: the quad in `Rect` pixels -- the axes rect's top-left corner and its
/// the grid's own size, which is the axes rect in whole device pixels. They are two uniforms /// size, which is the area the image is drawn over (its own grid may hold fewer cells than the
/// rather than a rect because a `vec4` uniform would need a fourth setter shape for one /// rect has pixels: see `kDensityCellCap`). They are two uniforms rather than a rect because a
/// caller; a corner and a size are what the vertex shader builds the quad from anyway. /// `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; void set_rect(Vec2 origin, Vec2 size) const noexcept;
/// `u_color`: the tint the cells' luminances are multiplied into. /// `u_color`: the tint the cells' luminances are multiplied into.
void set_color(Color color) const noexcept; void set_color(Color color) const noexcept;
+62 -20
View File
@@ -95,6 +95,24 @@ struct FrameStats {
/// a guard -- and the frame reports it (see `last_frame_had_gl_error()`). /// a guard -- and the frame reports it (see `last_frame_had_gl_error()`).
/// ///
/// Not thread-safe, and confined to the thread that owns the current context. /// Not thread-safe, and confined to the thread that owns the current context.
/// One cursor label's measured text and placed badge: what `Renderer::collect_cursor_label()`
/// builds and `Renderer::draw_clustered_cursor_labels()` places and draws. A rendering
/// detail of the cursor labels' backplates, declared at namespace scope only because a
/// private nested type cannot be named in an out-of-class member definition's signature.
struct CursorLabel {
std::string text; ///< the one line the backplate carries
Color border{}; ///< the panel's border: the cursor's own colour
f32 anchor = 0.0f; ///< the coordinate the label belongs to (x for a marker, y for a ruler)
f32 w = 0.0f; ///< the measured text width
bool horizontal = false;
/// The placed pen and backplate: centred on the anchor across the mark, nudged onto a
/// free row when a close neighbour's badge is in the way, and clustered into one panel
/// by `draw_clustered_cursor_labels()`.
Vec2 pen{};
Rect box{};
};
class Renderer { class Renderer {
public: public:
Renderer() noexcept = default; Renderer() noexcept = default;
@@ -238,12 +256,13 @@ class Renderer {
/// exactly as a stand-alone plot is, and `draw(Plot&)` itself is unchanged for /// exactly as a stand-alone plot is, and `draw(Plot&)` itself is unchanged for
/// single-plot hosts. /// single-plot hosts.
/// The figure's one shared x axis is the **bottom row's own**, so it is drawn by /// A shared x axis is the **bottom row's own** (of a run of rows with the same
/// that row's normal path: when the figure says labels belong on the bottom only /// columns), so it is drawn by that row's normal path: when the figure says labels
/// (the default), the rows above reserve no x chrome and the bottom row stages its /// belong on the bottom only (the default), the rows above that match the row below
/// own labels and title once, inside its own strip -- the rect /// reserve no x chrome and the bottom row stages its own labels and title once,
/// `FigureLayout::x_axis_strip` reads back. With that switched off, every row keeps /// inside its own strip -- the rect `FigureLayout::x_axis_strip` reads back. A row
/// its own chrome and each draws its own labels; there is then no shared axis. /// whose columns differ keeps and draws its own; with that switched off, every row
/// does and there is no shared axis.
/// Styling, the text backend and `ui_scale` are installed on **every** row before /// Styling, the text backend and `ui_scale` are installed on **every** row before
/// the figure computes its layout, so the strips the rows measure and the strokes /// the figure computes its layout, so the strips the rows measure and the strokes
@@ -533,13 +552,17 @@ class Renderer {
/// ///
/// Each trace's image reaches the GPU as an `GL_R8` texture, one texel per cell, converted here /// 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 /// 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. A frame /// neither, so the numbers a host reads back and the picture it sees are one derivation. The
/// whose grids did not change skips the conversion *and* the uploads and costs the draw calls, /// texture is magnified over the axes rect with a linear filter, because past `kDensityCellCap` a
/// which `FrameStats::density_upload_bytes` says per frame. /// 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); 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 /// 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 one /// address are the ones already uploaded) and draws it over the axes rect in `tint` -- the rect,
/// place the density conversion lives, shared by every trace of `draw_density()`. /// 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, void draw_density_grid(const DensityConfig& config, const DensityGrid& grid, Color tint,
const PlotLayout& layout); const PlotLayout& layout);
/// `Plot::invalid_spans()`, as `PlotLayout::invalid_spans` measured them: one fill per span, /// `Plot::invalid_spans()`, as `PlotLayout::invalid_spans` measured them: one fill per span,
@@ -616,13 +639,22 @@ class Renderer {
/// the clip, and a range maximum that undershoots the edge by a ulp is not mistaken for /// the clip, and a range maximum that undershoots the edge by a ulp is not mistaken for
/// off the plot. /// off the plot.
void draw_cursors(const Plot& plot, const PlotLayout& layout); void draw_cursors(const Plot& plot, const PlotLayout& layout);
/// One cursor's label: the caller's caption for either orientation, and for a horizontal /// One cursor's label, measured rather than drawn: a horizontal ruler's line is the value
/// ruler the value itself, written in its y axis' own tick format (`Axis::format_value()`) /// itself, written in its y axis' own tick format (`Axis::format_value()`) -- the number a
/// -- the number a ruler exists to be read off. A vertical cursor's caption starts at the /// ruler exists to be read off -- with the caption first on the line when there is one; a
/// top of the axes beside its marker; a ruler's text starts at the axes' left edge on its /// vertical cursor's line is its caption, beside the marker at the top of the axes. The
/// own row, with the caption first on that line when there is one. /// label is then placed (centred on its mark, stacked clear of close neighbours) and drawn
void draw_cursor_label(const Plot& plot, const PlotLayout& layout, const Cursor& cursor, /// clustered (`draw_clustered_cursor_labels()`), which is what keeps a pair of readout
f32 at); /// marks half a division apart readable: two texts over one column is a smear, one panel
/// with two lines is a readout.
void collect_cursor_label(const Plot& plot, const PlotLayout& layout, const Cursor& cursor,
f32 at, std::vector<CursorLabel>& labels);
/// Draws the placed labels' backplates -- one panel per cluster of meeting boxes, every
/// line at its own placed pen -- and stages the lines. Clustering is what keeps a pair of
/// readout marks half a division apart readable: two texts over one column is a smear, one
/// panel with two lines is a readout.
void draw_clustered_cursor_labels(const PlotLayout& layout,
const std::vector<CursorLabel>& labels);
/// The regions: the fill, one bound per edge, the optional label. Every rect comes /// The regions: the fill, one bound per edge, the optional label. Every rect comes
/// from `PlotLayout::regions` -- the same geometry the controller hit-tests -- so /// from `PlotLayout::regions` -- the same geometry the controller hit-tests -- so
/// a region is drawn where it can be grabbed, and neither side derives the other's /// a region is drawn where it can be grabbed, and neither side derives the other's
@@ -651,8 +683,12 @@ class Renderer {
/// outermost pixels the fill covers (the crisp-hairline rule -- a border drawn on the measured /// outermost pixels the fill covers (the crisp-hairline rule -- a border drawn on the measured
/// rect's fractional edges is lit half-heartedly, and a frame bright on three sides and dim on the /// rect's fractional edges is lit half-heartedly, and a frame bright on three sides and dim on the
/// fourth reads as a mistake). One helper, two callers: the border's colour is the only thing a /// fourth reads as a mistake). One helper, two callers: the border's colour is the only thing a
/// legend panel and a readout panel do not share. /// legend panel and a readout panel do not share. `background_alpha` scales the frame's own
void draw_readout_panel(const Rect& panel, Color border); /// background under the border: the legend and the crosshair's readout keep the shared
/// translucent backplate (what they cover stays faintly visible), while a *badge* -- a
/// cursor's value box, the crosshair's axis badges -- is read against whatever it sits on
/// (ticks, a noisy trace) and is drawn opaque.
void draw_readout_panel(const Rect& panel, Color border, float background_alpha);
/// One trace's colour swatch in `box`, opaque whatever the trace's own alpha is: a swatch drawn at /// One trace's colour swatch in `box`, opaque whatever the trace's own alpha is: a swatch drawn at
/// a trace's 15% would be the least legible thing in the frame, and the swatch is the one element /// a trace's 15% would be the least legible thing in the frame, and the swatch is the one element
/// that says which colour an entry or a readout row *is*. The legend's entries and the crosshair's /// that says which colour an entry or a readout row *is*. The legend's entries and the crosshair's
@@ -682,6 +718,12 @@ class Renderer {
/// Drawn after every other interior pass -- the legend included -- because it is what the /// Drawn after every other interior pass -- the legend included -- because it is what the
/// pointer is on: a readout the legend covered would be the feature failing at its one job. /// pointer is on: a readout the legend covered would be the feature failing at its one job.
void draw_crosshair_readout(const Plot& plot, const PlotLayout& layout); void draw_crosshair_readout(const Plot& plot, const PlotLayout& layout);
/// The crosshair's **axis badges** (`CrosshairLayout::y_axis_badge`/`x_axis_badge`): the
/// values the two lines meet the axes at, each on the shared readout backplate with its
/// border in the crosshair's colour, drawn over the tick labels they sit among. Called
/// from `draw_plot()` after the label strips are drawn, which is the only order in which a
/// badge in a strip can sit on the ticks and still be read.
void draw_crosshair_axis_badges(const PlotLayout& layout);
/// The host's box-zoom frame (`PlotLayout::selection`): a fill at `selection_fill` under a border at /// The host's box-zoom frame (`PlotLayout::selection`): a fill at `selection_fill` under a border at
/// `selection_edge`, or nothing at all when nothing was swept. Drawn last of the interior passes, so /// `selection_edge`, or nothing at all when nothing was swept. Drawn last of the interior passes, so
+3 -3
View File
@@ -76,9 +76,9 @@ extern const char* const kTextFS;
/// nothing to upload (the density's own bytes go to a texture, not to a buffer). /// 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` /// 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 grid's own columns and rows, so one texel /// pixels), `u_size` (vec2, its size in pixels -- the axes rect the image is drawn over, which the
/// is one pixel on screen and no filtering is needed), `u_color` (vec4), `u_density` (sampler2D, /// texture's cells are spread across: one texel a cell while the rect is under `kDensityCellCap`,
/// unit 0). /// 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 /// 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 /// is the whole of the brightness mapping: a cell's count was turned into a luminance byte on the
+338 -38
View File
@@ -23,6 +23,88 @@ namespace {
/// wants a taller floor gives the figure a taller viewport, or gives the rows weights. /// wants a taller floor gives the figure a taller viewport, or gives the rows weights.
constexpr f32 kMinRowHeightLogical = 24.0f; constexpr f32 kMinRowHeightLogical = 24.0f;
/// The width a cell never drops below, in logical pixels, converted exactly as the row
/// height floor is. It yields to the row -- capped at an equal share of the width there
/// is -- so a row too narrow for its cells still tiles instead of overflowing.
constexpr f32 kMinCellWidthLogical = 64.0f;
/// The number of plots a per-row cell count asks for, each row holding at least one.
u32 total_cells(std::span<const u32> cells) noexcept {
u32 total = 0;
for (const u32 c : cells) total += std::max<u32>(c, 1u);
return total;
}
/// Splits the row rect `r` into `n` cells in `out[0..n)` and returns the gap actually used.
/// The cells tile `r` exactly: the gap is dropped when `r` cannot hold it, every cell first
/// gets the floor width and then its weight's share of what is left, and the last cell
/// closes the row on its right edge, absorbing the rounding of the ones before it.
f32 place_cells(const Rect& r, u32 n, const std::vector<f32>& weights, f32 col_gap,
f32 ui_scale, Rect* out) {
if (n == 1u) {
out[0] = r;
return col_gap;
}
f32 gap = col_gap;
f32 usable = r.w - gap * static_cast<f32>(n - 1u);
if (usable <= 0.0f) {
gap = 0.0f;
usable = r.w;
}
usable = std::max(0.0f, usable);
const f32 floor_device = ui_scale > 0.0f ? kMinCellWidthLogical * ui_scale
: kMinCellWidthLogical;
const f32 floor_px = std::min(floor_device, usable / static_cast<f32>(n));
const f32 extra = usable - floor_px * static_cast<f32>(n);
f32 total = 0.0f;
const bool weighted = weights.size() == n;
if (weighted) {
for (const f32 w : weights) total += w;
}
f32 x = r.x;
f32 consumed = 0.0f;
for (u32 i = 0; i < n; ++i) {
const f32 share = weighted && total > 0.0f ? weights[i] / total : 1.0f / static_cast<f32>(n);
const f32 end = i + 1u == n ? usable : consumed + floor_px + extra * share;
out[i] = Rect{x, r.y, end - consumed, r.h};
consumed = end;
x = out[i].right() + gap;
}
return gap;
}
/// The share of the row each of its `n` cells is given by `weights`, exactly as `place_cells`
/// reads them: an empty (or wrongly sized, or non-positive-total) vector means equal shares.
std::vector<f32> normalised_shares(const std::vector<f32>& weights, u32 n) {
std::vector<f32> shares(n, 1.0f / static_cast<f32>(n));
f32 total = 0.0f;
if (weights.size() == n) {
for (const f32 w : weights) total += w;
}
if (total > 0.0f) {
for (u32 i = 0; i < n; ++i) shares[i] = weights[i] / total;
}
return shares;
}
/// Whether two rows have the **same columns**: the same number of cells and the same
/// normalised cell weights (within 1e-4). The rows are all the figure's width, so such rows
/// get cells with identical x extents -- the same y gutter, gap, floors and right gutter
/// apply to both -- and one row's x axis serves the other. Structural, never content: it
/// reads the cell counts and the weights the host set and nothing a plot holds.
bool same_columns(const std::vector<u32>& cells_per_row,
const std::vector<std::vector<f32>>& cell_weights, u32 row_a, u32 row_b) {
const u32 n = cells_per_row[row_a];
if (n != cells_per_row[row_b]) return false;
if (n == 1u) return true;
const std::vector<f32> a = normalised_shares(cell_weights[row_a], n);
const std::vector<f32> b = normalised_shares(cell_weights[row_b], n);
for (u32 i = 0; i < n; ++i) {
if (std::fabs(a[i] - b[i]) > 1e-4f) return false;
}
return true;
}
/// A coordinate in the shared axis' **axis space**: `log10` when the figure's one /// A coordinate in the shared axis' **axis space**: `log10` when the figure's one
/// x axis -- the bottom row's, the row the shared strip belongs to -- is /// x axis -- the bottom row's, the row the shared strip belongs to -- is
/// logarithmic, the value itself otherwise. A following group's span and offset /// logarithmic, the value itself otherwise. A following group's span and offset
@@ -41,18 +123,72 @@ f64 from_link_space(f64 v, bool log) noexcept {
} // namespace } // namespace
Figure::Figure(u32 rows) : plots_(rows) {} Figure::Figure(u32 rows) : plots_(rows) {
const std::vector<u32> ones(rows, 1u);
Plot& Figure::plot(u32 row) noexcept { build_cells(std::span<const u32>(ones));
return row < plots_.size() ? plots_[row] : fallback_;
} }
const Plot& Figure::plot(u32 row) const noexcept { Figure::Figure(std::span<const u32> cells_per_row) : plots_(total_cells(cells_per_row)) {
return row < plots_.size() ? plots_[row] : fallback_; build_cells(cells_per_row);
}
void Figure::build_cells(std::span<const u32> cells) {
cells_per_row_.assign(cells.begin(), cells.end());
for (u32& c : cells_per_row_) c = std::max<u32>(c, 1u);
row_first_.assign(cells_per_row_.size() + 1u, 0u);
for (std::size_t r = 0; r < cells_per_row_.size(); ++r) {
row_first_[r + 1u] = row_first_[r] + cells_per_row_[r];
}
cell_weights_.assign(cells_per_row_.size(), {});
}
u32 Figure::cells_in_row(u32 row) const noexcept {
return row < cells_per_row_.size() ? cells_per_row_[row] : 0u;
}
u32 Figure::first_plot_of_row(u32 row) const noexcept {
return row < cells_per_row_.size() ? row_first_[row] : plot_count();
}
u32 Figure::row_of_plot(u32 plot) const noexcept {
u32 r = 0;
while (r + 1u < cells_per_row_.size() && row_first_[r + 1u] <= plot) ++r;
return r;
}
Plot& Figure::plot(u32 index) noexcept {
return index < plots_.size() ? plots_[index] : fallback_;
}
const Plot& Figure::plot(u32 index) const noexcept {
return index < plots_.size() ? plots_[index] : fallback_;
}
Status Figure::set_cell_weights(u32 row, std::span<const f32> weights) {
if (row >= cells_per_row_.size() || weights.size() != cells_per_row_[row]) {
return Status{ErrorCode::InvalidArgument, "cell weights must name every cell of an existing row"};
}
for (const f32 w : weights) {
if (!std::isfinite(w) || !(w > 0.0f)) {
return Status{ErrorCode::InvalidArgument,
"cell weights must be positive finite numbers: a weight is a share of "
"the row, and a cell with none of it has no rect"};
}
}
cell_weights_[row].assign(weights.begin(), weights.end());
return Status{};
}
void Figure::clear_cell_weights(u32 row) noexcept {
if (row < cell_weights_.size()) cell_weights_[row].clear();
}
bool Figure::has_cell_weights(u32 row) const noexcept {
return row < cell_weights_.size() && !cell_weights_[row].empty();
} }
Status Figure::set_row_weights(std::span<const f32> weights) { Status Figure::set_row_weights(std::span<const f32> weights) {
if (weights.size() != plots_.size()) { if (weights.size() != cells_per_row_.size()) {
return Status{ErrorCode::InvalidArgument, "row weights must name every row"}; return Status{ErrorCode::InvalidArgument, "row weights must name every row"};
} }
f32 total = 0.0f; f32 total = 0.0f;
@@ -330,6 +466,81 @@ bool Figure::adopt_member_changes(XLink& group) {
return false; return false;
} }
void Figure::set_share_row_y(bool on) {
share_row_y_ = on;
row_y_.clear();
for (u32 row = 0; row < cells_per_row_.size(); ++row) {
if (cells_per_row_[row] < 2u) continue;
for (u32 i = row_first_[row]; i < row_first_[row] + cells_per_row_[row]; ++i) {
plots_[i].set_y_link_member(0u, on);
}
}
}
bool Figure::row_y_auto(u32 row) const noexcept {
return row < row_y_.size() && row_y_[row].have ? row_y_[row].auto_scale : true;
}
void Figure::sync_row_y() {
if (!share_row_y_) return;
row_y_.resize(cells_per_row_.size());
for (u32 row = 0; row < cells_per_row_.size(); ++row) {
const u32 n = cells_per_row_[row];
if (n < 2u) continue;
RowY& g = row_y_[row];
const u32 first = row_first_[row];
// Axes added after set_share_row_y() are covered here.
for (u32 i = first; i < first + n; ++i) plots_[i].set_y_link_member(0u, true);
if (!g.have) {
g.pushed = plots_[first].y_axis(0u).range;
g.auto_scale = plots_[first].y_axis(0u).auto_scale;
}
// What happened since the last push. A member's auto flag that differs from the group's
// is a request (set_y_range clears it, auto_fit_y / set_y_auto_scale(true) sets it);
// otherwise a member whose range differs from the pushed one was moved by a gesture.
bool want_auto = g.auto_scale;
Range target = g.pushed;
bool flag_request = false;
for (u32 i = first; i < first + n && !flag_request; ++i) {
const Axis& a = plots_[i].y_axis(0u);
if (a.auto_scale != g.auto_scale) {
want_auto = a.auto_scale;
if (!want_auto) target = a.range; // auto -> manual: the gesture's range
flag_request = true;
}
}
if (!flag_request && !want_auto) {
for (u32 i = first; i < first + n; ++i) {
const Range r = plots_[i].y_axis(0u).range;
if (r.min != g.pushed.min || r.max != g.pushed.max) {
target = r;
break;
}
}
}
if (want_auto) {
Range bounds{};
bool any = false;
for (u32 i = first; i < first + n; ++i) {
Range b{};
if (!plots_[i].y_data_bounds(0u, b)) continue;
bounds = any ? Range{std::min(bounds.min, b.min), std::max(bounds.max, b.max)} : b;
any = true;
}
// The first cell stands in for the row: all cells are assumed to share one y axis kind/padding (the host builds them identically).
if (any && plots_[first].fit_y_bounds(0u, bounds)) target = plots_[first].y_axis(0u).range;
// else: nothing to fit -- keep the range the group had
}
for (u32 i = first; i < first + n; ++i) {
plots_[i].set_y_range(0u, target); // clears the flag ...
plots_[i].set_y_auto_scale(0u, want_auto); // ... so restate it: every member mirrors the group
}
g.pushed = target;
g.auto_scale = want_auto;
g.have = true;
}
}
void Figure::sync_x_links() { void Figure::sync_x_links() {
for (XLink& group : links_) { for (XLink& group : links_) {
if (group.rows.empty()) continue; if (group.rows.empty()) continue;
@@ -369,6 +580,8 @@ void Figure::sync_x_links() {
const FigureLayout& Figure::layout() { const FigureLayout& Figure::layout() {
const u32 rows = row_count(); const u32 rows = row_count();
layout_.rows.assign(rows, Rect{}); layout_.rows.assign(rows, Rect{});
layout_.cells.clear();
layout_.col_gap = 0.0f;
layout_.row_gap = row_gap_; layout_.row_gap = row_gap_;
layout_.x_axis_strip = Rect{}; layout_.x_axis_strip = Rect{};
if (rows == 0u) { if (rows == 0u) {
@@ -377,12 +590,13 @@ const FigureLayout& Figure::layout() {
} }
const f32 figure_h = std::max(0.0f, viewport_.h); const f32 figure_h = std::max(0.0f, viewport_.h);
// The rows tile the figure itself: the shared x axis is the *bottom row's own*, so // The rows tile the figure itself: an x axis is a row's *own* strip (the bottom row's, and
// nothing is taken off the figure for it. That is what keeps the axis reachable -- // the strip of any row whose columns differ from the row below), so nothing is taken off
// its strip is a real `PlotLayout::x_label_area` inside the bottom row's rect, which // the figure for it. That is what keeps the axis reachable -- its strip is a real
// is the rect the controller's axis-zoom hit test reads and the strip an x title is // `PlotLayout::x_label_area` inside a row's rect, which is the rect the controller's
// drawn in -- and it is why an interior row reserves no x chrome at all instead of // axis-zoom hit test reads and the strip an x title is drawn in -- and it is why a row
// leaving a band no one owns. // that hands its axis to the one below reserves no x chrome at all instead of leaving a
// band no one owns.
const f32 content_h = figure_h; const f32 content_h = figure_h;
// The gap is dropped, rather than squeezing the rows, when the figure cannot hold // The gap is dropped, rather than squeezing the rows, when the figure cannot hold
// it: a figure one pixel tall with four rows still gives every row a positive // it: a figure one pixel tall with four rows still gives every row a positive
@@ -441,25 +655,59 @@ const FigureLayout& Figure::layout() {
// short to hold the one asked for. // short to hold the one asked for.
layout_.row_gap = gap; layout_.row_gap = gap;
const u32 bottom_row = rows - 1u; // Each row rect is split into its cells; a one-cell row's cell is the rect itself, so an
// unsplit figure lays out exactly as it always did.
layout_.cells.assign(plots_.size(), Rect{});
layout_.col_gap = col_gap_;
for (u32 row = 0; row < rows; ++row) { for (u32 row = 0; row < rows; ++row) {
Plot& p = plots_[row]; const u32 n = cells_per_row_[row];
p.set_viewport(layout_.rows[row]); const f32 used = place_cells(layout_.rows[row], n, cell_weights_[row], col_gap_,
// One x axis for the whole figure, and it is the bottom row's: every row keeps its floor_scale, &layout_.cells[row_first_[row]]);
// x chrome -- labels, title and the strip the controller's axis zoom is found in -- if (n > 1u && used < col_gap_) layout_.col_gap = 0.0f;
// and every other row gives all of it to that one, so the labels and the title are }
// not drawn once per row. A figure told to label every row keeps them everywhere,
// and then there is no shared axis to speak of: each row has its own. // Which rows carry their own x strip, decided structurally and before the first
p.set_x_strip_mode(labels_bottom_only_ && row != bottom_row ? XStrip::None : XStrip::Both); // measurement pass so that pass and the final one see the same strips. The bottom row
// Before either pass below, and the same value for every row: nothing is measured on // always has one. A row above hides its strip only when the row directly below has the
// the right, so unlike the left gutter this needs no measurement pass -- the rows end // same columns (see `same_columns()`), because then the cells of the two rows have the
// on a common column because they are all told the one width. // same x extents and the axis below serves this row too; the rule is transitive, since
p.set_right_gutter(right_gutter_); // that row is itself either labelled or hidden in favour of one that matches it. A row
// whose columns differ from the one below -- `[1][2]` over `[3]` -- keeps its own strip,
// drawn under each of its cells, because the axis below is at another scale and width.
// A figure told to label every row keeps the strips everywhere.
std::vector<XStrip> strips(rows, XStrip::Both);
if (labels_bottom_only_) {
for (u32 row = rows - 1u; row-- > 0u;) {
// By induction the row below is labelled or itself hidden in favour of a matching
// row, so hiding this one on a match with it is the transitive rule.
if (same_columns(cells_per_row_, cell_weights_, row, row + 1u)) strips[row] = XStrip::None;
}
}
for (u32 row = 0; row < rows; ++row) {
const XStrip strip = strips[row];
// A split row whose cells share one y range also shares one y axis, drawn by its first
// cell: every other cell reserves no y chrome at all (see `YStrip::None`).
const bool shared_y = share_row_y_ && cells_per_row_[row] > 1u;
// ... and its right gutter is the last cell's alone, so the gap between two plots of the
// row is the column gap and not a gutter more (see the placement block below).
const u32 last = row_first_[row + 1u] - 1u;
for (u32 i = row_first_[row]; i < row_first_[row + 1u]; ++i) {
Plot& p = plots_[i];
p.set_viewport(layout_.cells[i]);
p.set_x_strip_mode(strip);
p.set_y_strip_mode(shared_y && i != row_first_[row] ? YStrip::None : YStrip::Both);
// Before either pass below, and the same value for every plot: nothing is measured on
// the right, so unlike the left gutter this needs no measurement pass -- the plots
// end on a common column because they are all told the one width.
p.set_right_gutter(shared_y && i != last ? 0.0f : right_gutter_);
}
} }
// After the viewports, so a row's ticks and grid -- built from its axes rect -- and // After the viewports, so a row's ticks and grid -- built from its axes rect -- and
// its transforms describe this frame's rects; and before the rows lay themselves // its transforms describe this frame's rects; and before the rows lay themselves
// out below, so a following group's window is the range they lay out with. // out below, so a following group's window is the range they lay out with.
sync_x_links(); sync_x_links();
// Before any `Plot::layout()`, where a plot would autoscale its own y axis.
sync_row_y();
// Every row is laid out against its own rect, so after this call each row's // Every row is laid out against its own rect, so after this call each row's
// chrome is current: `plot(i).layout()` reads back what the figure computed rather // chrome is current: `plot(i).layout()` reads back what the figure computed rather
// than what the row last held. That is the contract `Renderer::draw(Figure&)` // than what the row last held. That is the contract `Renderer::draw(Figure&)`
@@ -486,26 +734,63 @@ const FigureLayout& Figure::layout() {
// `set_min_y_gutter()` seeds the maximum rather than being imposed on a row before the // `set_min_y_gutter()` seeds the maximum rather than being imposed on a row before the
// measurement, so a reserved floor cannot widen what a row reports and then be widened // measurement, so a reserved floor cannot widen what a row reports and then be widened
// by its own reservation on the next frame. // by its own reservation on the next frame.
//
// Over the flattened plots: every cell of every row takes part, so the y axes of a split
// row line up with each other and with the rows above and below.
f32 gutter = min_y_gutter_; f32 gutter = min_y_gutter_;
for (u32 row = 0; row < rows; ++row) { //
plots_[row].set_min_y_gutter(0.0f); // A `YStrip::None` cell draws no y axis, so it has no width to contribute and takes no part
const PlotLayout& row_layout = plots_[row].layout(); // in the maximum: the gutter is the widest of the cells that do draw one.
for (Plot& p : plots_) {
p.set_min_y_gutter(0.0f);
const PlotLayout& row_layout = p.layout();
if (p.y_strip_mode() == YStrip::None) continue;
gutter = std::max(gutter, row_layout.y_axis_width + row_layout.y_title_width); gutter = std::max(gutter, row_layout.y_axis_width + row_layout.y_title_width);
} }
// Rows that share one y axis divide their *plot* widths, not their cell widths: the first
// cell's y strip would otherwise make its plot narrower than its neighbours'. The gutter is
// taken off the row first, the remainder is split by `place_cells` (so the weights, the gap
// and the floor all apply to plot widths), and the first cell then grows left by the gutter
// to hold the axis. The gutter is capped at half the row, so a row too narrow for it
// degrades to ordinary cells instead of producing a negative rect. The cells still tile the
// row: the first starts on its left edge and the last still ends on its right edge.
//
// The right gutter is taken off the same way and belongs to the **last** cell alone (the
// other cells were told none above): left to every cell, the visible gap between two
// plots would be the column gap plus a whole right gutter. Together with the y gutter it is
// capped at half the row, and never exceeds the figure's own right gutter.
for (u32 row = 0; row < rows; ++row) {
const u32 n = cells_per_row_[row];
if (!share_row_y_ || n <= 1u) continue;
const Rect& r = layout_.rows[row];
const f32 g_eff = std::max(0.0f, std::min(gutter, r.w / 2.0f));
const f32 rg_eff = std::max(0.0f, std::min(right_gutter_, r.w / 2.0f - g_eff));
Rect* cells = &layout_.cells[row_first_[row]];
const f32 used = place_cells(Rect{r.x + g_eff, r.y, r.w - g_eff - rg_eff, r.h}, n,
cell_weights_[row], col_gap_, floor_scale, cells);
cells[0].x -= g_eff;
cells[0].w += g_eff;
cells[n - 1u].w += rg_eff;
layout_.col_gap = used < col_gap_ ? 0.0f : layout_.col_gap;
for (u32 i = row_first_[row]; i < row_first_[row + 1u]; ++i) plots_[i].set_viewport(layout_.cells[i]);
// What the cell actually reserved, so a row too narrow for the whole gutter is not asked
// for more than it was given (the same value whenever the row has room).
plots_[row_first_[row + 1u] - 1u].set_right_gutter(rg_eff);
}
// A one-row figure imposes exactly what that row measured, so it lays out as the plot // A one-row figure imposes exactly what that row measured, so it lays out as the plot
// would on its own; a figure whose rows all need the same strip imposes that, which is // would on its own; a figure whose rows all need the same strip imposes that, which is
// also what each would have done alone. // also what each would have done alone.
for (u32 row = 0; row < rows; ++row) { for (Plot& p : plots_) {
plots_[row].set_min_y_gutter(gutter); p.set_min_y_gutter(p.y_strip_mode() == YStrip::None ? 0.0f : gutter);
plots_[row].layout(); p.layout();
} }
// The figure's one x axis is the bottom row's, so the strip it is drawn in is that // `x_axis_strip` is the bottom row's own label area (the last plot's) -- the rect the
// row's own label area -- the rect the controller hit-tests and the renderer stages // controller hit-tests and the renderer stages its labels in, read back here rather than
// its labels in, read back here rather than derived again, so a host and the frame // derived again, so a host and the frame cannot disagree about where it is. Rows above
// cannot disagree about where the shared axis is. It is zero-sized only when there // that carry their own strip (their columns differ from the row below, or every row is
// are no rows at all: with `set_x_labels_only_on_bottom(false)` it is simply the last // labelled) have it in their plots' own `layout().x_label_area`. It is zero-sized only
// row's own strip, one of several. // when there are no rows at all.
layout_.x_axis_strip = plots_.back().layout().x_label_area; layout_.x_axis_strip = plots_.back().layout().x_label_area;
return layout_; return layout_;
} }
@@ -534,6 +819,21 @@ void Figure::set_crosshair(f64 x, u32 hovered_row, f64 y_in_row, bool active) {
// than clearing a hover the pointer still has. // than clearing a hover the pointer still has.
if (!active || hovered_row >= plots_.size()) return; if (!active || hovered_row >= plots_.size()) return;
plots_[hovered_row].set_crosshair(x, y_in_row, true); plots_[hovered_row].set_crosshair(x, y_in_row, true);
// **The cells of a split row share one y**, so the horizontal line means the same value in
// every one of them: it spans the row rather than stopping at the divider between two cells
// that read the same scale. Only that row -- rows do not share y with each other, so a
// sibling row's line would be a value on a scale it does not have -- and only when the row
// really is split (`set_share_row_y`, which is what makes the y common in the first place).
if (!share_row_y_) return;
const u32 row = row_of_plot(hovered_row);
if (row >= cells_per_row_.size() || cells_per_row_[row] <= 1u) return;
const u32 first = row_first_[row];
for (u32 k = 0; k < cells_per_row_[row]; ++k) {
const u32 cell = first + k;
if (cell != hovered_row && cell < plots_.size()) {
plots_[cell].set_crosshair(x, y_in_row, true);
}
}
} }
void Figure::clear_crosshair() { void Figure::clear_crosshair() {
+124 -14
View File
@@ -311,6 +311,11 @@ void Plot::set_cursor_color(u32 id, Color color) {
cursors_[id].color = color; cursors_[id].color = color;
} }
void Plot::set_cursor_label(u32 id, std::string label) {
if (id >= cursors_.size()) return;
cursors_[id].label = std::move(label);
}
void Plot::set_cursor_stroke(u32 id, f32 width, Vec2 dash) { void Plot::set_cursor_stroke(u32 id, f32 width, Vec2 dash) {
if (id >= cursors_.size()) return; if (id >= cursors_.size()) return;
cursors_[id].width = width; cursors_[id].width = width;
@@ -404,9 +409,25 @@ void Plot::clear_density() noexcept {
bool Plot::size_density_grids() { bool Plot::size_density_grids() {
const Rect& axes = layout_.axes; const Rect& axes = layout_.axes;
if (!(axes.w >= 1.0f) || !(axes.h >= 1.0f)) return false; if (!(axes.w >= 1.0f) || !(axes.h >= 1.0f)) return false;
const u32 columns = static_cast<u32>(std::ceil(axes.w)); u32 columns = static_cast<u32>(std::ceil(axes.w));
const u32 rows = static_cast<u32>(std::ceil(axes.h)); u32 rows = static_cast<u32>(std::ceil(axes.h));
if (columns == 0u || rows == 0u) return false; 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 // **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 // 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. // accumulation gets its own empty grid here while the grids already sized keep what they held.
@@ -557,10 +578,19 @@ void Plot::deposit_density_segment(f32 x0, f32 y0, f32 x1, f32 y1, DensityGrid&
if (density_stamp_.size() < rows) return; // sized with the grid; nothing deposits before that 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; if (!std::isfinite(x0) || !std::isfinite(y0) || !std::isfinite(x1) || !std::isfinite(y1)) return;
const Rect& axes = layout_.axes; 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 lo_x = std::min(x0, x1);
const f32 hi_x = std::max(x0, x1); const f32 hi_x = std::max(x0, x1);
i32 c0 = static_cast<i32>(std::floor(lo_x - axes.x)); i32 c0 = static_cast<i32>(std::floor((lo_x - axes.x) * sx));
i32 c1 = static_cast<i32>(std::floor(hi_x - axes.x)); 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 // 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 // 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. // keeps ink off the edge column when a trace leaves the frame sideways.
@@ -575,12 +605,12 @@ void Plot::deposit_density_segment(f32 x0, f32 y0, f32 x1, f32 y1, DensityGrid&
// along the segment. Every column's span meets its neighbour's end exactly (the y is linear in // 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. // 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 auto column_y_span = [&](i32 column, f32& out_lo, f32& out_hi) {
const f32 ca = axes.x + static_cast<f32>(column); const f32 ca = axes.x + static_cast<f32>(column) / sx;
f32 ya = y0; f32 ya = y0;
f32 yb = y1; f32 yb = y1;
if (span > 0.0f && denom != 0.0f) { if (span > 0.0f && denom != 0.0f) {
const f32 a = std::max(ca, lo_x); const f32 a = std::max(ca, lo_x);
const f32 b = std::min(ca + 1.0f, hi_x); const f32 b = std::min(ca + 1.0f / sx, hi_x);
ya = y0 + (a - x0) / denom * (y1 - y0); ya = y0 + (a - x0) / denom * (y1 - y0);
yb = y0 + (b - x0) / denom * (y1 - y0); yb = y0 + (b - x0) / denom * (y1 - y0);
} }
@@ -599,8 +629,8 @@ void Plot::deposit_density_segment(f32 x0, f32 y0, f32 x1, f32 y1, DensityGrid&
// One ULP inside the bottom edge, so a segment ending exactly on it lands in the last row // One ULP inside the bottom edge, so a segment ending exactly on it lands in the last row
// rather than one past the end. // rather than one past the end.
const f32 bottom = std::min(y_hi, std::nextafter(axes.bottom(), axes.y)); 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)), 0, last_row); 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)), 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 // **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 // 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, // sweep at the ceiling of the brightness scale: the newer sweeps behind it would be invisible,
@@ -794,6 +824,19 @@ void Plot::set_x_link_member(u32 axis, bool linked) noexcept {
x_axes_[axis].link_member = linked; x_axes_[axis].link_member = linked;
} }
void Plot::set_y_link_member(u32 axis, bool linked) noexcept {
if (axis >= y_axes_.size()) return;
y_axes_[axis].link_member = linked;
}
bool Plot::y_data_bounds(u32 axis, Range& out) const {
return trace_bounds_union(/*x_side=*/false, axis, out);
}
bool Plot::fit_y_bounds(u32 axis, Range bounds) {
return axis < y_axes_.size() && fit_axis_range(y_axes_[axis], bounds);
}
XFollow Plot::x_follow(u32 axis) const { XFollow Plot::x_follow(u32 axis) const {
return axis < x_axes_.size() ? x_axes_[axis].follow : XFollow::Off; return axis < x_axes_.size() ? x_axes_[axis].follow : XFollow::Off;
} }
@@ -976,7 +1019,8 @@ void Plot::autoscale_axes() {
if (x_axes_[i].auto_scale && !follows_live(i)) fit_x_axis(i); if (x_axes_[i].auto_scale && !follows_live(i)) fit_x_axis(i);
} }
for (u32 i = 0; i < y_axes_.size(); ++i) { for (u32 i = 0; i < y_axes_.size(); ++i) {
if (y_axes_[i].auto_scale) fit_y_axis(i); // A shared-y member is fitted by its figure, against every cell's traces.
if (y_axes_[i].auto_scale && !y_axes_[i].link_member) fit_y_axis(i);
} }
} }
@@ -1273,13 +1317,18 @@ const PlotLayout& Plot::layout() {
y_width = y_axes_.empty() ? 0.0f : 2.0f * pad; y_width = y_axes_.empty() ? 0.0f : 2.0f * pad;
x_height = text_->height() + 2.0f * pad; x_height = text_->height() + 2.0f * pad;
} }
// A row of a `Figure` gives its x strip to the figure's one shared axis -- the // A row of a `Figure` that hands its x axis to the row below (which has the same
// bottom row's own strip, where that axis lives -- so it reserves none: the seed and // columns, so the same x extents) reserves no strip: the seed and
// the fixed point below both start from zero, and `axes` then reaches the viewport's // the fixed point below both start from zero, and `axes` then reaches the viewport's
// bottom edge. Set here rather than in the loop, because the loop's `need_x` is what // bottom edge. Set here rather than in the loop, because the loop's `need_x` is what
// the strip wants and this overrides the want, not the measurement. // the strip wants and this overrides the want, not the measurement.
const bool x_labels = x_strip_has_labels(x_strip_); const bool x_labels = x_strip_has_labels(x_strip_);
if (!x_labels) x_height = 0.0f; if (!x_labels) x_height = 0.0f;
// The y strip likewise: a cell that gives its y axis to a neighbour (`YStrip::None`) reserves
// no label width, no title column and no share of the figure's gutter, so every y-strip
// quantity below collapses to zero and `axes` starts at the viewport's left edge.
const bool y_none = y_strip_ == YStrip::None;
if (y_none) y_width = 0.0f;
// The title strips, though, are needed before the loop and never change, unlike // The title strips, though, are needed before the loop and never change, unlike
// the label strips: a strip holds exactly one text line per titled axis and no // the label strips: a strip holds exactly one text line per titled axis and no
// tick label can change that. Nothing at all is reserved without a text backend // tick label can change that. Nothing at all is reserved without a text backend
@@ -1293,7 +1342,7 @@ const PlotLayout& Plot::layout() {
// no title reserves no row for a title either, and one that reserves the title and no // no title reserves no row for a title either, and one that reserves the title and no
// labels still gets its row -- below where the labels would have been. // labels still gets its row -- below where the labels would have been.
const u32 x_titles = x_strip_has_title(x_strip_) ? titled_x_axis_count() : 0u; const u32 x_titles = x_strip_has_title(x_strip_) ? titled_x_axis_count() : 0u;
const f32 y_title_w = text_ != nullptr ? y_title_strip_width() : 0.0f; const f32 y_title_w = text_ != nullptr && !y_none ? y_title_strip_width() : 0.0f;
const f32 x_title_h = text_ != nullptr && x_titles != 0u const f32 x_title_h = text_ != nullptr && x_titles != 0u
? static_cast<f32>(x_titles) * text_->height() + 2.0f * pad ? static_cast<f32>(x_titles) * text_->height() + 2.0f * pad
: 0.0f; : 0.0f;
@@ -1314,7 +1363,7 @@ const PlotLayout& Plot::layout() {
// -- and it is applied before the title column below, so the pair still fits the cap // -- and it is applied before the title column below, so the pair still fits the cap
// (the floor is clamped to the same budget) and the invariant `axes.w >= share` holds. // (the floor is clamped to the same budget) and the invariant `axes.w >= share` holds.
// `min_y_gutter_` is 0.0 unless a figure set it, so this is a no-op for a lone plot. // `min_y_gutter_` is 0.0 unless a figure set it, so this is a no-op for a lone plot.
const f32 y_floor = std::min(min_y_gutter_, y_budget); const f32 y_floor = y_none ? 0.0f : std::min(min_y_gutter_, y_budget);
y_width = std::min(std::max(y_width, y_floor - y_title_w), y_budget); y_width = std::min(std::max(y_width, y_floor - y_title_w), y_budget);
x_height = std::min(x_height, x_budget); x_height = std::min(x_height, x_budget);
const f32 y_title_width = std::min(y_title_w, y_budget - y_width); const f32 y_title_width = std::min(y_title_w, y_budget - y_width);
@@ -1347,7 +1396,8 @@ const PlotLayout& Plot::layout() {
if (text_ == nullptr || pass + 1 >= kMaxLayoutPasses) break; if (text_ == nullptr || pass + 1 >= kMaxLayoutPasses) break;
// The same caps as above, so this compares like with like and the loop still // The same caps as above, so this compares like with like and the loop still
// reaches its fixed point on the second pass. // reaches its fixed point on the second pass.
const f32 need_y = std::min(std::clamp(y_label_width(), 0.0f, vw), y_budget); const f32 need_y =
y_none ? 0.0f : std::min(std::clamp(y_label_width(), 0.0f, vw), y_budget);
// Overridden, not merely unmeasured: the strip is not reserved, so the fixed // Overridden, not merely unmeasured: the strip is not reserved, so the fixed
// point has to be zero for a row that gave its strip away. // point has to be zero for a row that gave its strip away.
const f32 need_x = const f32 need_x =
@@ -1843,6 +1893,14 @@ void Plot::build_crosshair() {
const auto inside = [](f32 v, f32 lo, f32 hi) { const auto inside = [](f32 v, f32 lo, f32 hi) {
return std::clamp(v, std::min(lo, hi), std::max(lo, hi)); return std::clamp(v, std::min(lo, hi), std::max(lo, hi));
}; };
crosshair.y_axis_badge = Rect{};
crosshair.y_axis_text.clear();
crosshair.x_axis_badge = Rect{};
crosshair.x_axis_text.clear();
if (!crosshair_.active) {
crosshair.rows.clear();
return;
}
// The two lines, each a stroke box held *inside* the axes by half its own width. The // The two lines, each a stroke box held *inside* the axes by half its own width. The
// renderer stages the stroke across the box's centre line, so this is the whole placement // renderer stages the stroke across the box's centre line, so this is the whole placement
@@ -1873,6 +1931,58 @@ void Plot::build_crosshair() {
crosshair.horizontal = Rect{axes.x, at - half, axes.w, stroke}; crosshair.horizontal = Rect{axes.x, at - half, axes.w, stroke};
} }
// The axis badges, when asked for (`Crosshair::axis_labels`): the values the two lines
// meet the axes at, written in the strips the tick labels are written in and in each
// axis' own format, so a badge reads exactly like the tick beside it. Placed here rather
// than by the renderer, for the readout panel's reason -- one placement rule, one place to
// read it -- and left empty whenever there is nothing honest to place: no text backend to
// measure with, the line they belong to not drawn (a vertical-only crosshair has no y),
// or their strip absent or too small (a row whose x strip went to the row below, a y
// gutter collapsed to a title). The x badge is held to the axes' own columns as well, so
// a crosshair in the leftmost cell of a split row cannot badge into its neighbour's.
if (crosshair_.axis_labels && text_ != nullptr) {
const f32 badge_pad = style_.device(4.0f);
const f32 badge_h = text_->height() + 2.0f * badge_pad;
if (crosshair_.horizontal && layout_.y_label_area.w > 0.0f &&
layout_.y_label_area.h >= badge_h) {
char text[64];
y_axis(0u).format_value(crosshair_.y, text, sizeof text);
crosshair.y_axis_text.assign(text);
const f32 w = text_->measure(crosshair.y_axis_text.c_str()) + 2.0f * badge_pad;
// Right-aligned on the axis line -- where the y ticks end -- and **centred** on the
// horizontal line's own height, the way the x badge is centred on its column: the badge
// names the line it sits beside, and a badge whose top edge *is* that line reads as
// belonging to the tick below it. A badge is as wide as its own text and is capped by
// nothing else, so it
// is held within the **left strips' union**: the label strip first, the title strip
// beside it when there is one, and never over the data or off the viewport's left
// edge. A `std::clamp` on an inverted pair is undefined, which is exactly the state
// "the strip is narrower than the badge" produces -- hence the ordered min/max chain.
const f32 left = layout_.y_title_area.w > 0.0f ? layout_.y_title_area.x
: layout_.y_label_area.x;
f32 bx = layout_.y_label_area.right() - w;
if (bx < left) bx = left;
bx = std::min(bx, axes.x - w);
if (bx < left) bx = left; // a badge wider than the union stays at its left edge
const f32 at = inside(static_cast<f32>(ty.to_screen_y(crosshair_.y)) - 0.5f * badge_h,
layout_.y_label_area.y + badge_pad,
layout_.y_label_area.bottom() - badge_pad - badge_h);
crosshair.y_axis_badge = Rect{bx, at, w, badge_h};
}
if (crosshair_.vertical && layout_.x_label_area.w > 0.0f &&
layout_.x_label_area.h >= badge_h) {
char text[64];
x_axis(0u).format_value(crosshair_.x, text, sizeof text);
crosshair.x_axis_text.assign(text);
const f32 w = text_->measure(crosshair.x_axis_text.c_str()) + 2.0f * badge_pad;
const f32 column =
inside(static_cast<f32>(tx.to_screen_x(crosshair_.x)), axes.x, axes.right());
const f32 x = inside(column - 0.5f * w, layout_.x_label_area.x,
layout_.x_label_area.right() - w);
crosshair.x_axis_badge = Rect{x, layout_.x_label_area.y, w, badge_h};
}
}
// The rows: one per *visible* trace -- a trace with no series or no samples is not under // The rows: one per *visible* trace -- a trace with no series or no samples is not under
// the pointer, so it has nothing to say -- named or not, because the readout is about what // the pointer, so it has nothing to say -- named or not, because the readout is about what
// the pointer is over and not about identity. The vector is filled in place and its entries // the pointer is over and not about identity. The vector is filled in place and its entries
+206 -42
View File
@@ -471,7 +471,7 @@ void Renderer::draw(Figure& figure) {
// Every row is prepared before the figure lays itself out, because a row's strips // 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 // 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. // 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)); 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 // The figure's one layout for the frame. It hands every row its rect and its
@@ -480,10 +480,11 @@ void Renderer::draw(Figure& figure) {
// than laying each row out a second time. Nothing draws before this: a row's // than laying each row out a second time. Nothing draws before this: a row's
// viewport is the figure's to set. // 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 // A shared x axis needs no pass of its own: it is the bottom row's own (of a run of rows
// that row stages it in its own strip through `draw_tick_labels()` like any other // with the same columns), so that row stages it in its own strip through
// plot's axis -- and every row above it reserved no x chrome, which is what keeps // `draw_tick_labels()` like any other plot's axis -- and every row of the run above it
// the labels from appearing once per row without a second staging here. // 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(); figure.layout();
for (u32 row = 0; row < rows; ++row) { for (u32 row = 0; row < rows; ++row) {
Plot& plot = figure.plot(row); Plot& plot = figure.plot(row);
@@ -564,6 +565,10 @@ void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) {
clear_clip(); clear_clip();
draw_tick_labels(plot, layout); draw_tick_labels(plot, layout);
draw_titles(plot, layout); draw_titles(plot, layout);
// The crosshair's axis badges, last of all: they sit in the label strips among the tick
// labels, so they are drawn after them and unclipped -- and over them, which is the point
// (a badge the ticks print through is two numbers in one place).
draw_crosshair_axis_badges(layout);
} }
void Renderer::end_frame() noexcept { void Renderer::end_frame() noexcept {
@@ -878,8 +883,8 @@ void Renderer::draw_tick_labels(const Plot& plot, const PlotLayout& layout) {
const f32 line_h = font_backend_.height(); const f32 line_h = font_backend_.height();
// X labels: unless the plot gave its x label strip away -- a row of a `Figure` // 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 // that reserves no x chrome, because the row below has the same columns and draws the
// row's own strip instead -- in which case it has no strip to put them in and their // 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 // 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 // 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 // strip's single text line, which is the one rule for a plot's labels and a figure's
@@ -902,9 +907,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 // 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 // inside the strip at the two extremes (a label centred on the top tick would
// otherwise hang above the plot area). // 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 = const f32 top_limit =
std::max(layout.y_label_area.y, layout.y_label_area.bottom() - line_h); 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); const Transform transform = plot.y_transform(i);
for (const Tick& tick : plot.y_axis(i).ticks) { for (const Tick& tick : plot.y_axis(i).ticks) {
if (!tick.major || tick.label.empty()) continue; if (!tick.major || tick.label.empty()) continue;
@@ -1669,6 +1679,9 @@ void Renderer::draw_cursors(const Plot& plot, const PlotLayout& layout) {
// does not depend on the marker's own colour differing from the accent -- a cursor // does not depend on the marker's own colour differing from the accent -- a cursor
// created in the accent colour is otherwise indistinguishable from an idle one. // created in the accent colour is otherwise indistinguishable from an idle one.
const HotHandles hot = plot.hot_handles(); const HotHandles hot = plot.hot_handles();
// Collected across the loop below and drawn clustered at its end -- see
// `draw_clustered_cursor_labels()` for why one panel can carry several lines.
std::vector<CursorLabel> labels;
for (u32 i = 0; i < plot.cursor_count(); ++i) { for (u32 i = 0; i < plot.cursor_count(); ++i) {
const Cursor& cursor = plot.cursor(i); const Cursor& cursor = plot.cursor(i);
@@ -1724,34 +1737,143 @@ void Renderer::draw_cursors(const Plot& plot, const PlotLayout& layout) {
flush_lines(mapping(plot, cursor.axis_x, 0, x_drawn), flush_lines(mapping(plot, cursor.axis_x, 0, x_drawn),
on ? style_.cursor : cursor.color, width, cursor.dash); on ? style_.cursor : cursor.color, width, cursor.dash);
} }
draw_cursor_label(plot, layout, cursor, drawn); // The labels are drawn after the strokes, clustered: collecting them all first is what
// lets two marks in the same column share one panel instead of overprinting each other.
collect_cursor_label(plot, layout, cursor, drawn, labels);
} }
draw_clustered_cursor_labels(layout, labels);
} }
void Renderer::draw_cursor_label(const Plot& plot, const PlotLayout& layout, const Cursor& cursor, void Renderer::collect_cursor_label(const Plot& plot, const PlotLayout& layout,
f32 at) { const Cursor& cursor, f32 at,
const f32 gap = style_.device(3.0f); std::vector<CursorLabel>& labels) {
(void)layout;
if (cursor.is_horizontal()) { if (cursor.is_horizontal()) {
// A ruler's text is the value it is *for*, written the way the y axis writes its own // A ruler's text is the value it is *for*, written the way the y axis writes its own
// tick labels, so a value read off a ruler reads exactly like the tick beside it. The // tick labels, so a value read off a ruler reads exactly like the tick beside it. The
// caller's caption, when there is one, is staged first on the same line, so neither the // caller's caption, when there is one, leads the same line.
// caption nor the value has to be dropped -- and both stay one text run's worth of
// glyphs, since the pen advances per glyph either way.
const Vec2 pen{layout.axes.x + gap, at + style_.device(2.0f)};
char value[32]; char value[32];
plot.y_axis(cursor.axis_y).format_value(cursor.y, value, sizeof value); plot.y_axis(cursor.axis_y).format_value(cursor.y, value, sizeof value);
if (cursor.label.empty()) { const std::string text =
stage_text(glyphs_under_, pen, value, style_.axis_text); cursor.label.empty() ? std::string(value) : cursor.label + " " + value;
return; CursorLabel label;
} label.text = text;
stage_text(glyphs_under_, pen, cursor.label.c_str(), style_.axis_text); label.border = cursor.color;
stage_text(glyphs_under_, {pen.x + font_backend_.measure(cursor.label.c_str()) + gap, pen.y}, label.anchor = at; // the ruler's own row: the badge is centred on it
value, style_.axis_text); label.w = font_backend_.measure(text.c_str());
label.horizontal = true;
labels.push_back(std::move(label));
return; return;
} }
if (!cursor.label.empty()) { if (cursor.label.empty()) return;
stage_text(glyphs_under_, {at + gap, layout.axes.y + style_.device(2.0f)}, CursorLabel label;
cursor.label.c_str(), style_.axis_text); label.text = cursor.label;
label.border = cursor.color;
label.anchor = at; // the marker's own column: the badge is centred on it
label.w = font_backend_.measure(cursor.label.c_str());
labels.push_back(std::move(label));
}
void Renderer::draw_clustered_cursor_labels(const PlotLayout& layout,
const std::vector<CursorLabel>& labels) {
if (labels.empty()) return;
const f32 pad = style_.device(3.0f);
const f32 line_h = font_backend_.height();
const Rect& axes = layout.axes;
// **Placement first, clustering second.** Each badge is centred on the mark it belongs to
// -- a ruler's badge straddles the ruler's own row, a marker's badge straddles the
// marker's own column -- because a badge that drifts from its mark is a number without an
// anchor. When two badges meet, the later one drops one text line (downward from its
// centred position) until it is clear, so a column of close rulers reads as a stack of
// numbers in their own rows rather than as texts printed through each other; the rows keep
// each badge beside its own mark, which a sequential stack from the panel's top would not.
std::vector<CursorLabel> placed;
placed.reserve(labels.size());
std::vector<CursorLabel> by_anchor = labels;
std::stable_sort(by_anchor.begin(), by_anchor.end(),
[](const CursorLabel& a, const CursorLabel& b) { return a.anchor < b.anchor; });
for (CursorLabel& label : by_anchor) {
f32 x = 0.0f;
f32 y = 0.0f;
if (label.horizontal) {
x = axes.x + pad;
y = label.anchor - 0.5f * line_h; // centred on the ruler's own row
} else {
// Centred on the marker's column, held inside the axes: a badge half over an edge is
// half a badge.
x = std::clamp(label.anchor - 0.5f * label.w, axes.x + pad,
std::max(axes.x + pad, axes.right() - pad - label.w));
y = axes.y + pad;
}
for (;;) {
const Rect box{x - pad, y - pad, label.w + 2.0f * pad, line_h + 2.0f * pad};
bool meets = false;
for (const CursorLabel& p : placed) {
meets = box.x < p.box.x + p.box.w && p.box.x < box.x + box.w &&
box.y < p.box.y + p.box.h && p.box.y < box.y + box.h;
if (meets) break;
}
label.pen = Vec2{x, y};
label.box = box;
if (!meets) break;
y += line_h; // one row down: still beside the mark, no longer on its neighbour
}
placed.push_back(label);
}
// The panels: one per cluster of meeting backplates -- the boxes' union plus padding, the
// border the cluster's first label's own -- with every line staged at its *placed* pen, so
// the panel is a unified plate behind badges that each still sit on their own mark.
const f32 margin = style_.device(2.0f);
std::vector<Rect> bounds;
std::vector<std::vector<std::size_t>> groups;
std::vector<char> used(placed.size(), 0);
for (std::size_t i = 0; i < placed.size(); ++i) {
if (used[i] != 0) continue;
used[i] = 1;
bounds.push_back(placed[i].box);
groups.emplace_back(1, i);
Rect& u = bounds.back();
for (bool grew = true; grew;) {
grew = false;
for (std::size_t j = 0; j < placed.size(); ++j) {
if (used[j] != 0) continue;
const Rect inflated{u.x - margin, u.y - margin, u.w + 2.0f * margin,
u.h + 2.0f * margin};
const Rect& b = placed[j].box;
const bool meet = inflated.x < b.x + b.w && b.x < inflated.x + inflated.w &&
inflated.y < b.y + b.h && b.y < inflated.y + inflated.h;
if (!meet) continue;
used[j] = 1;
groups.back().push_back(j);
const f32 rx = std::min(u.x, b.x);
const f32 ry = std::min(u.y, b.y);
u = Rect{rx, ry, std::max(u.x + u.w, b.x + b.w) - rx,
std::max(u.y + u.h, b.y + b.h) - ry};
grew = true;
}
}
}
for (std::size_t g = 0; g < groups.size(); ++g) {
Rect panel = bounds[g];
panel.x -= pad;
panel.y -= pad;
panel.w += 2.0f * pad;
panel.h += 2.0f * pad;
// Inside the axes, shifted rather than clipped: a panel that hangs over an edge is a
// second plot; a panel nudged along it is still a readout of what it labels. Opaque: a
// badge is read against ticks and traces, and the badge's line beneath it is covered --
// the box interrupting its own ruler is what makes the two read as one instrument.
panel.x = std::clamp(panel.x, axes.x,
std::max(axes.x, axes.right() - panel.w));
panel.y = std::clamp(panel.y, axes.y,
std::max(axes.y, axes.bottom() - panel.h));
draw_readout_panel(panel, placed[groups[g].front()].border, 1.0f);
for (std::size_t k = 0; k < groups[g].size(); ++k) {
const CursorLabel& label = placed[groups[g][k]];
stage_text(glyphs_under_, label.pen, label.text.c_str(), style_.axis_text);
}
} }
} }
@@ -1806,10 +1928,15 @@ void Renderer::draw_density_grid(const DensityConfig& config, const DensityGrid&
gl_error_ = true; gl_error_ = true;
return; return;
} }
// One texel per cell and a quad exactly that many pixels across, so every fragment lands // **Linear, and it used to be a point sample.** The filter is the thing that makes a cell
// inside its own cell: a point sample is the whole of the filtering this needs, and any // bigger than a pixel acceptable: a cell is a *count*, a continuous quantity, so a fragment
// other mode would blend two sweeps' counts at the cell walls. // between two of them is between their two ink levels -- which is exactly what a linear tap
density_texture_.set_filter(gl::GL_NEAREST, gl::GL_NEAREST); // 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_w_ = columns;
density_h_ = rows; density_h_ = rows;
density_uploaded_grid_ = nullptr; // a fresh texture holds nothing density_uploaded_grid_ = nullptr; // a fresh texture holds nothing
@@ -1847,13 +1974,16 @@ void Renderer::draw_density_grid(const DensityConfig& config, const DensityGrid&
stats_.density_upload_bytes += columns * rows; stats_.density_upload_bytes += columns * rows;
} }
// The quad covers the cells, not the axes rect's own fractional size: `columns`/`rows` are that // The quad covers the image's own **footprint**: the axes rect rounded up to whole device pixels,
// rect rounded up, so mapping uv over them puts texel i exactly on device pixel `axes.x + i`. // the rect `Plot::size_density_grids()` counted the cells on. While that rect is under
// Drawing the un-rounded rect would stretch the last row and column of cells over the fraction. // `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_.bind();
density_program_.set_viewport({viewport_.w, viewport_.h}); density_program_.set_viewport({viewport_.w, viewport_.h});
density_program_.set_rect({layout.axes.x, layout.axes.y}, density_program_.set_rect({layout.axes.x, layout.axes.y},
{static_cast<f32>(columns), static_cast<f32>(rows)}); {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 // 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. // trace at its own brightness, and the shader multiplies it by the cell's luminance.
density_program_.set_color(tint); density_program_.set_color(tint);
@@ -2009,7 +2139,7 @@ void Renderer::draw_legend(const Plot& plot, const PlotLayout& layout) {
// The panel first, so every entry is on top of it. A legend's border is the frame's axis colour; // The panel first, so every entry is on top of it. A legend's border is the frame's axis colour;
// the readout's is the crosshair's own (see draw_crosshair_readout), which is the only thing that // the readout's is the crosshair's own (see draw_crosshair_readout), which is the only thing that
// differs between the two panels -- so it is the one parameter the shared helper takes. // differs between the two panels -- so it is the one parameter the shared helper takes.
if (plot.legend().frame) draw_readout_panel(legend.panel, style_.axis_line); if (plot.legend().frame) draw_readout_panel(legend.panel, style_.axis_line, kLegendPanelAlpha);
for (const LegendRow& row : legend.rows) { for (const LegendRow& row : legend.rows) {
// The truncation row names no trace: it has no swatch to colour and its text is // The truncation row names no trace: it has no swatch to colour and its text is
@@ -2039,7 +2169,7 @@ void Renderer::draw_x_note(const Plot& plot, const PlotLayout& layout) {
// A sliver of axes gets no caption rather than one hanging over the frame: the panel is chrome // A sliver of axes gets no caption rather than one hanging over the frame: the panel is chrome
// *inside* the axes, and a backplate crossing the axis line would read as a second plot. // *inside* the axes, and a backplate crossing the axis line would read as a second plot.
if (panel.w > axes.w || panel.h > axes.h) return; if (panel.w > axes.w || panel.h > axes.h) return;
draw_readout_panel(panel, style_.axis_line); draw_readout_panel(panel, style_.axis_line, kLegendPanelAlpha);
// Bottom-left, beside the labels it qualifies -- the reader adds the caption to a tick, so it // Bottom-left, beside the labels it qualifies -- the reader adds the caption to a tick, so it
// belongs at the end of the tick row and not in a corner of its own. `glyphs_under_` because // belongs at the end of the tick row and not in a corner of its own. `glyphs_under_` because
// the panel is flushed with the frame's line geometry and the under-text batch goes out after // the panel is flushed with the frame's line geometry and the under-text batch goes out after
@@ -2108,12 +2238,13 @@ void Renderer::draw_selection_frame(const PlotLayout& layout) {
flush_lines(pixel_mapping(), style_.selection_edge, style_.device(1.0f)); flush_lines(pixel_mapping(), style_.selection_edge, style_.device(1.0f));
} }
void Renderer::draw_readout_panel(const Rect& panel, Color border) { void Renderer::draw_readout_panel(const Rect& panel, Color border, float background_alpha) {
if (!(panel.w > 0.0f) || !(panel.h > 0.0f)) return; if (!(panel.w > 0.0f) || !(panel.h > 0.0f)) return;
// The frame's own background at a partial alpha, which dims whatever it covers instead of erasing // The frame's own background scaled by the caller's opacity, which dims whatever it covers
// it -- the one backplate both readout panels use. // -- or, at full opacity, covers it outright: a badge read against ticks or a noisy trace
// is read *through* nothing.
Color background = style_.background; Color background = style_.background;
background.a *= kLegendPanelAlpha; background.a *= background_alpha;
fill_pixels(panel, background); fill_pixels(panel, background);
// The border frames the pixels the fill covers, not the measured rect's own fractional edges: // The border frames the pixels the fill covers, not the measured rect's own fractional edges:
// those are wherever the content happened to measure to, and a hairline drawn on one is lit // those are wherever the content happened to measure to, and a hairline drawn on one is lit
@@ -2212,14 +2343,47 @@ void Renderer::draw_crosshair_lines(const Plot& plot, const PlotLayout& layout)
} }
} }
void Renderer::draw_crosshair_axis_badges(const PlotLayout& layout) {
const CrosshairLayout& crosshair = layout.crosshair;
if (!crosshair.active) return;
const bool y_badge = crosshair.y_axis_badge.w > 0.0f && !crosshair.y_axis_text.empty();
const bool x_badge = crosshair.x_axis_badge.w > 0.0f && !crosshair.x_axis_text.empty();
if (!y_badge && !x_badge) return;
// The same backplate the readout panel has, its border in the crosshair's colour too --
// the badge is that panel's value column, moved to where the line meets the axis it
// names. Drawn here rather than in the readout pass because it sits in a *label strip*:
// the strips are drawn after the interior clip is cleared, so a badge that lived in the
// clipped pass would be trimmed to nothing.
//
// **The tick labels are flushed first, and that is the whole point of this panel.** They
// are staged into the over-text batch, which the frame flushes after *every* line batch --
// so a fill drawn straight away would sit under them and the label it covers would print
// through the badge (the "transparent" backplate). They are already placed at this point,
// so pushing them out costs one batch: the fill then covers them, and the badge's own text
// is staged after both fills and leaves with the rest of the over batch, over everything.
flush_text(glyphs_over_);
const f32 pad = style_.device(4.0f);
if (y_badge) draw_readout_panel(crosshair.y_axis_badge, style_.crosshair, 1.0f);
if (x_badge) draw_readout_panel(crosshair.x_axis_badge, style_.crosshair, 1.0f);
if (y_badge) {
stage_text(glyphs_over_,
{crosshair.y_axis_badge.x + pad, crosshair.y_axis_badge.y + pad},
crosshair.y_axis_text.c_str(), style_.axis_text);
}
if (x_badge) {
stage_text(glyphs_over_,
{crosshair.x_axis_badge.x + pad, crosshair.x_axis_badge.y + pad},
crosshair.x_axis_text.c_str(), style_.axis_text);
}
}
void Renderer::draw_crosshair_readout(const Plot& plot, const PlotLayout& layout) { void Renderer::draw_crosshair_readout(const Plot& plot, const PlotLayout& layout) {
const CrosshairLayout& crosshair = layout.crosshair; const CrosshairLayout& crosshair = layout.crosshair;
if (crosshair.empty()) return; if (crosshair.empty()) return;
// The readout: the same backplate a legend has (the shared `draw_readout_panel()`), with its // The readout: the same backplate a legend has (the shared `draw_readout_panel()`), with its
// border in the crosshair's own colour -- which is what makes the panel read as part of the // border in the crosshair's own colour -- which is what makes the panel read as part of the
// instrument that placed it rather than as a second legend. // instrument that placed it rather than as a second legend.
draw_readout_panel(crosshair.panel, style_.crosshair); draw_readout_panel(crosshair.panel, style_.crosshair, kLegendPanelAlpha);
for (const CrosshairRow& row : crosshair.rows) { for (const CrosshairRow& row : crosshair.rows) {
if (!row.listed) continue; if (!row.listed) continue;
+5 -5
View File
@@ -241,7 +241,7 @@ const char* const kDensityVS = R"(#version 330 core
// fill. The density's own bytes go to a texture (see kDensityFS), never to a buffer. // 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_viewport; // render target size, pixels
uniform vec2 u_origin; // the quad's top-left corner in Rect 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 grid's columns and rows uniform vec2 u_size; // the quad's size in pixels -- the axes rect, which the image fills
out vec2 v_uv; out vec2 v_uv;
@@ -253,9 +253,9 @@ void main() {
// Rect is top-left origin, GL is bottom-left: the one and only y flip. // 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); 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); gl_Position = vec4(ndc, 0.0, 1.0);
// u_size is the grid's own size in pixels and the texture is one texel per cell, so one // uv runs 0..1 across the whole image, so a fragment lands wherever the axes rect puts it
// unit of uv is exactly one cell and a GL_NEAREST fetch lands on the cell the fragment is // between the grid's cells: one texel a cell while the rect is under the cap (a cell a device
// inside -- there is nothing to filter and nothing to round. // pixel, the sampling all but a point sample) and a linear magnification of the image past it.
v_uv = corner; v_uv = corner;
} }
)"; )";
@@ -263,7 +263,7 @@ void main() {
const char* const kDensityFS = R"(#version 330 core const char* const kDensityFS = R"(#version 330 core
in vec2 v_uv; // cell coordinate in [0,1] across the quad in vec2 v_uv; // cell coordinate in [0,1] across the quad
uniform sampler2D u_density; // GL_R8 luminance, one texel per cell, GL_NEAREST uniform sampler2D u_density; // GL_R8 luminance, one texel per cell, GL_LINEAR
uniform vec4 u_color; uniform vec4 u_color;
out vec4 o_color; out vec4 o_color;
+553
View File
@@ -16,6 +16,7 @@
#include <plot/render/embedded_mono_font.hpp> #include <plot/render/embedded_mono_font.hpp>
#include <cmath> #include <cmath>
#include <span>
#include <vector> #include <vector>
namespace { namespace {
@@ -1141,3 +1142,555 @@ PLOT_TEST(figure_crosshair_draws_a_horizontal_line_only_in_the_hovered_row) {
PLOT_CHECK(figure.plot(1u).layout().crosshair.vertical.w > 0.0f); PLOT_CHECK(figure.plot(1u).layout().crosshair.vertical.w > 0.0f);
PLOT_CHECK(figure.plot(1u).layout().crosshair.horizontal.h == 0.0f); PLOT_CHECK(figure.plot(1u).layout().crosshair.horizontal.h == 0.0f);
} }
// ------------------------------------------------------------------ cell grid
namespace {
/// A figure whose rows hold `cells[r]` plots each (the flattened plots in row-major
/// order), with an x and a y axis on every plot unless `axes` is off.
plot::Figure split_figure(const std::vector<plot::u32>& cells, bool axes = true) {
plot::Figure figure{std::span<const plot::u32>(cells)}; // braces: parentheses would be a vexing parse
figure.set_viewport(whole_figure());
if (axes) {
for (plot::u32 i = 0; i < figure.plot_count(); ++i) {
figure.plot(i).add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
figure.plot(i).add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
}
}
return figure;
}
} // namespace
PLOT_TEST(figure_cells_all_ones_equals_rows) {
plot::Figure split = split_figure({1, 1, 1});
plot::Figure plain = figure_with_axes(3u);
const plot::FigureLayout& sl = split.layout();
const plot::FigureLayout& pl = plain.layout();
PLOT_CHECK(split.row_count() == 3u && split.plot_count() == 3u);
PLOT_CHECK(sl.cells.size() == 3u);
for (plot::u32 i = 0; i < 3u; ++i) {
PLOT_CHECK_NEAR(sl.cells[i].y, pl.rows[i].y, 0.01);
PLOT_CHECK_NEAR(sl.cells[i].h, pl.rows[i].h, 0.01);
PLOT_CHECK_NEAR(sl.cells[i].w, pl.rows[i].w, 0.01);
}
}
PLOT_TEST(figure_cells_tile_exactly) {
plot::Figure figure = split_figure({1, 2, 1});
figure.set_col_gap(10.0f);
const plot::FigureLayout& fl = figure.layout();
PLOT_CHECK(figure.row_count() == 3u && figure.plot_count() == 4u);
PLOT_CHECK(figure.cells_in_row(1u) == 2u);
PLOT_CHECK(figure.first_plot_of_row(1u) == 1u && figure.first_plot_of_row(3u) == 4u);
PLOT_CHECK(figure.row_of_plot(2u) == 1u);
PLOT_CHECK_NEAR(fl.cells[2].x - fl.cells[1].right(), 10.0, 0.01);
PLOT_CHECK_NEAR(fl.cells[2].right(), fl.rows[1].right(), 0.001);
PLOT_CHECK_NEAR(fl.cells[1].x, fl.rows[1].x, 0.001);
PLOT_CHECK_NEAR(fl.cells[1].y, fl.cells[2].y, 0.001);
PLOT_CHECK_NEAR(fl.cells[1].h, fl.cells[2].h, 0.001);
PLOT_CHECK_NEAR(fl.cells[1].w, fl.cells[2].w, 0.01);
PLOT_CHECK_NEAR(fl.col_gap, 10.0, 0.001);
}
PLOT_TEST(figure_cell_weights_and_floor) {
plot::Figure figure = split_figure({2});
figure.set_col_gap(0.0f);
const std::vector<plot::f32> w{3.0f, 1.0f};
PLOT_CHECK(figure.set_cell_weights(0u, w));
PLOT_CHECK(figure.has_cell_weights(0u));
{
const auto& c = figure.layout().cells;
PLOT_CHECK_NEAR((c[0].w - 64.0), (c[1].w - 64.0) * 3.0, 0.01);
}
const std::vector<plot::f32> tiny{1000.0f, 0.001f};
PLOT_CHECK(figure.set_cell_weights(0u, tiny));
PLOT_CHECK(figure.layout().cells[1].w >= 64.0f - 0.001f);
const std::vector<plot::f32> three{1.0f, 1.0f, 1.0f};
const std::vector<plot::f32> zero{1.0f, 0.0f};
PLOT_CHECK(!figure.set_cell_weights(0u, three));
PLOT_CHECK(!figure.set_cell_weights(0u, zero));
PLOT_CHECK(!figure.set_cell_weights(5u, w));
figure.clear_cell_weights(0u);
PLOT_CHECK(!figure.has_cell_weights(0u));
const auto& c = figure.layout().cells;
PLOT_CHECK_NEAR(c[0].w, c[1].w, 0.01);
}
PLOT_TEST(figure_cells_narrow_degrades) {
plot::Figure figure = split_figure({4}, false);
figure.set_viewport(plot::Rect{0.0f, 0.0f, 20.0f, 100.0f});
for (plot::u32 i = 0; i < 4u; ++i) {
figure.plot(i).add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
figure.plot(i).add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
}
figure.set_col_gap(10.0f);
const plot::FigureLayout& fl = figure.layout();
double sum = 0.0;
for (const plot::Rect& c : fl.cells) {
PLOT_CHECK(c.w >= 0.0f && c.h >= 0.0f);
PLOT_CHECK_NEAR(c.h, 100.0, 0.01);
sum += c.w;
}
PLOT_CHECK_NEAR(sum, 20.0, 0.01);
PLOT_CHECK_NEAR(fl.col_gap, 0.0, 0.001);
}
PLOT_TEST(figure_cells_share_x_link_and_crosshair) {
plot::Figure figure = split_figure({2, 1});
const plot::u32 group = figure.add_x_link();
for (plot::u32 i = 0; i < 3u; ++i) PLOT_CHECK(figure.link_x(i, group));
figure.plot(1u).set_x_range(0u, {3.0, 9.0});
figure.layout();
for (plot::u32 i = 0; i < 3u; ++i) {
PLOT_CHECK_NEAR(figure.plot(i).x_range(0u).min, 3.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(i).x_range(0u).max, 9.0, 1e-9);
}
figure.set_crosshair(4.0, true);
for (plot::u32 i = 0; i < 3u; ++i) {
PLOT_CHECK(figure.plot(i).crosshair().active);
PLOT_CHECK_NEAR(figure.plot(i).crosshair().x, 4.0, 1e-9);
}
figure.clear_crosshair();
for (plot::u32 i = 0; i < 3u; ++i) PLOT_CHECK(!figure.plot(i).crosshair().active);
}
PLOT_TEST(figure_cells_x_labels_bottom_row_only) {
// Rows with the same columns share the bottom one's axis: {1,1,1} labels the last row only.
plot::Figure figure = split_figure({1, 1, 1});
const plot::FigureLayout& fl = figure.layout();
PLOT_CHECK(figure.plot(0u).layout().x_label_area.h == 0.0f);
PLOT_CHECK(figure.plot(1u).layout().x_label_area.h == 0.0f);
PLOT_CHECK(figure.plot(2u).layout().x_label_area.h > 0.0f);
PLOT_CHECK_NEAR(fl.x_axis_strip.h, figure.plot(2u).layout().x_label_area.h, 0.001);
}
PLOT_TEST(figure_cells_common_y_gutter) {
plot::Figure figure = split_figure({2}, false);
give_magnitude_row(figure.plot(0u), 0u);
give_magnitude_row(figure.plot(1u), 1u);
const plot::FigureLayout& fl = figure.layout();
const double g0 = figure.plot(0u).layout().axes.x - fl.cells[0].x;
const double g1 = figure.plot(1u).layout().axes.x - fl.cells[1].x;
PLOT_CHECK(g0 > 0.0);
PLOT_CHECK_NEAR(g0, g1, 0.01);
}
PLOT_TEST(figure_row_y_cells_share_a_range_after_a_gesture) {
plot::Figure figure = split_figure({2, 1});
figure.set_share_row_y(true);
figure.layout();
figure.plot(1u).set_y_range(0u, {-3.0, 7.0}); // what a wheel / drag / pan ends in
figure.layout();
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).min, -3.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).max, 7.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(1u).y_range(0u).min, -3.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(1u).y_range(0u).max, 7.0, 1e-9);
// The unsplit row is its own thing.
PLOT_CHECK_NEAR(figure.plot(2u).y_range(0u).min, -1.0, 1e-9);
PLOT_CHECK(!figure.row_y_auto(0u));
}
/// The horizontal line is one *value*, and a split row's cells share one y scale: the line
/// spans the whole row instead of stopping at the divider between two cells that read the same
/// numbers. The rows of the figure still do not share y, so no other row gets one.
PLOT_TEST(figure_crosshair_horizontal_spans_a_split_rows_cells) {
plot::Figure figure = split_figure({2, 1});
figure.set_share_row_y(true);
figure.layout();
// The pointer in the *second* cell of the split row, half-way down it.
const plot::Rect cell1 = figure.layout().cells[1];
const plot::Transform ty = figure.plot(1u).y_transform(0u);
const plot::f32 y = static_cast<plot::f32>(ty.to_data_y(cell1.y + 0.5f * cell1.h));
figure.set_crosshair(50.0, 1u, y, true);
figure.layout();
const plot::CrosshairLayout& left = figure.plot(0u).layout().crosshair;
const plot::CrosshairLayout& right = figure.plot(1u).layout().crosshair;
const plot::CrosshairLayout& other = figure.plot(2u).layout().crosshair;
// Both cells of the row carry the line, at one screen row: they share the y scale, so the
// row's own y is the same value's height in each.
PLOT_CHECK(left.horizontal.w > 0.0f);
PLOT_CHECK(right.horizontal.w > 0.0f);
PLOT_CHECK_NEAR(left.horizontal.y + 0.5f * left.horizontal.h,
right.horizontal.y + 0.5f * right.horizontal.h, 0.75);
// The unsplit row keeps the shared-x vertical line and no horizontal one -- a line there
// would mean a value on a scale that row does not have.
PLOT_CHECK(other.vertical.w > 0.0f);
PLOT_CHECK(other.horizontal.h == 0.0f);
// The spread does not touch the x: both cells keep the one shared column.
PLOT_CHECK(left.vertical.w > 0.0f);
PLOT_CHECK(right.vertical.w > 0.0f);
// Without `set_share_row_y` the cells are separate scales again, and the line is the
// hovered cell's alone.
figure.set_share_row_y(false);
figure.set_crosshair(50.0, 1u, y, true);
figure.layout();
PLOT_CHECK(figure.plot(1u).layout().crosshair.horizontal.w > 0.0f);
PLOT_CHECK(figure.plot(0u).layout().crosshair.horizontal.h == 0.0f);
}
PLOT_TEST(figure_row_y_auto_fits_the_union_and_ignores_empty_cells) {
plot::Figure figure = split_figure({3});
for (plot::u32 i = 0; i < 3u; ++i) figure.plot(i).set_y_auto_scale(0u, true);
plot::data::ArraySeries a(std::vector<plot::f32>{0.0f, 10.0f, 5.0f}, 0.0, 1.0);
plot::data::ArraySeries b(std::vector<plot::f32>{-20.0f, 5.0f, 0.0f}, 0.0, 1.0);
figure.plot(0u).add_trace({&a, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
figure.plot(1u).add_trace({&b, plot::Color::hex(0xFF8080), 1.5f, 0u, 0u});
figure.set_share_row_y(true); // cell 2 has no trace
figure.layout();
PLOT_CHECK(figure.row_y_auto(0u));
const plot::Range r0 = figure.plot(0u).y_range(0u);
PLOT_CHECK(r0.min <= -20.0 && r0.max >= 10.0);
PLOT_CHECK(r0.max - r0.min < 40.0); // padded, not blown up
for (plot::u32 i = 1; i < 3u; ++i) {
PLOT_CHECK_NEAR(figure.plot(i).y_range(0u).min, r0.min, 1e-9);
PLOT_CHECK_NEAR(figure.plot(i).y_range(0u).max, r0.max, 1e-9);
}
}
PLOT_TEST(figure_row_y_manual_then_fit_returns_to_auto) {
plot::Figure figure = split_figure({2});
for (plot::u32 i = 0; i < 2u; ++i) figure.plot(i).set_y_auto_scale(0u, true);
plot::data::ArraySeries a(std::vector<plot::f32>{0.0f, 10.0f}, 0.0, 1.0);
plot::data::ArraySeries b(std::vector<plot::f32>{-4.0f, 4.0f}, 0.0, 1.0);
figure.plot(0u).add_trace({&a, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
figure.plot(1u).add_trace({&b, plot::Color::hex(0xFF8080), 1.5f, 0u, 0u});
figure.set_share_row_y(true);
figure.layout();
figure.plot(1u).set_y_range(0u, {1.0, 2.0});
figure.layout();
PLOT_CHECK(!figure.row_y_auto(0u));
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).min, 1.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).max, 2.0, 1e-9);
figure.layout(); // manual holds: no fight with the plots' own autoscale
PLOT_CHECK_NEAR(figure.plot(1u).y_range(0u).max, 2.0, 1e-9);
figure.plot(0u).auto_fit_y(0u); // what fit-all / double-click does to one cell
figure.layout();
PLOT_CHECK(figure.row_y_auto(0u));
PLOT_CHECK(figure.plot(1u).y_range(0u).min <= -4.0 && figure.plot(1u).y_range(0u).max >= 10.0);
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).min, figure.plot(1u).y_range(0u).min, 1e-9);
}
PLOT_TEST(figure_row_y_sharing_off_keeps_cells_independent) {
plot::Figure figure = split_figure({2});
figure.layout();
figure.plot(1u).set_y_range(0u, {-3.0, 7.0});
figure.layout();
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).max, 1.0, 1e-9);
PLOT_CHECK(!figure.share_row_y());
}
PLOT_TEST(figure_row_y_only_the_first_cell_has_a_gutter) {
plot::Figure figure = split_figure({1, 3}, false);
for (plot::u32 i = 0; i < figure.plot_count(); ++i) give_magnitude_row(figure.plot(i), 0u);
figure.set_share_row_y(true);
figure.layout();
PLOT_CHECK(figure.plot(0u).layout().y_axis_width > 0.0f); // unsplit row keeps its axis
PLOT_CHECK(figure.plot(1u).layout().y_axis_width > 0.0f); // first cell of the split row
for (plot::u32 i = 2; i < 4u; ++i) {
const plot::PlotLayout& l = figure.plot(i).layout();
PLOT_CHECK(l.y_axis_width == 0.0f && l.y_title_width == 0.0f && l.y_label_area.w == 0.0f);
PLOT_CHECK(l.axes.w > 0.0f);
}
// The gutter is one figure-wide value: the unsplit row and the split row's first cell agree.
PLOT_CHECK_NEAR(figure.plot(0u).layout().axes.x - figure.layout().cells[0].x,
figure.plot(1u).layout().axes.x - figure.layout().cells[1].x, 0.01);
}
PLOT_TEST(figure_row_y_equal_weights_give_equal_plot_widths_and_cells_tile) {
plot::Figure figure = split_figure({3}, false);
for (plot::u32 i = 0; i < 3u; ++i) give_magnitude_row(figure.plot(i), 0u);
figure.set_col_gap(8.0f);
figure.set_share_row_y(true);
const plot::FigureLayout& fl = figure.layout();
const double w0 = figure.plot(0u).layout().axes.w;
PLOT_CHECK_NEAR(figure.plot(1u).layout().axes.w, w0, 0.6);
PLOT_CHECK_NEAR(figure.plot(2u).layout().axes.w, w0, 0.6);
PLOT_CHECK_NEAR(fl.cells[0].x, fl.rows[0].x, 0.001);
PLOT_CHECK_NEAR(fl.cells[2].right(), fl.rows[0].right(), 0.001);
PLOT_CHECK_NEAR(fl.cells[1].x - fl.cells[0].right(), 8.0, 0.01);
PLOT_CHECK_NEAR(fl.cells[2].x - fl.cells[1].right(), 8.0, 0.01);
}
PLOT_TEST(figure_row_y_weights_apply_to_plot_widths) {
plot::Figure figure = split_figure({2}, false);
for (plot::u32 i = 0; i < 2u; ++i) give_magnitude_row(figure.plot(i), 0u);
figure.set_col_gap(0.0f);
figure.set_share_row_y(true);
const std::vector<plot::f32> w{3.0f, 1.0f};
PLOT_CHECK(figure.set_cell_weights(0u, w));
figure.layout();
const double a0 = figure.plot(0u).layout().axes.w;
const double a1 = figure.plot(1u).layout().axes.w;
PLOT_CHECK_NEAR(a0 - 64.0, (a1 - 64.0) * 3.0, 1.0);
}
PLOT_TEST(figure_row_y_narrow_row_degrades_without_negative_rects) {
plot::Figure figure = split_figure({4}, false);
figure.set_viewport(plot::Rect{0.0f, 0.0f, 30.0f, 100.0f});
for (plot::u32 i = 0; i < 4u; ++i) give_magnitude_row(figure.plot(i), 0u);
figure.set_col_gap(10.0f);
figure.set_share_row_y(true);
double sum = 0.0;
for (const plot::Rect& c : figure.layout().cells) {
PLOT_CHECK(c.w >= 0.0f && c.h >= 0.0f);
sum += c.w;
}
PLOT_CHECK_NEAR(sum, 30.0, 0.05);
}
PLOT_TEST(figure_row_y_sharing_off_still_gives_every_cell_a_gutter) {
plot::Figure figure = split_figure({2}, false);
give_magnitude_row(figure.plot(0u), 0u);
give_magnitude_row(figure.plot(1u), 1u);
figure.layout();
PLOT_CHECK(figure.plot(1u).layout().y_axis_width > 0.0f);
}
// ------------------------------------------------- x axes follow the columns
namespace {
/// A split figure with a text backend and a y axis of a real magnitude on every plot (so the
/// shared y gutter is non-zero), the host's configuration: row-shared y, a 6 px column gap and
/// a 35 px right gutter.
plot::Figure host_like_figure(const std::vector<plot::u32>& cells, bool share_y = true) {
plot::Figure figure = split_figure(cells, false);
for (plot::u32 i = 0; i < figure.plot_count(); ++i) give_magnitude_row(figure.plot(i), i);
figure.set_col_gap(6.0f);
figure.set_right_gutter(35.0f);
figure.set_share_row_y(share_y);
return figure;
}
bool shows_x_axis(plot::Figure& figure, plot::u32 plot_index) {
const plot::PlotLayout& l = figure.plot(plot_index).layout();
return l.x_axis_height > 0.0f && l.x_label_area.h > 0.0f;
}
bool hides_x_axis(plot::Figure& figure, plot::u32 plot_index) {
const plot::PlotLayout& l = figure.plot(plot_index).layout();
return l.x_axis_height == 0.0f && l.x_label_area.h == 0.0f;
}
} // namespace
PLOT_TEST(figure_x_axis_a_split_row_over_an_unsplit_one_keeps_its_own) {
plot::Figure figure = host_like_figure({2, 1});
figure.layout();
PLOT_CHECK(shows_x_axis(figure, 0u));
PLOT_CHECK(shows_x_axis(figure, 1u));
PLOT_CHECK(shows_x_axis(figure, 2u));
// ... and the other way round.
plot::Figure other = host_like_figure({1, 2});
other.layout();
PLOT_CHECK(shows_x_axis(other, 0u));
PLOT_CHECK(shows_x_axis(other, 1u));
PLOT_CHECK(shows_x_axis(other, 2u));
}
PLOT_TEST(figure_x_axis_rows_with_the_same_columns_share_the_bottom_axis) {
plot::Figure figure = host_like_figure({2, 2});
figure.layout();
PLOT_CHECK(hides_x_axis(figure, 0u));
PLOT_CHECK(hides_x_axis(figure, 1u));
PLOT_CHECK(shows_x_axis(figure, 2u));
PLOT_CHECK(shows_x_axis(figure, 3u));
// The hidden row's cells have exactly the x extents of the cells below, so the axis below
// serves them.
const plot::FigureLayout& fl = figure.layout();
for (plot::u32 c = 0; c < 2u; ++c) {
PLOT_CHECK_NEAR(fl.cells[c].x, fl.cells[2u + c].x, 1e-4);
PLOT_CHECK_NEAR(fl.cells[c].w, fl.cells[2u + c].w, 1e-4);
const plot::PlotLayout& up = figure.plot(c).layout();
const plot::PlotLayout& down = figure.plot(2u + c).layout();
PLOT_CHECK_NEAR(up.axes.x, down.axes.x, 1e-4);
PLOT_CHECK_NEAR(up.axes.w, down.axes.w, 1e-4);
PLOT_CHECK_NEAR(up.y_axis_width, down.y_axis_width, 1e-4);
}
// The same without row-shared y (every cell has its own y strip).
plot::Figure own_y = host_like_figure({2, 2}, false);
own_y.layout();
PLOT_CHECK(hides_x_axis(own_y, 0u) && hides_x_axis(own_y, 1u));
PLOT_CHECK(shows_x_axis(own_y, 2u) && shows_x_axis(own_y, 3u));
for (plot::u32 c = 0; c < 2u; ++c) {
PLOT_CHECK_NEAR(own_y.plot(c).layout().axes.x, own_y.plot(2u + c).layout().axes.x, 1e-4);
PLOT_CHECK_NEAR(own_y.plot(c).layout().axes.w, own_y.plot(2u + c).layout().axes.w, 1e-4);
}
}
PLOT_TEST(figure_x_axis_matching_compares_normalised_weights) {
const std::vector<plot::f32> one_three{1.0f, 3.0f};
const std::vector<plot::f32> two_six{2.0f, 6.0f};
const std::vector<plot::f32> halves{1.0f, 1.0f};
{ // 1:3 over 1:3
plot::Figure figure = host_like_figure({2, 2});
PLOT_CHECK(figure.set_cell_weights(0u, one_three));
PLOT_CHECK(figure.set_cell_weights(1u, one_three));
figure.layout();
PLOT_CHECK(hides_x_axis(figure, 0u) && hides_x_axis(figure, 1u));
PLOT_CHECK(shows_x_axis(figure, 2u) && shows_x_axis(figure, 3u));
const plot::FigureLayout& fl = figure.layout();
PLOT_CHECK_NEAR(fl.cells[0].w, fl.cells[2].w, 1e-3);
PLOT_CHECK_NEAR(figure.plot(1u).layout().axes.x, figure.plot(3u).layout().axes.x, 1e-3);
}
{ // 1:3 over 2:6 -- normalised, so the same columns
plot::Figure figure = host_like_figure({2, 2});
PLOT_CHECK(figure.set_cell_weights(0u, one_three));
PLOT_CHECK(figure.set_cell_weights(1u, two_six));
figure.layout();
PLOT_CHECK(hides_x_axis(figure, 0u) && hides_x_axis(figure, 1u));
}
{ // 1:3 over 1:1
plot::Figure figure = host_like_figure({2, 2});
PLOT_CHECK(figure.set_cell_weights(0u, one_three));
PLOT_CHECK(figure.set_cell_weights(1u, halves));
figure.layout();
PLOT_CHECK(shows_x_axis(figure, 0u) && shows_x_axis(figure, 1u));
PLOT_CHECK(shows_x_axis(figure, 2u) && shows_x_axis(figure, 3u));
}
{ // 1:3 over (no weights = equal)
plot::Figure figure = host_like_figure({2, 2});
PLOT_CHECK(figure.set_cell_weights(0u, one_three));
figure.layout();
PLOT_CHECK(shows_x_axis(figure, 0u) && shows_x_axis(figure, 1u));
}
{ // explicit 1:1 over (no weights = equal)
plot::Figure figure = host_like_figure({2, 2});
PLOT_CHECK(figure.set_cell_weights(0u, halves));
figure.layout();
PLOT_CHECK(hides_x_axis(figure, 0u) && hides_x_axis(figure, 1u));
}
{ // a different number of cells never matches, whatever the weights
plot::Figure figure = host_like_figure({3, 2});
figure.layout();
PLOT_CHECK(shows_x_axis(figure, 0u) && shows_x_axis(figure, 2u));
}
}
PLOT_TEST(figure_x_axis_unsplit_rows_are_unchanged) {
plot::Figure figure = host_like_figure({1, 1, 1});
figure.layout();
PLOT_CHECK(hides_x_axis(figure, 0u));
PLOT_CHECK(hides_x_axis(figure, 1u));
PLOT_CHECK(shows_x_axis(figure, 2u));
PLOT_CHECK_NEAR(figure.layout().x_axis_strip.h, figure.plot(2u).layout().x_label_area.h, 1e-4);
}
PLOT_TEST(figure_x_axis_follows_a_chain_of_matching_rows) {
{ // [2]/[2]/[1]: row 0 matches row 1 and hides; row 1 does not match row 2 and shows.
plot::Figure figure = host_like_figure({2, 2, 1});
figure.layout();
PLOT_CHECK(hides_x_axis(figure, 0u) && hides_x_axis(figure, 1u));
PLOT_CHECK(shows_x_axis(figure, 2u) && shows_x_axis(figure, 3u));
PLOT_CHECK(shows_x_axis(figure, 4u));
}
{ // [2]/[2]/[2]: transitive -- only the last row carries the axis.
plot::Figure figure = host_like_figure({2, 2, 2});
figure.layout();
for (plot::u32 i = 0; i < 4u; ++i) PLOT_CHECK(hides_x_axis(figure, i));
PLOT_CHECK(shows_x_axis(figure, 4u) && shows_x_axis(figure, 5u));
}
{ // [1]/[2]/[2]: the unsplit row matches nothing below it.
plot::Figure figure = host_like_figure({1, 2, 2});
figure.layout();
PLOT_CHECK(shows_x_axis(figure, 0u));
PLOT_CHECK(hides_x_axis(figure, 1u) && hides_x_axis(figure, 2u));
PLOT_CHECK(shows_x_axis(figure, 3u) && shows_x_axis(figure, 4u));
}
{ // the figure's x_axis_strip is still the last plot's label area
plot::Figure figure = host_like_figure({2, 1});
const plot::FigureLayout& fl = figure.layout();
PLOT_CHECK(fl.x_axis_strip.h > 0.0f);
PLOT_CHECK_NEAR(fl.x_axis_strip.h, figure.plot(2u).layout().x_label_area.h, 1e-4);
}
}
PLOT_TEST(figure_x_axis_labels_on_every_row_when_asked) {
plot::Figure figure = host_like_figure({2, 2, 1});
figure.set_x_labels_only_on_bottom(false);
figure.layout();
for (plot::u32 i = 0; i < figure.plot_count(); ++i) PLOT_CHECK(shows_x_axis(figure, i));
}
// -------------------------------------------- the gap between cells of a row
PLOT_TEST(figure_split_row_gap_between_plot_frames_is_the_column_gap) {
for (const plot::u32 n : {2u, 3u, 4u}) {
plot::Figure figure = host_like_figure({n});
const plot::FigureLayout& fl = figure.layout();
PLOT_CHECK_NEAR(fl.col_gap, 6.0, 1e-4);
for (plot::u32 k = 0; k + 1u < n; ++k) {
PLOT_CHECK_NEAR(figure.plot(k + 1u).layout().axes.x - figure.plot(k).layout().axes.right(),
6.0, 1e-3);
PLOT_CHECK_NEAR(fl.cells[k + 1u].x - fl.cells[k].right(), 6.0, 1e-3);
// Only the last cell holds the right gutter.
PLOT_CHECK_NEAR(figure.plot(k).layout().right_gutter, 0.0, 1e-6);
}
// Equal weights give equal axes widths, the first cell included.
for (plot::u32 k = 1; k < n; ++k) {
PLOT_CHECK_NEAR(figure.plot(k).layout().axes.w, figure.plot(0u).layout().axes.w, 0.6);
}
// The cells tile the row exactly.
PLOT_CHECK_NEAR(fl.cells[0].x, fl.rows[0].x, 1e-4);
PLOT_CHECK_NEAR(fl.cells[n - 1u].right(), fl.rows[0].right(), 1e-3);
// The right gutter is still there, on the last cell.
const plot::PlotLayout& last = figure.plot(n - 1u).layout();
PLOT_CHECK_NEAR(fl.cells[n - 1u].right() - last.axes.right(), 35.0, 1e-3);
PLOT_CHECK_NEAR(last.right_gutter, 35.0, 1e-3);
// The y gutter still sits on the first cell alone.
PLOT_CHECK(figure.plot(0u).layout().axes.x > fl.cells[0].x);
PLOT_CHECK_NEAR(figure.plot(1u).layout().axes.x, fl.cells[1].x, 1e-4);
}
}
PLOT_TEST(figure_split_row_gap_with_weights_and_a_y_gutter) {
plot::Figure figure = host_like_figure({1, 2});
const std::vector<plot::f32> w{3.0f, 1.0f};
PLOT_CHECK(figure.set_cell_weights(1u, w));
const plot::FigureLayout& fl = figure.layout();
const plot::Rect a0 = figure.plot(1u).layout().axes;
const plot::Rect a1 = figure.plot(2u).layout().axes;
PLOT_CHECK_NEAR(a1.x - a0.right(), 6.0, 1e-3);
PLOT_CHECK_NEAR(a0.w - 64.0, (a1.w - 64.0) * 3.0, 1.0);
PLOT_CHECK_NEAR(fl.cells[2].right(), fl.rows[1].right(), 1e-3);
PLOT_CHECK_NEAR(fl.cells[1].x, fl.rows[1].x, 1e-4);
PLOT_CHECK_NEAR(fl.cells[2].right() - a1.right(), 35.0, 1e-3);
// The unsplit row above keeps its own right gutter on its one plot.
PLOT_CHECK_NEAR(fl.cells[0].right() - figure.plot(0u).layout().axes.right(), 35.0, 1e-3);
}
PLOT_TEST(figure_split_row_gutters_degrade_on_a_narrow_row) {
plot::Figure figure = host_like_figure({3});
figure.set_viewport(plot::Rect{0.0f, 0.0f, 60.0f, 100.0f});
const plot::FigureLayout& fl = figure.layout();
double sum = 0.0;
for (const plot::Rect& c : fl.cells) {
PLOT_CHECK(c.w >= 0.0f && c.h >= 0.0f);
sum += c.w;
}
PLOT_CHECK_NEAR(sum + fl.col_gap * 2.0, 60.0, 0.05);
PLOT_CHECK_NEAR(fl.cells[2].right(), 60.0, 0.05);
for (plot::u32 i = 0; i < 3u; ++i) PLOT_CHECK(figure.plot(i).layout().axes.w >= 0.0f);
}
PLOT_TEST(figure_row_gutter_without_sharing_is_the_old_layout) {
plot::Figure figure = host_like_figure({2}, false);
const plot::FigureLayout& fl = figure.layout();
// Plain cells: equal halves of the row less the gap, every plot with its own right gutter.
PLOT_CHECK_NEAR(fl.cells[0].w, (fl.rows[0].w - 6.0) / 2.0, 1e-3);
PLOT_CHECK_NEAR(fl.cells[1].w, (fl.rows[0].w - 6.0) / 2.0, 1e-3);
PLOT_CHECK_NEAR(fl.cells[1].x - fl.cells[0].right(), 6.0, 1e-4);
for (plot::u32 i = 0; i < 2u; ++i) {
PLOT_CHECK_NEAR(figure.plot(i).layout().right_gutter, 35.0, 1e-3);
PLOT_CHECK_NEAR(fl.cells[i].right() - figure.plot(i).layout().axes.right(), 35.0, 1e-3);
}
// An unsplit row is unchanged by sharing being on.
plot::Figure unsplit = host_like_figure({1, 1});
const plot::FigureLayout& ul = unsplit.layout();
for (plot::u32 i = 0; i < 2u; ++i) {
PLOT_CHECK_NEAR(ul.cells[i].w, ul.rows[i].w, 1e-4);
PLOT_CHECK_NEAR(ul.cells[i].right() - unsplit.plot(i).layout().axes.right(), 35.0, 1e-3);
}
}
+31
View File
@@ -14,6 +14,9 @@
#include <glfw/glfw_host.hpp> #include <glfw/glfw_host.hpp>
#include <plot/plot/figure.hpp> #include <plot/plot/figure.hpp>
#include <span>
#include <vector>
namespace { namespace {
/// A figure of `rows` rows over a known rect, with an x and a y axis in every row (so /// A figure of `rows` rows over a known rect, with an x and a y axis in every row (so
@@ -315,3 +318,31 @@ PLOT_TEST(the_host_keeps_its_own_controller_while_no_figure_is_set) {
PLOT_CHECK(host.row_controller(0u) == nullptr); PLOT_CHECK(host.row_controller(0u) == nullptr);
PLOT_CHECK(&host.controller_at(10.0f, 10.0f) == &host.controller()); PLOT_CHECK(&host.controller_at(10.0f, 10.0f) == &host.controller());
} }
PLOT_TEST(glfw_host_routes_by_cell) {
// A figure of two rows, the first of one cell and the second of two side by side: the
// pointer's x as well as its y picks the plot, and the controllers are indexed by the
// flattened plot.
std::vector<plot::u32> cells{1, 2};
plot::Figure figure{std::span<const plot::u32>(cells)};
figure.set_viewport(plot::Rect{0, 0, 300, 600});
figure.set_col_gap(6.0f);
for (plot::u32 i = 0; i < 3; ++i) {
figure.plot(i).add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
figure.plot(i).add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
}
plot::glfw::Host host;
host.set_figure(&figure);
const std::vector<plot::Rect> c = figure.layout().cells;
PLOT_CHECK(c.size() == 3u);
for (plot::u32 i = 0; i < 3; ++i) {
PLOT_CHECK(host.row_controller(i) != nullptr);
const double cx = c[i].x + c[i].w * 0.5, cy = c[i].y + c[i].h * 0.5;
PLOT_CHECK(&host.controller_at(static_cast<plot::f32>(cx), static_cast<plot::f32>(cy)) ==
host.row_controller(i));
}
PLOT_CHECK(host.row_controller(3u) == nullptr);
// in the column gap: the nearest cell wins
PLOT_CHECK(&host.controller_at(c[1].right() + 1.0f, c[1].y + 5.0f) == host.row_controller(1));
PLOT_CHECK(&host.controller_at(c[2].x - 1.0f, c[2].y + 5.0f) == host.row_controller(2));
}
+84 -14
View File
@@ -2839,14 +2839,34 @@ struct DensityFixture {
} // namespace } // namespace
/// The grid is the screen's, not the data's: one cell per device pixel of the axes rect, and /// The grid is the screen's, not the data's: one cell per device pixel of the axes rect **up to
/// nothing at all until the density is switched on. The figures here are the ones the API's /// `kDensityCellCap`**, and nothing at all until the density is switched on. The figures here are
/// memory claim rests on -- `cell_count * 4` bytes, whatever the trigger has done. /// 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) { PLOT_TEST(a_density_grid_is_the_axes_in_device_pixels) {
DensityFixture f; DensityFixture f;
const plot::Rect axes = f.plot.layout().axes; const plot::Rect axes = f.plot.layout().axes;
const plot::u32 columns = static_cast<plot::u32>(std::ceil(axes.w)); // The 640x480 viewport this fixture asks for is past the cap, so its grid is the axes rect
const plot::u32 rows = static_cast<plot::u32>(std::ceil(axes.h)); // *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 // Off is the default, and off is *nothing*: no grid, no ink, and an accumulation that
// deposits nothing at all. // deposits nothing at all.
@@ -2866,8 +2886,9 @@ PLOT_TEST(a_density_grid_is_the_axes_in_device_pixels) {
PLOT_CHECK(grid.columns() == columns); PLOT_CHECK(grid.columns() == columns);
PLOT_CHECK(grid.rows() == rows); PLOT_CHECK(grid.rows() == rows);
PLOT_CHECK(grid.cell_count() == static_cast<plot::usize>(columns) * rows); PLOT_CHECK(grid.cell_count() == static_cast<plot::usize>(columns) * rows);
// A 640x480 viewport, minus the chrome: `4 bytes * cells`. // `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); 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", 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(), grid.columns(), grid.rows(), grid.cell_count(), bytes, grid.ink(),
static_cast<double>(grid.peak())); static_cast<double>(grid.peak()));
@@ -2879,7 +2900,7 @@ PLOT_TEST(a_density_grid_is_the_axes_in_device_pixels) {
// ... and all of it in one row, the one y = 0 maps to. // ... and all of it in one row, the one y = 0 maps to.
const plot::Transform ty = f.plot.y_transform(f.ay); 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)); const int row = static_cast<int>(std::floor((ty.to_screen_y(0.0) - axes.y) * sy));
plot::u32 lit = 0; plot::u32 lit = 0;
for (plot::u32 r = 0; r < grid.rows(); ++r) { for (plot::u32 r = 0; r < grid.rows(); ++r) {
for (plot::u32 c = 0; c < grid.columns(); ++c) { for (plot::u32 c = 0; c < grid.columns(); ++c) {
@@ -2891,13 +2912,53 @@ PLOT_TEST(a_density_grid_is_the_axes_in_device_pixels) {
PLOT_CHECK(lit == columns); PLOT_CHECK(lit == columns);
// The accumulation is a *screen* image: a plot that has moved to another axes rect gets a // 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. // 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.set_viewport({0.0f, 0.0f, 320.0f, 240.0f});
f.plot.layout(); f.plot.layout();
f.plot.accumulate_density(); f.plot.accumulate_density();
PLOT_CHECK(f.plot.density_grid(0u).columns() < columns); const plot::DensityGrid& small = f.plot.density_grid(0u);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), const plot::Rect small_axes = f.plot.layout().axes;
static_cast<double>(f.plot.density_grid(0u).columns()), 1.0); 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 /// Per acquisition the image is multiplied by the factor *before* the new acquisition is
@@ -3045,11 +3106,15 @@ PLOT_TEST(a_density_deposits_only_where_the_trace_is) {
PLOT_CHECK(grid.ink() >= static_cast<double>(grid.columns())); PLOT_CHECK(grid.ink() >= static_cast<double>(grid.columns()));
const plot::Transform ty = split.plot.y_transform(split.ay); const plot::Transform ty = split.plot.y_transform(split.ay);
const plot::Rect axes = split.plot.layout().axes; 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 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. // the bottom edge -- lands in the last row rather than one past the end.
const auto row_of = [&](plot::f64 y) { const auto row_of = [&](plot::f64 y) {
return static_cast<plot::u32>(std::clamp( return static_cast<plot::u32>(std::clamp(
static_cast<int>(std::floor(ty.to_screen_y(y) - axes.y)), 0, static_cast<int>(std::floor((ty.to_screen_y(y) - axes.y) * sy)), 0,
static_cast<int>(grid.rows()) - 1)); static_cast<int>(grid.rows()) - 1));
}; };
PLOT_CHECK(grid.at(0u, row_of(-1.0)) > 0.0f); PLOT_CHECK(grid.at(0u, row_of(-1.0)) > 0.0f);
@@ -3077,8 +3142,11 @@ PLOT_TEST(a_density_deposits_only_where_the_trace_is) {
const plot::DensityGrid& gap_grid = gap.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::Transform tx = gap.plot.x_transform(gap.ax);
const plot::Rect gap_axes = gap.plot.layout().axes; 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( const plot::u32 column = static_cast<plot::u32>(std::clamp(
static_cast<int>(std::floor(tx.to_screen_x(0.5) - gap_axes.x)), 0, static_cast<int>(std::floor((tx.to_screen_x(0.5) - gap_axes.x) * gsx)), 0,
static_cast<int>(gap_grid.columns()) - 1)); 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(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(column_ink(gap_grid, column) == 0.0); // the NaN is a gap: nothing joins across it
@@ -3123,8 +3191,10 @@ PLOT_TEST(a_density_holds_one_grid_per_trace) {
const plot::Transform ty = p.y_transform(ay); const plot::Transform ty = p.y_transform(ay);
const plot::Rect axes = p.layout().axes; 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) { const auto row_of = [&](plot::f64 y) {
return static_cast<plot::u32>(std::floor(ty.to_screen_y(y) - axes.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 lo_row = row_of(-0.5);
const plot::u32 hi_row = row_of(0.5); const plot::u32 hi_row = row_of(0.5);
+258 -11
View File
@@ -5059,6 +5059,52 @@ PLOT_TEST(figure_x_title_is_drawn_on_the_bottom_row_only) {
ctx.destroy(); 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 axes clip (spec §13)
// The reported defect this section is about: a trace is staged in *data* space and // The reported defect this section is about: a trace is staged in *data* space and
@@ -5704,17 +5750,22 @@ PLOT_TEST(horizontal_ruler_label_reads_its_value) {
const InkExtent ink = ink_extent(frame, kW, kH, ruler_pixel); const InkExtent ink = ink_extent(frame, kW, kH, ruler_pixel);
PLOT_CHECK(ink.pixels > 0u); PLOT_CHECK(ink.pixels > 0u);
// One horizontal run: shallow in rows, spanning essentially the axes' own width. // One horizontal run: the stroke itself is shallow, but the label's backplate is drawn in
PLOT_CHECK(ink.rows() <= 3u); // the ruler's own colour too (see the clustered cursor labels), and its border spans one
// text line plus its padding above and below the stroke -- measured 28 rows on the suite's
// own font. The bound is that panel with a stroke's worth of slack, not the old bare-text
// run of three.
PLOT_CHECK(ink.rows() <= 32u);
PLOT_CHECK(ink.cols() + 2u >= static_cast<plot::u32>(axes.w)); PLOT_CHECK(ink.cols() + 2u >= static_cast<plot::u32>(axes.w));
// And at the row the transform says -- a ruler drawn at the wrong y, or a vertical column // And at the row the transform says -- a ruler drawn at the wrong y, or a vertical column
// that happens to be inside the rect, cannot satisfy both of these. // that happens to be inside the rect, cannot satisfy both of these.
PLOT_CHECK(static_cast<plot::f32>(ink.top) <= expected_row + 1.0f); PLOT_CHECK(static_cast<plot::f32>(ink.top) <= expected_row + 1.0f);
PLOT_CHECK(static_cast<plot::f32>(ink.bottom) >= expected_row - 1.0f); PLOT_CHECK(static_cast<plot::f32>(ink.bottom) >= expected_row - 1.0f);
// The label: text ink just under the ruler at its left end, inside the axes. The tick // The label: text ink centred on the ruler at its left end, inside the axes. The tick
// labels are outside `axes`, in their own strips, so this can only be the ruler's value. // labels are outside `axes`, in their own strips, so this can only be the ruler's value.
const plot::Rect label{axes.x + 1.0f, expected_row, 60.0f, 16.0f}; // Centred, not below: the badge straddles the ruler's own row (see the clustered labels).
const plot::Rect label{axes.x + 1.0f, expected_row - 9.0f, 60.0f, 18.0f};
PLOT_CHECK(predicate_pixels(frame, kW, label, text_pixel) > 0u); PLOT_CHECK(predicate_pixels(frame, kW, label, text_pixel) > 0u);
// The clip: every ruler-coloured pixel of the frame is inside `axes`. // The clip: every ruler-coloured pixel of the frame is inside `axes`.
@@ -6050,15 +6101,65 @@ PLOT_TEST(crosshair_ink_stays_inside_the_axes) {
PLOT_CHECK(crosshair.rows[2].marker.x < axes.x); PLOT_CHECK(crosshair.rows[2].marker.x < axes.x);
PLOT_CHECK(crosshair.rows[2].marker.x >= layout.y_label_area.x); PLOT_CHECK(crosshair.rows[2].marker.x >= layout.y_label_area.x);
// Every crosshair-coloured pixel of the frame is inside `axes`, and there are some: not one in a // Every crosshair-coloured pixel of the frame is inside `axes` or inside one of the two
// label or title strip, not one over the host's surface outside the viewport. // axis badges, and there are some. The badges are the deliberate exception -- the
// crosshair's own axis readings, drawn in the strips the ticks are written in (see
// `Crosshair::axis_labels`) -- and the layout named their rects, so the exception is
// checked against exactly what was placed, not against a loose region.
PLOT_CHECK(crosshair.y_axis_badge.w > 0.0f);
PLOT_CHECK(crosshair.x_axis_badge.w > 0.0f);
PLOT_CHECK(crosshair.y_axis_badge.right() <= axes.x + 0.5f); // beside the y ticks
PLOT_CHECK(crosshair.x_axis_badge.y >= axes.bottom() - 0.5f); // beside the x ticks
const plot::u32 inside = predicate_pixels(frame, kW, axes, crosshair_pixel); const plot::u32 inside = predicate_pixels(frame, kW, axes, crosshair_pixel);
// The badge rects are the panels' own frames, and a hairline's anti-aliased fringe can sit
// half a pixel outside one -- the badge's own ink either way, so the counts read over the
// rects inflated by a pixel. The inflation is held out of the axes: a badge sits *against*
// an axis line, and a fringe column read into the axes' own pixels would be counted twice.
const auto inflate_badge = [&axes](const plot::Rect& rect, bool toward_axes) {
plot::Rect out{rect.x - 1.0f, rect.y - 1.0f, rect.w + 2.0f, rect.h + 2.0f};
if (toward_axes && out.right() > axes.x) out.w -= out.right() - axes.x;
if (!toward_axes && out.y < axes.bottom()) {
const f32 d = axes.bottom() - out.y;
out.y += d;
out.h -= d;
}
return out;
};
const plot::Rect y_badge_rect = inflate_badge(crosshair.y_axis_badge, true);
const plot::Rect x_badge_rect = inflate_badge(crosshair.x_axis_badge, false);
const plot::u32 y_badge = predicate_pixels(frame, kW, y_badge_rect, crosshair_pixel);
const plot::u32 x_badge = predicate_pixels(frame, kW, x_badge_rect, crosshair_pixel);
PLOT_CHECK(inside > 0u); PLOT_CHECK(inside > 0u);
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, crosshair_pixel) == inside); PLOT_CHECK(y_badge > 0u);
PLOT_CHECK(predicate_pixels(frame, kW, layout.y_label_area, crosshair_pixel) == 0u); PLOT_CHECK(x_badge > 0u);
PLOT_CHECK(predicate_pixels(frame, kW, layout.y_title_area, crosshair_pixel) == 0u); PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, crosshair_pixel) ==
PLOT_CHECK(predicate_pixels(frame, kW, layout.x_label_area, crosshair_pixel) == 0u); inside + y_badge + x_badge);
PLOT_CHECK(predicate_pixels(frame, kW, layout.x_title_area, crosshair_pixel) == 0u); // The badges may reach past the label strip into the title strip beside it -- a badge is as
// wide as its own text and is capped by nothing but the strips' union -- so the title
// strips' rule is: **every crosshair-coloured pixel is the badge's own**, i.e. within one
// badge rect's one-pixel fringe. A stray pixel there is a second drawing in a strip that
// belongs to the axis title.
const auto title_stray = [&](const plot::Rect& strip, const plot::Rect& badge) {
const plot::Rect frame_rect{badge.x - 1.0f, badge.y - 1.0f, badge.w + 2.0f, badge.h + 2.0f};
plot::u32 stray = 0;
for (plot::i32 y = static_cast<plot::i32>(strip.y);
y < static_cast<plot::i32>(strip.bottom()); ++y) {
for (plot::i32 x = static_cast<plot::i32>(strip.x);
x < static_cast<plot::i32>(strip.right()); ++x) {
const plot::f32 fx = static_cast<plot::f32>(x);
const plot::f32 fy = static_cast<plot::f32>(y);
const plot::u8* pixel =
&frame[(static_cast<plot::usize>(y) * kW + static_cast<plot::usize>(x)) * 4u];
if (!crosshair_pixel(pixel)) continue;
const bool own = fx >= frame_rect.x && fx < frame_rect.x + frame_rect.w &&
fy >= frame_rect.y && fy < frame_rect.y + frame_rect.h;
if (!own) ++stray;
}
}
return stray;
};
PLOT_CHECK(title_stray(layout.y_title_area, crosshair.y_axis_badge) == 0u);
PLOT_CHECK(title_stray(layout.x_title_area, crosshair.x_axis_badge) == 0u);
plot::u8 host[4] = {}; plot::u8 host[4] = {};
PLOT_CHECK(ctx.read_pixel(4u, kH - 4u, host)); PLOT_CHECK(ctx.read_pixel(4u, kH - 4u, host));
PLOT_CHECK(host[0] > 100 && host[0] < 160 && host[1] > 100 && host[2] > 100); PLOT_CHECK(host[0] > 100 && host[0] < 160 && host[1] > 100 && host[2] > 100);
@@ -6087,6 +6188,21 @@ PLOT_TEST(crosshair_ink_stays_inside_the_axes) {
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, magenta_pixel) == PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, magenta_pixel) ==
predicate_pixels(frame, kW, axes, magenta_pixel)); predicate_pixels(frame, kW, axes, magenta_pixel));
// The same plot with the badges off: the layout places none, and the strips go back to
// being clean of the crosshair's colour -- the flag is the whole difference between the
// two frames, and this leg is what makes it a setting rather than a behaviour.
p.set_crosshair_axis_labels(false);
p.layout();
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
const std::vector<plot::u8> off_frame = read_frame(ctx);
PLOT_CHECK(p.layout().crosshair.y_axis_badge.w == 0.0f);
PLOT_CHECK(p.layout().crosshair.x_axis_badge.w == 0.0f);
PLOT_CHECK(predicate_pixels(off_frame, kW, layout.y_label_area, crosshair_pixel) == 0u);
PLOT_CHECK(predicate_pixels(off_frame, kW, layout.x_label_area, crosshair_pixel) == 0u);
ctx.destroy(); ctx.destroy();
} }
@@ -6232,6 +6348,13 @@ PLOT_TEST(crosshair_dash_is_configurable_and_zero_off_means_solid) {
// 4 on / 4 off over the axes' width: many dashes, not one line. // 4 on / 4 off over the axes' width: many dashes, not one line.
const plot::u32 dashed = runs(4.0f); const plot::u32 dashed = runs(4.0f);
PLOT_CHECK(dashed > 4u); PLOT_CHECK(dashed > 4u);
// The y axis badge is **centred** on the line it names, the way the x badge is centred on
// its column -- its middle is the line's own screen row. A badge hung from the line (top
// edge on it) is half a badge off, which is exactly what this reads for.
const plot::Rect badge = p.layout().crosshair.y_axis_badge;
PLOT_CHECK(badge.w > 0.0f);
PLOT_CHECK_NEAR(badge.y + 0.5f * badge.h, cyp, 0.6f);
// Off == 0 is solid: one run, the whole width, no gap anywhere. // Off == 0 is solid: one run, the whole width, no gap anywhere.
PLOT_CHECK(runs(0.0f) == 1u); PLOT_CHECK(runs(0.0f) == 1u);
// A longer gap is a longer period, so fewer dashes over the same width. // A longer gap is a longer period, so fewer dashes over the same width.
@@ -7725,3 +7848,127 @@ PLOT_TEST(a_phosphor_density_is_the_trace_in_its_own_colour) {
bg_g, cell_g, faded_g, steady_g, quarter_g, before, after); bg_g, cell_g, faded_g, steady_g, quarter_g, before, after);
ctx.destroy(); 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();
}