From 7d8c453232ccb0dcbfe68ad16db9052b77abb529 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 16:47:47 +0100 Subject: [PATCH] figure: rows can hold several plots (cells), cell weights and column gap --- include/plot/plot/figure.hpp | 64 +++++++++++-- src/plot/figure.cpp | 172 ++++++++++++++++++++++++++++++----- src/render/renderer.cpp | 2 +- tests/test_figure.cpp | 134 +++++++++++++++++++++++++++ 4 files changed, 341 insertions(+), 31 deletions(-) diff --git a/include/plot/plot/figure.hpp b/include/plot/plot/figure.hpp index 1da7742..295866d 100644 --- a/include/plot/plot/figure.hpp +++ b/include/plot/plot/figure.hpp @@ -27,6 +27,11 @@ struct FigureLayout { /// strips and axes come from its own `Plot::layout()` against this rect, so a row /// with a y title keeps its column and no two rows can disagree about chrome. std::vector rows; + /// One rect per plot, flattened row-major (row 0's cells left to right, then row 1's, + /// ...): the **viewport** each `Plot` was given. A row of one cell has exactly its row + /// rect here; a row of several tiles its rect left to right with `col_gap` between + /// cells, the cells' widths summing to the row's less the gaps. + std::vector cells; /// The strip the figure's one shared x axis is drawn in: the bottom row's own /// `PlotLayout::x_label_area`, at the bottom of the bottom row's rect and so as /// wide as that row's axes rather than the whole figure. Read back from the row @@ -44,6 +49,10 @@ struct FigureLayout { /// figure edge to edge, which is the default; it is dropped for a figure too /// short to hold it, so the rows never lose their height to a gap. f32 row_gap = 0.0f; + /// The horizontal gap between two cells of a row, in device pixels. `set_col_gap()`'s + /// value, or 0 when any row holding several cells was too narrow to hold it and + /// dropped it, so the cells never lose their width to a gap. + f32 col_gap = 0.0f; }; /// Several plots stacked in one column, sharing one x axis. @@ -94,13 +103,30 @@ class Figure { /// which is the *unlinked* group: rows that share nothing, each with the range /// its own plot was given. explicit Figure(u32 rows); + /// A figure of `cells_per_row.size()` rows, row `r` holding `cells_per_row[r]` plots side + /// by side (a count of 0 is clamped to 1). Every row of one cell makes this the same + /// figure as `Figure(rows)`. There is deliberately no `initializer_list` constructor, so + /// `Figure{3u}` keeps meaning three rows. + explicit Figure(std::span cells_per_row); - u32 row_count() const noexcept { return static_cast(plots_.size()); } - /// One row's plot. A row that does not exist yields a default-constructed `Plot` - /// rather than reading out of bounds -- the rule every accessor on `Plot` follows - /// -- so a stale row can only ever affect itself. - Plot& plot(u32 row) noexcept; - const Plot& plot(u32 row) const noexcept; + /// The number of figure rows -- the bands stacked top to bottom -- which equals + /// `plot_count()` for a figure no row of which is split. + u32 row_count() const noexcept { return static_cast(cells_per_row_.size()); } + /// The number of plots, i.e. cells over all rows. `plot()`, `link_x()`, `x_link()` and + /// the hovered plot of `set_crosshair()` index this flattened (row-major) range. + u32 plot_count() const noexcept { return static_cast(plots_.size()); } + /// How many cells row `row` holds; 0 for a row that does not exist. + u32 cells_in_row(u32 row) const noexcept; + /// The flattened index of row `row`'s first cell; `plot_count()` for `row == + /// row_count()` and for anything beyond. + u32 first_plot_of_row(u32 row) const noexcept; + /// The row that holds flattened plot `plot` (the last row for an index past the end). + u32 row_of_plot(u32 plot) const noexcept; + /// One plot, by flattened cell index. A plot that does not exist yields a + /// default-constructed `Plot` rather than reading out of bounds -- the rule every + /// accessor on `Plot` follows -- so a stale index can only ever affect itself. + Plot& plot(u32 index) noexcept; + const Plot& plot(u32 index) const noexcept; // --- geometry --------------------------------------------------------- @@ -119,6 +145,21 @@ class Figure { void clear_row_weights() noexcept { row_weights_.clear(); } bool has_row_weights() const noexcept { return !row_weights_.empty(); } + /// How much of row `row`'s width each of its cells gets: `weights[i]` is cell i's + /// share of what is left after every cell has its floor width (64 logical pixels, + /// capped at an equal share of a narrow row). Normalised. Refused, changing nothing, + /// when the row does not exist, the count is not the row's cell count, or a weight is + /// not a positive finite number. A row with no weights divides equally. + Status set_cell_weights(u32 row, std::span weights); + /// Back to an equal division of row `row`; a row that does not exist is ignored. + void clear_cell_weights(u32 row) noexcept; + bool has_cell_weights(u32 row) const noexcept; + /// The horizontal gap left between two cells of a row, in device pixels (0.0 by + /// default). Negative values are clamped to 0, and a gap too wide for a row to hold is + /// dropped for that layout rather than squeezing the cells to nothing. + void set_col_gap(f32 device_px) noexcept { col_gap_ = device_px > 0.0f ? device_px : 0.0f; } + f32 col_gap() const noexcept { return col_gap_; } + /// The gap left between two rows, in device pixels (0.0 by default, so the rows /// tile the figure). Negative values are clamped to 0, and a gap too large for the /// figure to hold is dropped for that layout rather than squeezing the rows to @@ -325,7 +366,18 @@ class Figure { /// a following group's window, push both to all members. void sync_x_links(); + /// 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 cells); + std::vector plots_; + std::vector 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 row_first_; + std::vector> cell_weights_; + f32 col_gap_ = 0.0f; /// What `plot()` yields for a row that does not exist, so a stale row can be used /// without reading out of bounds. Owned rather than a static, so one figure's /// stray row can never write into another's. diff --git a/src/plot/figure.cpp b/src/plot/figure.cpp index dcefa0d..cf10a0c 100644 --- a/src/plot/figure.cpp +++ b/src/plot/figure.cpp @@ -23,6 +23,56 @@ namespace { /// wants a taller floor gives the figure a taller viewport, or gives the rows weights. constexpr f32 kMinRowHeightLogical = 24.0f; +/// The width a cell never drops below, in logical pixels, converted exactly as the row +/// height floor is. It yields to the row -- capped at an equal share of the width there +/// is -- so a row too narrow for its cells still tiles instead of overflowing. +constexpr f32 kMinCellWidthLogical = 64.0f; + +/// The number of plots a per-row cell count asks for, each row holding at least one. +u32 total_cells(std::span cells) noexcept { + u32 total = 0; + for (const u32 c : cells) total += std::max(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& 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(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(n)); + const f32 extra = usable - floor_px * static_cast(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(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; +} + /// A coordinate in the shared axis' **axis space**: `log10` when the figure's one /// x axis -- the bottom row's, the row the shared strip belongs to -- is /// logarithmic, the value itself otherwise. A following group's span and offset @@ -41,18 +91,72 @@ f64 from_link_space(f64 v, bool log) noexcept { } // namespace -Figure::Figure(u32 rows) : plots_(rows) {} - -Plot& Figure::plot(u32 row) noexcept { - return row < plots_.size() ? plots_[row] : fallback_; +Figure::Figure(u32 rows) : plots_(rows) { + const std::vector ones(rows, 1u); + build_cells(std::span(ones)); } -const Plot& Figure::plot(u32 row) const noexcept { - return row < plots_.size() ? plots_[row] : fallback_; +Figure::Figure(std::span cells_per_row) : plots_(total_cells(cells_per_row)) { + build_cells(cells_per_row); +} + +void Figure::build_cells(std::span cells) { + cells_per_row_.assign(cells.begin(), cells.end()); + for (u32& c : cells_per_row_) c = std::max(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 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 weights) { - if (weights.size() != plots_.size()) { + if (weights.size() != cells_per_row_.size()) { return Status{ErrorCode::InvalidArgument, "row weights must name every row"}; } f32 total = 0.0f; @@ -369,6 +473,8 @@ void Figure::sync_x_links() { const FigureLayout& Figure::layout() { const u32 rows = row_count(); layout_.rows.assign(rows, Rect{}); + layout_.cells.clear(); + layout_.col_gap = 0.0f; layout_.row_gap = row_gap_; layout_.x_axis_strip = Rect{}; if (rows == 0u) { @@ -441,20 +547,35 @@ const FigureLayout& Figure::layout() { // short to hold the one asked for. layout_.row_gap = gap; + // 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) { + const u32 n = cells_per_row_[row]; + const f32 used = place_cells(layout_.rows[row], n, cell_weights_[row], col_gap_, + floor_scale, &layout_.cells[row_first_[row]]); + if (n > 1u && used < col_gap_) layout_.col_gap = 0.0f; + } + const u32 bottom_row = rows - 1u; for (u32 row = 0; row < rows; ++row) { - Plot& p = plots_[row]; - p.set_viewport(layout_.rows[row]); - // One x axis for the whole figure, and it is the bottom row's: every row keeps its + // 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 other row gives all of it to that one, so the labels and the title are - // not drawn once per row. A figure told to label every row keeps them everywhere, - // and then there is no shared axis to speak of: each row has its own. - p.set_x_strip_mode(labels_bottom_only_ && row != bottom_row ? XStrip::None : XStrip::Both); - // Before either pass below, and the same value for every row: nothing is measured on - // the right, so unlike the left gutter this needs no measurement pass -- the rows end - // on a common column because they are all told the one width. - p.set_right_gutter(right_gutter_); + // 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; + 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); + // 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_); + } } // 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 @@ -486,18 +607,21 @@ const FigureLayout& Figure::layout() { // `set_min_y_gutter()` seeds the maximum rather than being imposed on a row before the // measurement, so a reserved floor cannot widen what a row reports and then be widened // by its own reservation on the next frame. + // + // Over the flattened plots: every cell of every row takes part, so the y axes of a split + // row line up with each other and with the rows above and below. f32 gutter = min_y_gutter_; - for (u32 row = 0; row < rows; ++row) { - plots_[row].set_min_y_gutter(0.0f); - const PlotLayout& row_layout = plots_[row].layout(); + for (Plot& p : plots_) { + p.set_min_y_gutter(0.0f); + const PlotLayout& row_layout = p.layout(); gutter = std::max(gutter, row_layout.y_axis_width + row_layout.y_title_width); } // A one-row figure imposes exactly what that row measured, so it lays out as the plot // would on its own; a figure whose rows all need the same strip imposes that, which is // also what each would have done alone. - for (u32 row = 0; row < rows; ++row) { - plots_[row].set_min_y_gutter(gutter); - plots_[row].layout(); + for (Plot& p : plots_) { + p.set_min_y_gutter(gutter); + p.layout(); } // The figure's one x axis is the bottom row's, so the strip it is drawn in is that diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index f9094aa..9ef680c 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -471,7 +471,7 @@ void Renderer::draw(Figure& figure) { // Every row is prepared before the figure lays itself out, because a row's strips // are measured from the metrics this installs: a row measuring with one backend and // drawing with another is the defect the propagation point exists to prevent. - const u32 rows = figure.row_count(); + const u32 rows = figure.plot_count(); for (u32 row = 0; row < rows; ++row) prepare_plot(figure.plot(row)); // The figure's one layout for the frame. It hands every row its rect and its diff --git a/tests/test_figure.cpp b/tests/test_figure.cpp index 72a37b4..a69c50e 100644 --- a/tests/test_figure.cpp +++ b/tests/test_figure.cpp @@ -16,6 +16,7 @@ #include #include +#include #include namespace { @@ -1141,3 +1142,136 @@ PLOT_TEST(figure_crosshair_draws_a_horizontal_line_only_in_the_hovered_row) { PLOT_CHECK(figure.plot(1u).layout().crosshair.vertical.w > 0.0f); PLOT_CHECK(figure.plot(1u).layout().crosshair.horizontal.h == 0.0f); } + +// ------------------------------------------------------------------ cell grid + +namespace { + +/// A figure whose rows hold `cells[r]` plots each (the flattened plots in row-major +/// order), with an x and a y axis on every plot unless `axes` is off. +plot::Figure split_figure(const std::vector& cells, bool axes = true) { + plot::Figure figure{std::span(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 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 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 three{1.0f, 1.0f, 1.0f}; + const std::vector 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) { + plot::Figure figure = split_figure({1, 2}); + 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); +}