diff --git a/include/plot/plot/figure.hpp b/include/plot/plot/figure.hpp index 6d0097a..8511b51 100644 --- a/include/plot/plot/figure.hpp +++ b/include/plot/plot/figure.hpp @@ -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 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_; } diff --git a/include/plot/render/renderer.hpp b/include/plot/render/renderer.hpp index b89474d..0389653 100644 --- a/include/plot/render/renderer.hpp +++ b/include/plot/render/renderer.hpp @@ -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 diff --git a/src/plot/figure.cpp b/src/plot/figure.cpp index 71fd5d3..f9f3bd7 100644 --- a/src/plot/figure.cpp +++ b/src/plot/figure.cpp @@ -73,6 +73,38 @@ f32 place_cells(const Rect& r, u32 n, const std::vector& 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 normalised_shares(const std::vector& weights, u32 n) { + std::vector shares(n, 1.0f / static_cast(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& cells_per_row, + const std::vector>& 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 a = normalised_shares(cell_weights[row_a], n); + const std::vector 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 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_; } diff --git a/src/plot/plot.cpp b/src/plot/plot.cpp index 924b94e..4d7ad01 100644 --- a/src/plot/plot.cpp +++ b/src/plot/plot.cpp @@ -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. diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index b5d85c9..4ed710b 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -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 diff --git a/tests/test_figure.cpp b/tests/test_figure.cpp index 8f9c92b..cbe0608 100644 --- a/tests/test_figure.cpp +++ b/tests/test_figure.cpp @@ -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& 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 one_three{1.0f, 3.0f}; + const std::vector two_six{2.0f, 6.0f}; + const std::vector 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 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); + } +}