figure: rows can hold several plots (cells), cell weights and column gap

This commit is contained in:
Martino Ferrari committed 2026-11-03 16:47:47 +01:00
1 parent f79a97be9b
commit 7d8c453232
4 files changed
+341 -31

No files matched your search

+58 -6
View File
@@ -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<Rect> 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<Rect> 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<const u32> cells_per_row);
u32 row_count() const noexcept { return static_cast<u32>(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<u32>(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<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 ---------------------------------------------------------
@@ -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<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
/// 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<const u32> cells);
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_;
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.
+148 -24
View File
@@ -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<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;
}
/// 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<u32> ones(rows, 1u);
build_cells(std::span<const u32>(ones));
}
const Plot& Figure::plot(u32 row) const noexcept {
return row < plots_.size() ? plots_[row] : fallback_;
Figure::Figure(std::span<const u32> cells_per_row) : plots_(total_cells(cells_per_row)) {
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) {
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
+1 -1
View File
@@ -471,7 +471,7 @@ void Renderer::draw(Figure& figure) {
// Every row is prepared before the figure lays itself out, because a row's strips
// 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
+134
View File
@@ -16,6 +16,7 @@
#include <plot/render/embedded_mono_font.hpp>
#include <cmath>
#include <span>
#include <vector>
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<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) {
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);
}