figure: x axes follow the column structure; split rows keep the right gutter on the last cell only

This commit is contained in:
Martino Ferrari committed 2026-11-04 07:00:52 +01:00
1 parent 69f0083fc0
commit 2cb31cbb91
6 files changed
+366 -53

No files matched your search

+21 -14
View File
@@ -32,15 +32,16 @@ struct FigureLayout {
/// 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 one shared x axis is drawn in: the bottom row's own
/// 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
/// is where the controller's axis-zoom hit test reads each cell's gestures from), at the
/// bottom of the bottom row's rect and so as wide as that row's axes rather than the whole
/// figure. Read back from the row
/// rather than derived here, so a host and the frame cannot disagree about where
/// the axis is. It is nonzero whenever the figure has rows and the bottom row
/// reserves a label strip -- including the case where every row labels itself, in
/// which case it is simply the bottom row's own strip, one of several.
/// reserves a label strip. A row above whose columns differ from the row below keeps
/// a strip of its own (see `Figure`), which is its plots' `x_label_area`, not this.
///
/// It is the rect the controller's axis-zoom hit test reads (`x_label_area` is what
/// a middle drag in a label strip enters `DragMode::AxisZoomX` from), which is why
@@ -70,12 +71,15 @@ struct FigureLayout {
/// **The shared x axis.** Row heights are an equal division of the figure by
/// default, `set_row_weights()` gives a row more of it, and every row keeps a floor
/// height so chrome can never erase a plot. When `set_x_labels_only_on_bottom()` is
/// on -- the default -- the figure's one x axis is the **bottom row's own**: the rows
/// above reserve no x chrome at all, and the bottom row keeps its own label and title
/// strips, so those labels and that title are drawn once, from the bottom row's own
/// ticks and transform. That places the shared axis *inside* a row, where the
/// controller's axis-zoom hit test can reach it, instead of in a band outside every
/// row that no gesture could land in.
/// on -- the default -- the x axis is the **bottom row's own** for every run of rows
/// with the *same columns* (the same number of cells and the same normalised cell
/// weights): those rows above reserve no x chrome at all, and the bottom row of the run
/// keeps its own label and title strips, so those labels and that title are drawn once,
/// from its own ticks and transform. A row whose columns differ from the row below keeps
/// its own strip, drawn under each of its cells, because the axis below is at another
/// scale and width. The rule is structural, never a function of what a plot holds. That
/// places a shared axis *inside* a row, where the controller's axis-zoom hit test can
/// reach it, instead of in a band outside every row that no gesture could land in.
///
/// **The x link.** `plot(i).set_x_range()` and a controller gesture both reach a
/// member's x range the same way, so a figure cannot see either of them happen.
@@ -186,14 +190,17 @@ class Figure {
/// than row by row for the left gutter's reason: the rows' axes have to line up, and
/// they end on a common column only if they are told one width. See
/// `Plot::set_right_gutter()` for what it is for. Negative values are clamped to 0.
/// In a split row whose cells share one y (`set_share_row_y`) only the **last** cell
/// keeps it, taken off the row like the y gutter, so the gap between two plots of the
/// row is the column gap.
void set_right_gutter(f32 device_px) noexcept {
right_gutter_ = device_px > 0.0f ? device_px : 0.0f;
}
f32 right_gutter() const noexcept { return right_gutter_; }
/// Whether the shared x axis is the figure's: every row reserves no x label strip
/// and one shared axis is drawn under the last row (the default), or every row
/// draws its own labels and the figure draws nothing extra.
/// Whether rows with the same columns as the row below hand it their x axis (the
/// default; a row with different columns keeps its own), or every row draws its own
/// labels and the figure draws nothing extra.
void set_x_labels_only_on_bottom(bool on) noexcept { labels_bottom_only_ = on; }
bool x_labels_only_on_bottom() const noexcept { return labels_bottom_only_; }
+7 -6
View File
@@ -238,12 +238,13 @@ class Renderer {
/// exactly as a stand-alone plot is, and `draw(Plot&)` itself is unchanged for
/// single-plot hosts.
/// The figure's one shared x axis is the **bottom row's own**, so it is drawn by
/// that row's normal path: when the figure says labels belong on the bottom only
/// (the default), the rows above reserve no x chrome and the bottom row stages its
/// own labels and title once, inside its own strip -- the rect
/// `FigureLayout::x_axis_strip` reads back. With that switched off, every row keeps
/// its own chrome and each draws its own labels; there is then no shared axis.
/// A shared x axis is the **bottom row's own** (of a run of rows with the same
/// columns), so it is drawn by that row's normal path: when the figure says labels
/// belong on the bottom only (the default), the rows above that match the row below
/// reserve no x chrome and the bottom row stages its own labels and title once,
/// inside its own strip -- the rect `FigureLayout::x_axis_strip` reads back. A row
/// whose columns differ keeps and draws its own; with that switched off, every row
/// does and there is no shared axis.
/// Styling, the text backend and `ui_scale` are installed on **every** row before
/// the figure computes its layout, so the strips the rows measure and the strokes
+79 -23
View File
@@ -73,6 +73,38 @@ f32 place_cells(const Rect& r, u32 n, const std::vector<f32>& weights, f32 col_g
return gap;
}
/// The share of the row each of its `n` cells is given by `weights`, exactly as `place_cells`
/// reads them: an empty (or wrongly sized, or non-positive-total) vector means equal shares.
std::vector<f32> normalised_shares(const std::vector<f32>& weights, u32 n) {
std::vector<f32> shares(n, 1.0f / static_cast<f32>(n));
f32 total = 0.0f;
if (weights.size() == n) {
for (const f32 w : weights) total += w;
}
if (total > 0.0f) {
for (u32 i = 0; i < n; ++i) shares[i] = weights[i] / total;
}
return shares;
}
/// Whether two rows have the **same columns**: the same number of cells and the same
/// normalised cell weights (within 1e-4). The rows are all the figure's width, so such rows
/// get cells with identical x extents -- the same y gutter, gap, floors and right gutter
/// apply to both -- and one row's x axis serves the other. Structural, never content: it
/// reads the cell counts and the weights the host set and nothing a plot holds.
bool same_columns(const std::vector<u32>& cells_per_row,
const std::vector<std::vector<f32>>& cell_weights, u32 row_a, u32 row_b) {
const u32 n = cells_per_row[row_a];
if (n != cells_per_row[row_b]) return false;
if (n == 1u) return true;
const std::vector<f32> a = normalised_shares(cell_weights[row_a], n);
const std::vector<f32> b = normalised_shares(cell_weights[row_b], n);
for (u32 i = 0; i < n; ++i) {
if (std::fabs(a[i] - b[i]) > 1e-4f) return false;
}
return true;
}
/// A coordinate in the shared axis' **axis space**: `log10` when the figure's one
/// x axis -- the bottom row's, the row the shared strip belongs to -- is
/// logarithmic, the value itself otherwise. A following group's span and offset
@@ -558,12 +590,13 @@ const FigureLayout& Figure::layout() {
}
const f32 figure_h = std::max(0.0f, viewport_.h);
// The rows tile the figure itself: the shared x axis is the *bottom row's own*, so
// nothing is taken off the figure for it. That is what keeps the axis reachable --
// its strip is a real `PlotLayout::x_label_area` inside the bottom row's rect, which
// is the rect the controller's axis-zoom hit test reads and the strip an x title is
// drawn in -- and it is why an interior row reserves no x chrome at all instead of
// leaving a band no one owns.
// The rows tile the figure itself: an x axis is a row's *own* strip (the bottom row's, and
// the strip of any row whose columns differ from the row below), so nothing is taken off
// the figure for it. That is what keeps the axis reachable -- its strip is a real
// `PlotLayout::x_label_area` inside a row's rect, which is the rect the controller's
// axis-zoom hit test reads and the strip an x title is drawn in -- and it is why a row
// that hands its axis to the one below reserves no x chrome at all instead of leaving a
// band no one owns.
const f32 content_h = figure_h;
// The gap is dropped, rather than squeezing the rows, when the figure cannot hold
// it: a figure one pixel tall with four rows still gives every row a positive
@@ -633,18 +666,31 @@ const FigureLayout& Figure::layout() {
if (n > 1u && used < col_gap_) layout_.col_gap = 0.0f;
}
const u32 bottom_row = rows - 1u;
// Which rows carry their own x strip, decided structurally and before the first
// measurement pass so that pass and the final one see the same strips. The bottom row
// always has one. A row above hides its strip only when the row directly below has the
// same columns (see `same_columns()`), because then the cells of the two rows have the
// same x extents and the axis below serves this row too; the rule is transitive, since
// that row is itself either labelled or hidden in favour of one that matches it. A row
// whose columns differ from the one below -- `[1][2]` over `[3]` -- keeps its own strip,
// drawn under each of its cells, because the axis below is at another scale and width.
// A figure told to label every row keeps the strips everywhere.
std::vector<XStrip> strips(rows, XStrip::Both);
if (labels_bottom_only_) {
for (u32 row = rows - 1u; row-- > 0u;) {
// By induction the row below is labelled or itself hidden in favour of a matching
// row, so hiding this one on a match with it is the transitive rule.
if (same_columns(cells_per_row_, cell_weights_, row, row + 1u)) strips[row] = XStrip::None;
}
}
for (u32 row = 0; row < rows; ++row) {
// One x axis for the whole figure, and it is the bottom row's: every plot keeps its
// x chrome -- labels, title and the strip the controller's axis zoom is found in --
// and every plot above the bottom row gives all of it to that row, 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 plot has its own. A
// split bottom row labels each of its cells, each against its own x axis.
const XStrip strip = labels_bottom_only_ && row != bottom_row ? XStrip::None : XStrip::Both;
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]);
@@ -653,7 +699,7 @@ const FigureLayout& Figure::layout() {
// 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(right_gutter_);
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
@@ -708,18 +754,28 @@ const FigureLayout& Figure::layout() {
// 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, r.h}, n, cell_weights_[row],
col_gap_, floor_scale, cells);
const f32 used = place_cells(Rect{r.x + g_eff, r.y, r.w - g_eff - rg_eff, r.h}, n,
cell_weights_[row], col_gap_, floor_scale, cells);
cells[0].x -= g_eff;
cells[0].w += g_eff;
cells[n - 1u].w += rg_eff;
layout_.col_gap = used < col_gap_ ? 0.0f : layout_.col_gap;
for (u32 i = row_first_[row]; i < row_first_[row + 1u]; ++i) plots_[i].set_viewport(layout_.cells[i]);
// What the cell actually reserved, so a row too narrow for the whole gutter is not asked
// for more than it was given (the same value whenever the row has room).
plots_[row_first_[row + 1u] - 1u].set_right_gutter(rg_eff);
}
// A one-row figure imposes exactly what that row measured, so it lays out as the plot
// would on its own; a figure whose rows all need the same strip imposes that, which is
@@ -729,12 +785,12 @@ const FigureLayout& Figure::layout() {
p.layout();
}
// The figure's one x axis is the bottom row's, so the strip it is drawn in is that
// row's own label area -- the rect the controller hit-tests and the renderer stages
// its labels in, read back here rather than derived again, so a host and the frame
// cannot disagree about where the shared axis is. It is zero-sized only when there
// are no rows at all: with `set_x_labels_only_on_bottom(false)` it is simply the last
// row's own strip, one of several.
// `x_axis_strip` is the bottom row's own label area (the last plot's) -- the rect the
// controller hit-tests and the renderer stages its labels in, read back here rather than
// derived again, so a host and the frame cannot disagree about where it is. Rows above
// that carry their own strip (their columns differ from the row below, or every row is
// labelled) have it in their plots' own `layout().x_label_area`. It is zero-sized only
// when there are no rows at all.
layout_.x_axis_strip = plots_.back().layout().x_label_area;
return layout_;
}
+2 -2
View File
@@ -1312,8 +1312,8 @@ const PlotLayout& Plot::layout() {
y_width = y_axes_.empty() ? 0.0f : 2.0f * pad;
x_height = text_->height() + 2.0f * pad;
}
// A row of a `Figure` gives its x strip to the figure's one shared axis -- the
// bottom row's own strip, where that axis lives -- so it reserves none: the seed and
// A row of a `Figure` that hands its x axis to the row below (which has the same
// columns, so the same x extents) reserves no strip: the seed and
// the fixed point below both start from zero, and `axes` then reaches the viewport's
// bottom edge. Set here rather than in the loop, because the loop's `need_x` is what
// the strip wants and this overrides the want, not the measurement.
+7 -6
View File
@@ -480,10 +480,11 @@ void Renderer::draw(Figure& figure) {
// than laying each row out a second time. Nothing draws before this: a row's
// viewport is the figure's to set.
//
// The figure's one x axis needs no pass of its own: it is the bottom row's own, so
// that row stages it in its own strip through `draw_tick_labels()` like any other
// plot's axis -- and every row above it reserved no x chrome, which is what keeps
// the labels from appearing once per row without a second staging here.
// A shared x axis needs no pass of its own: it is the bottom row's own (of a run of rows
// with the same columns), so that row stages it in its own strip through
// `draw_tick_labels()` like any other plot's axis -- and every row of the run above it
// reserved no x chrome, which is what keeps the labels from appearing once per row without
// a second staging here. A row whose columns differ stages its own.
figure.layout();
for (u32 row = 0; row < rows; ++row) {
Plot& plot = figure.plot(row);
@@ -878,8 +879,8 @@ void Renderer::draw_tick_labels(const Plot& plot, const PlotLayout& layout) {
const f32 line_h = font_backend_.height();
// X labels: unless the plot gave its x label strip away -- a row of a `Figure`
// that reserves no x chrome, because the figure draws one shared axis in the bottom
// row's own strip instead -- in which case it has no strip to put them in and their
// that reserves no x chrome, because the row below has the same columns and draws the
// axis in its own strip instead -- in which case it has no strip to put them in and their
// absence is the point. A plot with no strip would otherwise stage its labels just
// above `axes`' bottom edge, i.e. over its own data. Centred under their tick on the
// strip's single text line, which is the one rule for a plot's labels and a figure's
+250 -2
View File
@@ -1257,10 +1257,11 @@ PLOT_TEST(figure_cells_share_x_link_and_crosshair) {
}
PLOT_TEST(figure_cells_x_labels_bottom_row_only) {
plot::Figure figure = split_figure({1, 2});
// 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(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);
}
@@ -1408,3 +1409,250 @@ PLOT_TEST(figure_row_y_sharing_off_still_gives_every_cell_a_gutter) {
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);
}
}