Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d5b286611 | ||
|
|
2cb31cbb91 | ||
|
|
69f0083fc0 | ||
|
|
2b4b316ad8 | ||
|
|
25c3381551 | ||
|
|
73f610b136 | ||
|
|
e5b7faadec | ||
|
|
7d8c453232 |
No files matched your search
+27
-22
@@ -455,9 +455,11 @@ void Host::set_figure(Figure* figure) noexcept {
|
|||||||
row_rects_.clear(); // no figure, no rows: the handlers fall back to `controller_`
|
row_rects_.clear(); // no figure, no rows: the handlers fall back to `controller_`
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const u32 rows = figure_->row_count();
|
// One controller per **plot** (a row of several cells has one for each), indexed by the
|
||||||
row_controllers_.reserve(rows);
|
// flattened plot index the figure uses.
|
||||||
for (u32 i = 0; i < rows; ++i) {
|
const u32 plots = figure_->plot_count();
|
||||||
|
row_controllers_.reserve(plots);
|
||||||
|
for (u32 i = 0; i < plots; ++i) {
|
||||||
row_controllers_.push_back(std::make_unique<input::Controller>(figure_->plot(i)));
|
row_controllers_.push_back(std::make_unique<input::Controller>(figure_->plot(i)));
|
||||||
}
|
}
|
||||||
// The new figure's rects go into the cache now, not at the next frame: an event
|
// The new figure's rects go into the cache now, not at the next frame: an event
|
||||||
@@ -469,8 +471,10 @@ void Host::set_figure(Figure* figure) noexcept {
|
|||||||
|
|
||||||
void Host::refresh_row_rects() noexcept {
|
void Host::refresh_row_rects() noexcept {
|
||||||
// The one `Figure::layout()` per call: every event handler reads `row_rects_`
|
// The one `Figure::layout()` per call: every event handler reads `row_rects_`
|
||||||
// instead of paying this again, which is the whole point of the cache (R7).
|
// instead of paying this again, which is the whole point of the cache (R7). The rects
|
||||||
row_rects_ = figure_ != nullptr ? figure_->layout().rows : std::vector<Rect>{};
|
// are the figure's **cells** (one per plot, flattened), which for a figure with one
|
||||||
|
// cell per row are exactly its row rects.
|
||||||
|
row_rects_ = figure_ != nullptr ? figure_->layout().cells : std::vector<Rect>{};
|
||||||
}
|
}
|
||||||
|
|
||||||
input::Controller* Host::row_controller(u32 row) noexcept {
|
input::Controller* Host::row_controller(u32 row) noexcept {
|
||||||
@@ -486,8 +490,7 @@ input::Controller& Host::controller_at(f32 x, f32 y) noexcept {
|
|||||||
// frame stale, which is the documented worst case.
|
// frame stale, which is the documented worst case.
|
||||||
const std::vector<Rect>& rows = row_rects_;
|
const std::vector<Rect>& rows = row_rects_;
|
||||||
if (rows.empty() || rows.size() != row_controllers_.size()) return controller_;
|
if (rows.empty() || rows.size() != row_controllers_.size()) return controller_;
|
||||||
(void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in()
|
const u32 row = row_in(rows, x, y);
|
||||||
const u32 row = row_in(rows, y);
|
|
||||||
last_row_ = row;
|
last_row_ = row;
|
||||||
input::Controller& controller = *row_controllers_[row];
|
input::Controller& controller = *row_controllers_[row];
|
||||||
// The row's own viewport, so the geometry this controller hit-tests is the geometry
|
// The row's own viewport, so the geometry this controller hit-tests is the geometry
|
||||||
@@ -506,7 +509,6 @@ void Host::feed_pointer_event(const input::PointerEvent& e) noexcept {
|
|||||||
|
|
||||||
input::Controller& Host::pointer_target(f32 x, f32 y, input::Button button, bool pressed,
|
input::Controller& Host::pointer_target(f32 x, f32 y, input::Button button, bool pressed,
|
||||||
bool released) noexcept {
|
bool released) noexcept {
|
||||||
(void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in()
|
|
||||||
if (figure_ == nullptr || row_controllers_.empty()) return controller_;
|
if (figure_ == nullptr || row_controllers_.empty()) return controller_;
|
||||||
const std::vector<Rect>& rows = row_rects_;
|
const std::vector<Rect>& rows = row_rects_;
|
||||||
if (rows.empty() || rows.size() != row_controllers_.size()) return controller_;
|
if (rows.empty() || rows.size() != row_controllers_.size()) return controller_;
|
||||||
@@ -523,7 +525,7 @@ input::Controller& Host::pointer_target(f32 x, f32 y, input::Button button, bool
|
|||||||
// drag, and its press must not steal the capture to the row it landed in.
|
// drag, and its press must not steal the capture to the row it landed in.
|
||||||
if (released && button == captured_button_) captured_ = false;
|
if (released && button == captured_button_) captured_ = false;
|
||||||
} else {
|
} else {
|
||||||
row = row_in(rows, y);
|
row = row_in(rows, x, y);
|
||||||
if (pressed) {
|
if (pressed) {
|
||||||
captured_ = true;
|
captured_ = true;
|
||||||
captured_row_ = row;
|
captured_row_ = row;
|
||||||
@@ -541,27 +543,30 @@ input::Controller& Host::pointer_target(f32 x, f32 y, input::Button button, bool
|
|||||||
|
|
||||||
u32 Host::pointer_row(const std::vector<Rect>& rows) noexcept {
|
u32 Host::pointer_row(const std::vector<Rect>& rows) noexcept {
|
||||||
if (captured_ && captured_row_ < rows.size()) return captured_row_;
|
if (captured_ && captured_row_ < rows.size()) return captured_row_;
|
||||||
return row_in(rows, last_pointer_y_);
|
return row_in(rows, last_pointer_x_, last_pointer_y_);
|
||||||
}
|
}
|
||||||
|
|
||||||
u32 Host::row_in(const std::vector<Rect>& rows, f32 y) noexcept {
|
u32 Host::row_in(const std::vector<Rect>& rows, f32 x, f32 y) noexcept {
|
||||||
if (rows.empty()) return 0u;
|
if (rows.empty()) return 0u;
|
||||||
// The row the pixel is in, or -- when it is in none of them, which is the row gap a
|
// The cell the pixel is in, or -- when it is in none of them, which is the row gap or
|
||||||
// host has asked for with `Figure::set_row_gap()` or the figure's own margins -- the
|
// column gap a host has asked for with `Figure::set_row_gap()`/`set_col_gap()` or the
|
||||||
// row whose rect is nearest. Selection by distance rather than by "the first that
|
// figure's own margins -- the cell whose rect is nearest (squared distance from the
|
||||||
// matches" is what makes a pixel in a gap belong to the row it is *near*: the rows
|
// pixel to the rect). Selection by distance rather than by "the first that matches" is
|
||||||
// tile the figure at the default gap of 0, so this is unreachable until a host asks
|
// what makes a pixel in a gap belong to the cell it is *near*, and a gesture in a
|
||||||
// for one, and a gesture in a visible gap must not be routed to row 0 by default.
|
// visible gap must not be routed to plot 0 by default. For a figure with one cell per
|
||||||
|
// row every cell spans the figure's width, so this reduces to the nearest row by y.
|
||||||
u32 row = 0u;
|
u32 row = 0u;
|
||||||
f32 best = std::numeric_limits<f32>::infinity();
|
double best = std::numeric_limits<double>::infinity();
|
||||||
for (u32 i = 0; i < rows.size(); ++i) {
|
for (u32 i = 0; i < rows.size(); ++i) {
|
||||||
const f32 top = rows[i].y;
|
const Rect& r = rows[i];
|
||||||
const f32 bottom = rows[i].y + rows[i].h;
|
// In double: in f32 the dy^2 term can be swallowed by a large dx^2 (pointer far outside).
|
||||||
const f32 distance = y < top ? top - y : (y >= bottom ? y - bottom : 0.0f);
|
const double dx = x < r.x ? double(r.x) - x : (x >= r.right() ? double(x) - r.right() : 0.0);
|
||||||
|
const double dy = y < r.y ? double(r.y) - y : (y >= r.bottom() ? double(y) - r.bottom() : 0.0);
|
||||||
|
const double distance = dx * dx + dy * dy;
|
||||||
if (distance < best) {
|
if (distance < best) {
|
||||||
best = distance;
|
best = distance;
|
||||||
row = i;
|
row = i;
|
||||||
if (distance == 0.0f) break; // inside this row: nothing can be nearer
|
if (distance == 0.0) break; // inside this cell: nothing can be nearer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return row;
|
return row;
|
||||||
|
|||||||
+19
-15
@@ -121,18 +121,21 @@ class Host {
|
|||||||
/// `plot_` is then untouched by any of this.
|
/// `plot_` is then untouched by any of this.
|
||||||
void set_figure(Figure* figure) noexcept;
|
void set_figure(Figure* figure) noexcept;
|
||||||
Figure* figure() noexcept { return figure_; }
|
Figure* figure() noexcept { return figure_; }
|
||||||
/// One figure row's controller -- the one a gesture on that row reaches -- or
|
/// One figure plot's controller -- the one a gesture on that plot reaches -- or null
|
||||||
/// null while no figure is set or the row does not exist. A host reads a drag
|
/// while no figure is set or it does not exist. `row` is the **flattened plot index**
|
||||||
|
/// (`Figure::plot_count()` of them: the row count when no row holds several cells). A host reads a drag
|
||||||
/// mode or a hover out of it (`controller()` is the single-plot loop's, and is
|
/// mode or a hover out of it (`controller()` is the single-plot loop's, and is
|
||||||
/// not the one figure gestures go to).
|
/// not the one figure gestures go to).
|
||||||
input::Controller* row_controller(u32 row) noexcept;
|
input::Controller* row_controller(u32 row) noexcept;
|
||||||
/// The controller an event at framebuffer pixel (x, y) belongs to: `controller_` --
|
/// The controller an event at framebuffer pixel (x, y) belongs to: `controller_` --
|
||||||
/// the single-plot loop -- while no figure is set, and the controller of the figure
|
/// the single-plot loop -- while no figure is set, and the controller of the figure
|
||||||
/// row that pixel lands in otherwise. The row rects come from the cache
|
/// plot (cell) that pixel lands in otherwise -- x and y both pick it, as a row may hold
|
||||||
|
/// several cells side by side. The cell rects come from the cache
|
||||||
/// `refresh_row_rects()` fills -- the same ones the renderer draws -- so a gesture
|
/// `refresh_row_rects()` fills -- the same ones the renderer draws -- so a gesture
|
||||||
/// lands on the row it was aimed at and no event lays the figure out; a pixel
|
/// lands on the row it was aimed at and no event lays the figure out; a pixel
|
||||||
/// inside no row -- the row gap a host asked for with `Figure::set_row_gap()`,
|
/// inside no cell -- the row or column gap a host asked for with
|
||||||
/// or a margin -- is the *nearest* row rather than dropped or defaulted, so a drag
|
/// `Figure::set_row_gap()`/`set_col_gap()`, or a margin -- is the *nearest* cell rather than
|
||||||
|
/// dropped or defaulted, so a drag
|
||||||
/// that leaves the data area still ends where it should.
|
/// that leaves the data area still ends where it should.
|
||||||
///
|
///
|
||||||
/// It also hands that row its own viewport, which is what keeps the controller's
|
/// It also hands that row its own viewport, which is what keeps the controller's
|
||||||
@@ -217,11 +220,12 @@ class Host {
|
|||||||
void on_scroll(f64 dx, f64 dy) noexcept;
|
void on_scroll(f64 dx, f64 dy) noexcept;
|
||||||
void on_key(int key, int action, int mods) noexcept;
|
void on_key(int key, int action, int mods) noexcept;
|
||||||
|
|
||||||
/// The index of the row of `rows` a framebuffer y belongs to: the row it is in, or the nearest
|
/// The index of the cell of `rows` (the figure's flattened cell rects) a framebuffer pixel
|
||||||
/// row when it is in none (a row gap, a margin outside the figure). One rule, shared by the event
|
/// belongs to: the cell it is in, or the nearest cell when it is in none (a row or column gap,
|
||||||
/// routing and the crosshair sync -- and taking the rects rather than reading them, so a caller
|
/// a margin outside the figure). One rule, shared by the event routing and the crosshair sync
|
||||||
/// that has just laid the figure out does not make it lay out again for this.
|
/// -- and taking the rects rather than reading them, so a caller that has just laid the figure
|
||||||
static u32 row_in(const std::vector<Rect>& rows, f32 y) noexcept;
|
/// out does not make it lay out again for this.
|
||||||
|
static u32 row_in(const std::vector<Rect>& rows, f32 x, f32 y) noexcept;
|
||||||
|
|
||||||
/// The controller a pointer event at (x, y) belongs to, applying the button capture:
|
/// The controller a pointer event at (x, y) belongs to, applying the button capture:
|
||||||
/// a press claims the row it landed in (`captured_row_` + `captured_button_`) and
|
/// a press claims the row it landed in (`captured_row_` + `captured_button_`) and
|
||||||
@@ -231,10 +235,10 @@ class Host {
|
|||||||
input::Controller& pointer_target(f32 x, f32 y, input::Button button, bool pressed,
|
input::Controller& pointer_target(f32 x, f32 y, input::Button button, bool pressed,
|
||||||
bool released) noexcept;
|
bool released) noexcept;
|
||||||
|
|
||||||
/// The row the pointer's events currently go to: the captured row while a drag owns
|
/// The plot (flattened index) the pointer's events currently go to: the captured one
|
||||||
/// the button, the row under the pointer otherwise. The run loop uses it to clear
|
/// while a drag owns the button, the one under the pointer otherwise. The run loop uses
|
||||||
/// every *other* row's hot handles, so a row the pointer left cannot keep drawing a
|
/// it to clear every *other* plot's hot handles, so one the pointer left cannot keep
|
||||||
/// hover or a grab it last published.
|
/// drawing a hover or a grab it last published.
|
||||||
u32 pointer_row(const std::vector<Rect>& rows) noexcept;
|
u32 pointer_row(const std::vector<Rect>& rows) noexcept;
|
||||||
|
|
||||||
/// Spreads the hover over a whole `Figure` (spec §15).
|
/// Spreads the hover over a whole `Figure` (spec §15).
|
||||||
@@ -292,7 +296,7 @@ class Host {
|
|||||||
u32 captured_row_ = 0u;
|
u32 captured_row_ = 0u;
|
||||||
input::Button captured_button_ = input::Button::Left;
|
input::Button captured_button_ = input::Button::Left;
|
||||||
|
|
||||||
/// The figure's row rects as of the last `refresh_row_rects()` -- the cache the
|
/// The figure's cell rects (one per plot, flattened; the row rects when no row is split) as of the last `refresh_row_rects()` -- the cache the
|
||||||
/// event routing reads instead of laying the figure out per event (R7). Filled by
|
/// event routing reads instead of laying the figure out per event (R7). Filled by
|
||||||
/// `set_figure()` and once per frame by the run loop; empty while no figure is
|
/// `set_figure()` and once per frame by the run loop; empty while no figure is
|
||||||
/// set, which is the state `controller_at()`/`pointer_target()` fall back to the
|
/// set, which is the state `controller_at()`/`pointer_target()` fall back to the
|
||||||
|
|||||||
@@ -245,9 +245,11 @@ void PlotArea::set_figure(Figure* figure) noexcept {
|
|||||||
row_rects_.clear(); // no figure, no rows: the routing falls back to `controller_`
|
row_rects_.clear(); // no figure, no rows: the routing falls back to `controller_`
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const u32 rows = figure_->row_count();
|
// One controller per **plot** (a row of several cells has one for each), indexed by the
|
||||||
row_controllers_.reserve(rows);
|
// flattened plot index the figure uses.
|
||||||
for (u32 i = 0; i < rows; ++i) {
|
const u32 plots = figure_->plot_count();
|
||||||
|
row_controllers_.reserve(plots);
|
||||||
|
for (u32 i = 0; i < plots; ++i) {
|
||||||
row_controllers_.push_back(std::make_unique<input::Controller>(figure_->plot(i)));
|
row_controllers_.push_back(std::make_unique<input::Controller>(figure_->plot(i)));
|
||||||
}
|
}
|
||||||
// The new figure's geometry goes into the cache now, not at the next frame: an event
|
// The new figure's geometry goes into the cache now, not at the next frame: an event
|
||||||
@@ -269,8 +271,10 @@ void PlotArea::lay_figure_out(Rect px) noexcept {
|
|||||||
|
|
||||||
void PlotArea::refresh_row_rects() noexcept {
|
void PlotArea::refresh_row_rects() noexcept {
|
||||||
// The one `Figure::layout()` per call: every event handler reads `row_rects_`
|
// The one `Figure::layout()` per call: every event handler reads `row_rects_`
|
||||||
// instead of paying this again, which is the whole point of the cache.
|
// instead of paying this again, which is the whole point of the cache. The rects are
|
||||||
row_rects_ = figure_ != nullptr ? figure_->layout().rows : std::vector<Rect>{};
|
// the figure's **cells** (one per plot, flattened), which for a figure with one cell
|
||||||
|
// per row are exactly its row rects.
|
||||||
|
row_rects_ = figure_ != nullptr ? figure_->layout().cells : std::vector<Rect>{};
|
||||||
}
|
}
|
||||||
|
|
||||||
input::Controller* PlotArea::row_controller(u32 row) noexcept {
|
input::Controller* PlotArea::row_controller(u32 row) noexcept {
|
||||||
@@ -283,8 +287,7 @@ input::Controller& PlotArea::controller_at(f32 x, f32 y) noexcept {
|
|||||||
// -- and not a fresh `Figure::layout()`: an event pays no layout, or a burst of
|
// -- and not a fresh `Figure::layout()`: an event pays no layout, or a burst of
|
||||||
// pointer motion over a multi-row figure would re-lay every row out per move.
|
// pointer motion over a multi-row figure would re-lay every row out per move.
|
||||||
if (row_rects_.empty() || row_rects_.size() != row_controllers_.size()) return controller_;
|
if (row_rects_.empty() || row_rects_.size() != row_controllers_.size()) return controller_;
|
||||||
(void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in()
|
const u32 row = row_in(row_rects_, x, y);
|
||||||
const u32 row = row_in(row_rects_, y);
|
|
||||||
input::Controller& controller = *row_controllers_[row];
|
input::Controller& controller = *row_controllers_[row];
|
||||||
controller.set_viewport(row_rects_[row]);
|
controller.set_viewport(row_rects_[row]);
|
||||||
return controller;
|
return controller;
|
||||||
@@ -292,11 +295,11 @@ input::Controller& PlotArea::controller_at(f32 x, f32 y) noexcept {
|
|||||||
|
|
||||||
u32 PlotArea::row_at(double x, double y) const noexcept {
|
u32 PlotArea::row_at(double x, double y) const noexcept {
|
||||||
if (figure_ == nullptr || row_rects_.empty()) return 0;
|
if (figure_ == nullptr || row_rects_.empty()) return 0;
|
||||||
(void)x;
|
const Vec2 p = to_device(x, y);
|
||||||
return row_in(row_rects_, to_device(x, y).y);
|
return row_in(row_rects_, p.x, p.y);
|
||||||
}
|
}
|
||||||
|
|
||||||
input::Controller& PlotArea::target_controller(f32 y) noexcept {
|
input::Controller& PlotArea::target_controller(f32 x, f32 y) noexcept {
|
||||||
if (figure_ == nullptr || row_controllers_.empty()) return controller_;
|
if (figure_ == nullptr || row_controllers_.empty()) return controller_;
|
||||||
if (row_rects_.empty() || row_rects_.size() != row_controllers_.size()) return controller_;
|
if (row_rects_.empty() || row_rects_.size() != row_controllers_.size()) return controller_;
|
||||||
// The capture: while a press is open the gesture belongs to the row that press
|
// The capture: while a press is open the gesture belongs to the row that press
|
||||||
@@ -304,7 +307,7 @@ input::Controller& PlotArea::target_controller(f32 y) noexcept {
|
|||||||
// the release to the row the pointer is over *now* would leave the first row's drag
|
// the release to the row the pointer is over *now* would leave the first row's drag
|
||||||
// open for ever -- its controller never sees the release that ends it -- and let a
|
// open for ever -- its controller never sees the release that ends it -- and let a
|
||||||
// later gesture resume it from the stale anchor.
|
// later gesture resume it from the stale anchor.
|
||||||
u32 row = press_open_ ? press_row_ : row_in(row_rects_, y);
|
u32 row = press_open_ ? press_row_ : row_in(row_rects_, x, y);
|
||||||
// Re-read against this frame's rects: the widget can be resized mid-drag, and the
|
// Re-read against this frame's rects: the widget can be resized mid-drag, and the
|
||||||
// guard keeps a stale index from reaching past the controller list.
|
// guard keeps a stale index from reaching past the controller list.
|
||||||
if (row >= row_controllers_.size()) row = 0;
|
if (row >= row_controllers_.size()) row = 0;
|
||||||
@@ -314,23 +317,26 @@ input::Controller& PlotArea::target_controller(f32 y) noexcept {
|
|||||||
return controller;
|
return controller;
|
||||||
}
|
}
|
||||||
|
|
||||||
u32 PlotArea::row_in(const std::vector<Rect>& rows, f32 y) noexcept {
|
u32 PlotArea::row_in(const std::vector<Rect>& rows, f32 x, f32 y) noexcept {
|
||||||
if (rows.empty()) return 0;
|
if (rows.empty()) return 0;
|
||||||
// The row the pixel is in, or -- when it is in none of them, which is the row gap a
|
// The cell the pixel is in, or -- when it is in none of them, which is the row or
|
||||||
// host asked for with `Figure::set_row_gap()` or the figure's own margins -- the row
|
// column gap a host asked for with `Figure::set_row_gap()`/`set_col_gap()` or the
|
||||||
// whose rect is nearest. By distance rather than by "the first that matches": the
|
// figure's own margins -- the cell whose rect is nearest (squared distance from the
|
||||||
// rows tile the figure at the default gap of 0, so this is unreachable until a host
|
// pixel to the rect). By distance rather than by "the first that matches": a gesture
|
||||||
// asks for a gap, and a gesture in a visible gap must not default to row 0.
|
// in a visible gap must not default to plot 0. For a figure with one cell per row
|
||||||
|
// every cell spans the figure's width, so this reduces to the nearest row by y.
|
||||||
u32 row = 0;
|
u32 row = 0;
|
||||||
f32 best = std::numeric_limits<f32>::infinity();
|
double best = std::numeric_limits<double>::infinity();
|
||||||
for (u32 i = 0; i < rows.size(); ++i) {
|
for (u32 i = 0; i < rows.size(); ++i) {
|
||||||
const f32 top = rows[i].y;
|
const Rect& r = rows[i];
|
||||||
const f32 bottom = rows[i].y + rows[i].h;
|
// In double: in f32 the dy^2 term can be swallowed by a large dx^2 (pointer far outside).
|
||||||
const f32 distance = y < top ? top - y : (y >= bottom ? y - bottom : 0.0f);
|
const double dx = x < r.x ? double(r.x) - x : (x >= r.right() ? double(x) - r.right() : 0.0);
|
||||||
|
const double dy = y < r.y ? double(r.y) - y : (y >= r.bottom() ? double(y) - r.bottom() : 0.0);
|
||||||
|
const double distance = dx * dx + dy * dy;
|
||||||
if (distance < best) {
|
if (distance < best) {
|
||||||
best = distance;
|
best = distance;
|
||||||
row = i;
|
row = i;
|
||||||
if (distance == 0.0f) break; // inside this row: nothing can be nearer
|
if (distance == 0.0) break; // inside this cell: nothing can be nearer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return row;
|
return row;
|
||||||
@@ -341,7 +347,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 -------------------------------------------------------------
|
||||||
|
|||||||
@@ -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_;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+129
-30
@@ -27,13 +27,21 @@ struct FigureLayout {
|
|||||||
/// strips and axes come from its own `Plot::layout()` against this rect, so a row
|
/// strips and axes come from its own `Plot::layout()` against this rect, so a row
|
||||||
/// with a y title keeps its column and no two rows can disagree about chrome.
|
/// with a y title keeps its column and no two rows can disagree about chrome.
|
||||||
std::vector<Rect> rows;
|
std::vector<Rect> rows;
|
||||||
/// The strip the figure's one shared x axis is drawn in: the bottom row's own
|
/// One rect per plot, flattened row-major (row 0's cells left to right, then row 1's,
|
||||||
/// `PlotLayout::x_label_area`, at the bottom of the bottom row's rect and so as
|
/// ...): the **viewport** each `Plot` was given. A row of one cell has exactly its row
|
||||||
/// wide as that row's axes rather than the whole figure. Read back from the row
|
/// rect here; a row of several tiles its rect left to right with `col_gap` between
|
||||||
|
/// cells, the cells' widths summing to the row's less the gaps.
|
||||||
|
std::vector<Rect> cells;
|
||||||
|
/// The strip the figure's bottom x axis is drawn in: the bottom row's own
|
||||||
|
/// `PlotLayout::x_label_area` (for a bottom row split into several cells, only the **last
|
||||||
|
/// cell's** strip -- the others' strips are their own `plot(i).layout().x_label_area`, which
|
||||||
|
/// is where the controller's axis-zoom hit test reads each cell's gestures from), at the
|
||||||
|
/// bottom of the bottom row's rect and so as wide as that row's axes rather than the whole
|
||||||
|
/// figure. Read back from the row
|
||||||
/// rather than derived here, so a host and the frame cannot disagree about where
|
/// rather than derived here, so a host and the frame cannot disagree about where
|
||||||
/// the axis is. It is nonzero whenever the figure has rows and the bottom row
|
/// the axis is. It is nonzero whenever the figure has rows and the bottom row
|
||||||
/// reserves a label strip -- including the case where every row labels itself, in
|
/// reserves a label strip. A row above whose columns differ from the row below keeps
|
||||||
/// which case it is simply the bottom row's own strip, one of several.
|
/// a strip of its own (see `Figure`), which is its plots' `x_label_area`, not this.
|
||||||
///
|
///
|
||||||
/// It is the rect the controller's axis-zoom hit test reads (`x_label_area` is what
|
/// It is the rect the controller's axis-zoom hit test reads (`x_label_area` is what
|
||||||
/// a middle drag in a label strip enters `DragMode::AxisZoomX` from), which is why
|
/// a middle drag in a label strip enters `DragMode::AxisZoomX` from), which is why
|
||||||
@@ -44,6 +52,10 @@ struct FigureLayout {
|
|||||||
/// figure edge to edge, which is the default; it is dropped for a figure too
|
/// figure edge to edge, which is the default; it is dropped for a figure too
|
||||||
/// short to hold it, so the rows never lose their height to a gap.
|
/// short to hold it, so the rows never lose their height to a gap.
|
||||||
f32 row_gap = 0.0f;
|
f32 row_gap = 0.0f;
|
||||||
|
/// The horizontal gap between two cells of a row, in device pixels. `set_col_gap()`'s
|
||||||
|
/// value, or 0 when any row holding several cells was too narrow to hold it and
|
||||||
|
/// dropped it, so the cells never lose their width to a gap.
|
||||||
|
f32 col_gap = 0.0f;
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Several plots stacked in one column, sharing one x axis.
|
/// Several plots stacked in one column, sharing one x axis.
|
||||||
@@ -59,12 +71,15 @@ struct FigureLayout {
|
|||||||
/// **The shared x axis.** Row heights are an equal division of the figure by
|
/// **The shared x axis.** Row heights are an equal division of the figure by
|
||||||
/// default, `set_row_weights()` gives a row more of it, and every row keeps a floor
|
/// default, `set_row_weights()` gives a row more of it, and every row keeps a floor
|
||||||
/// height so chrome can never erase a plot. When `set_x_labels_only_on_bottom()` is
|
/// height so chrome can never erase a plot. When `set_x_labels_only_on_bottom()` is
|
||||||
/// on -- the default -- the figure's one x axis is the **bottom row's own**: the rows
|
/// on -- the default -- the x axis is the **bottom row's own** for every run of rows
|
||||||
/// above reserve no x chrome at all, and the bottom row keeps its own label and title
|
/// with the *same columns* (the same number of cells and the same normalised cell
|
||||||
/// strips, so those labels and that title are drawn once, from the bottom row's own
|
/// weights): those rows above reserve no x chrome at all, and the bottom row of the run
|
||||||
/// ticks and transform. That places the shared axis *inside* a row, where the
|
/// keeps its own label and title strips, so those labels and that title are drawn once,
|
||||||
/// controller's axis-zoom hit test can reach it, instead of in a band outside every
|
/// from its own ticks and transform. A row whose columns differ from the row below keeps
|
||||||
/// row that no gesture could land in.
|
/// its own strip, drawn under each of its cells, because the axis below is at another
|
||||||
|
/// scale and width. The rule is structural, never a function of what a plot holds. That
|
||||||
|
/// places a shared axis *inside* a row, where the controller's axis-zoom hit test can
|
||||||
|
/// reach it, instead of in a band outside every row that no gesture could land in.
|
||||||
///
|
///
|
||||||
/// **The x link.** `plot(i).set_x_range()` and a controller gesture both reach a
|
/// **The x link.** `plot(i).set_x_range()` and a controller gesture both reach a
|
||||||
/// member's x range the same way, so a figure cannot see either of them happen.
|
/// member's x range the same way, so a figure cannot see either of them happen.
|
||||||
@@ -94,13 +109,30 @@ class Figure {
|
|||||||
/// which is the *unlinked* group: rows that share nothing, each with the range
|
/// which is the *unlinked* group: rows that share nothing, each with the range
|
||||||
/// its own plot was given.
|
/// its own plot was given.
|
||||||
explicit Figure(u32 rows);
|
explicit Figure(u32 rows);
|
||||||
|
/// A figure of `cells_per_row.size()` rows, row `r` holding `cells_per_row[r]` plots side
|
||||||
|
/// by side (a count of 0 is clamped to 1). Every row of one cell makes this the same
|
||||||
|
/// figure as `Figure(rows)`. There is deliberately no `initializer_list` constructor, so
|
||||||
|
/// `Figure{3u}` keeps meaning three rows.
|
||||||
|
explicit Figure(std::span<const u32> cells_per_row);
|
||||||
|
|
||||||
u32 row_count() const noexcept { return static_cast<u32>(plots_.size()); }
|
/// The number of figure rows -- the bands stacked top to bottom -- which equals
|
||||||
/// One row's plot. A row that does not exist yields a default-constructed `Plot`
|
/// `plot_count()` for a figure no row of which is split.
|
||||||
/// rather than reading out of bounds -- the rule every accessor on `Plot` follows
|
u32 row_count() const noexcept { return static_cast<u32>(cells_per_row_.size()); }
|
||||||
/// -- so a stale row can only ever affect itself.
|
/// The number of plots, i.e. cells over all rows. `plot()`, `link_x()`, `x_link()` and
|
||||||
Plot& plot(u32 row) noexcept;
|
/// the hovered plot of `set_crosshair()` index this flattened (row-major) range.
|
||||||
const Plot& plot(u32 row) const noexcept;
|
u32 plot_count() const noexcept { return static_cast<u32>(plots_.size()); }
|
||||||
|
/// How many cells row `row` holds; 0 for a row that does not exist.
|
||||||
|
u32 cells_in_row(u32 row) const noexcept;
|
||||||
|
/// The flattened index of row `row`'s first cell; `plot_count()` for `row ==
|
||||||
|
/// row_count()` and for anything beyond.
|
||||||
|
u32 first_plot_of_row(u32 row) const noexcept;
|
||||||
|
/// The row that holds flattened plot `plot` (the last row for an index past the end).
|
||||||
|
u32 row_of_plot(u32 plot) const noexcept;
|
||||||
|
/// One plot, by flattened cell index. A plot that does not exist yields a
|
||||||
|
/// default-constructed `Plot` rather than reading out of bounds -- the rule every
|
||||||
|
/// accessor on `Plot` follows -- so a stale index can only ever affect itself.
|
||||||
|
Plot& plot(u32 index) noexcept;
|
||||||
|
const Plot& plot(u32 index) const noexcept;
|
||||||
|
|
||||||
// --- geometry ---------------------------------------------------------
|
// --- geometry ---------------------------------------------------------
|
||||||
|
|
||||||
@@ -119,6 +151,21 @@ class Figure {
|
|||||||
void clear_row_weights() noexcept { row_weights_.clear(); }
|
void clear_row_weights() noexcept { row_weights_.clear(); }
|
||||||
bool has_row_weights() const noexcept { return !row_weights_.empty(); }
|
bool has_row_weights() const noexcept { return !row_weights_.empty(); }
|
||||||
|
|
||||||
|
/// How much of row `row`'s width each of its cells gets: `weights[i]` is cell i's
|
||||||
|
/// share of what is left after every cell has its floor width (64 logical pixels,
|
||||||
|
/// capped at an equal share of a narrow row). Normalised. Refused, changing nothing,
|
||||||
|
/// when the row does not exist, the count is not the row's cell count, or a weight is
|
||||||
|
/// not a positive finite number. A row with no weights divides equally.
|
||||||
|
Status set_cell_weights(u32 row, std::span<const f32> weights);
|
||||||
|
/// Back to an equal division of row `row`; a row that does not exist is ignored.
|
||||||
|
void clear_cell_weights(u32 row) noexcept;
|
||||||
|
bool has_cell_weights(u32 row) const noexcept;
|
||||||
|
/// The horizontal gap left between two cells of a row, in device pixels (0.0 by
|
||||||
|
/// default). Negative values are clamped to 0, and a gap too wide for a row to hold is
|
||||||
|
/// dropped for that layout rather than squeezing the cells to nothing.
|
||||||
|
void set_col_gap(f32 device_px) noexcept { col_gap_ = device_px > 0.0f ? device_px : 0.0f; }
|
||||||
|
f32 col_gap() const noexcept { return col_gap_; }
|
||||||
|
|
||||||
/// The gap left between two rows, in device pixels (0.0 by default, so the rows
|
/// The gap left between two rows, in device pixels (0.0 by default, so the rows
|
||||||
/// tile the figure). Negative values are clamped to 0, and a gap too large for the
|
/// tile the figure). Negative values are clamped to 0, and a gap too large for the
|
||||||
/// figure to hold is dropped for that layout rather than squeezing the rows to
|
/// figure to hold is dropped for that layout rather than squeezing the rows to
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -861,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)
|
||||||
@@ -1060,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;
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -620,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
|
||||||
@@ -655,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
|
||||||
@@ -686,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
|
||||||
|
|||||||
+338
-38
@@ -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() {
|
||||||
|
|||||||
+91
-6
@@ -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;
|
||||||
@@ -819,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;
|
||||||
}
|
}
|
||||||
@@ -1001,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1298,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
|
||||||
@@ -1318,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;
|
||||||
@@ -1339,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);
|
||||||
@@ -1372,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 =
|
||||||
@@ -1868,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
|
||||||
@@ -1898,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
|
||||||
|
|||||||
+190
-34
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2017,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
|
||||||
@@ -2047,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
|
||||||
@@ -2116,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
|
||||||
@@ -2220,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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
+134
-11
@@ -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.
|
||||||
|
|||||||
Reference in new issue
Block a user