From bee318870fd68c6fafbda8cb0201b73f921e4e4e Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 5 Oct 2026 22:24:27 +0200 Subject: [PATCH] feat(plot): a figure can keep an empty band to the right of its data `layout_.axes` reached the viewport's right edge, so the rightmost x tick label -- centred on its tick -- had half of itself outside the plot, and the newest sample's ink landed on the frame. `Plot::set_right_gutter()` reserves a band there, out of the same `1 - kMinAxesShare` allowance the label and title strips are served from, so it can no more squeeze `axes` below the half-viewport floor than they can. `Figure` sets one value on every row, the left gutter's reason: the rows' axes have to end on a common column as well as start on one. Nothing is drawn in the band, so unlike the strips it is a want and never a measurement: no measuring pass, and it cannot feed the layout's fixed point. Co-Authored-By: Claude Opus 4.6 --- include/plot/plot/figure.hpp | 11 +++++++++ include/plot/plot/plot.hpp | 22 +++++++++++++++++ src/plot/figure.cpp | 4 ++++ src/plot/plot.cpp | 11 ++++++++- tests/test_figure.cpp | 46 ++++++++++++++++++++++++++++++++++++ 5 files changed, 93 insertions(+), 1 deletion(-) diff --git a/include/plot/plot/figure.hpp b/include/plot/plot/figure.hpp index 54028c2..1da7742 100644 --- a/include/plot/plot/figure.hpp +++ b/include/plot/plot/figure.hpp @@ -138,6 +138,16 @@ class Figure { } f32 min_y_gutter() const noexcept { return min_y_gutter_; } + /// The empty band every row keeps to the right of its `axes`, in device pixels (0.0 + /// by default, so `axes` reaches the viewport's right edge). It is set here rather + /// 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. + 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. @@ -326,6 +336,7 @@ class Figure { FigureLayout layout_{}; f32 row_gap_ = 0.0f; f32 min_y_gutter_ = 0.0f; + f32 right_gutter_ = 0.0f; bool labels_bottom_only_ = true; }; diff --git a/include/plot/plot/plot.hpp b/include/plot/plot/plot.hpp index 4d4f555..066aaa9 100644 --- a/include/plot/plot/plot.hpp +++ b/include/plot/plot/plot.hpp @@ -88,6 +88,11 @@ struct PlotLayout { /// no text backend there is no title to lay out at all and both stay 0.0f. f32 y_title_width = 0.0f; f32 x_title_height = 0.0f; + /// The empty band kept between `axes`' right edge and the viewport's, 0.0f unless a + /// caller asked for one (see `Plot::set_right_gutter()`). Reported because it is + /// clamped by the same cap as the strips above, so what was asked for and what was + /// reserved are not always the same number. + f32 right_gutter = 0.0f; /// The measured legend: inside `axes`, never reserving any part of it, and empty /// when there is nothing to draw. It is measured with the same text backend the /// strips above are, and against the same `axes` rect the caller reads back, so @@ -640,6 +645,20 @@ class Plot { } f32 min_y_gutter() const noexcept { return min_y_gutter_; } + /// An empty band reserved between `axes`' right edge and the viewport's, in device + /// pixels. 0.0 -- the default -- is what the layout has always done: `axes` reaches + /// the viewport's right edge, which leaves the rightmost x tick label, *centred* on + /// that edge, half of it to be drawn in and nothing to draw it in -- and puts the + /// newest sample's ink on the frame. It is the mirror of the left gutter in purpose + /// and not in kind: nothing is drawn here, so there is nothing to measure and the + /// value is the caller's to choose. Reserved inside the same cap as the label and + /// title strips, so it can no more squeeze `axes` below the half-viewport floor than + /// they can. Negative values are clamped to 0. + 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_; } + /// Grid segments for the primary axis pair (x axis 0, y axis 0), in data space. const Grid& grid() const noexcept { return grid_; } @@ -867,6 +886,9 @@ class Plot { /// The floor a figure imposes on the left gutter; see set_min_y_gutter(). 0.0 means /// none, which is what a plot standing on its own keeps. f32 min_y_gutter_ = 0.0f; + /// The empty band kept to the right of `axes`; see set_right_gutter(). 0.0 means none, + /// which is what the layout did before the setter existed. + f32 right_gutter_ = 0.0f; /// Which handles a pointer is on, published by the input layer (see HotHandles). HotHandles hot_{}; diff --git a/src/plot/figure.cpp b/src/plot/figure.cpp index 7ebed6c..dcefa0d 100644 --- a/src/plot/figure.cpp +++ b/src/plot/figure.cpp @@ -451,6 +451,10 @@ const FigureLayout& Figure::layout() { // 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_); } // 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 diff --git a/src/plot/plot.cpp b/src/plot/plot.cpp index 985c363..b50ef79 100644 --- a/src/plot/plot.cpp +++ b/src/plot/plot.cpp @@ -1039,12 +1039,21 @@ const PlotLayout& Plot::layout() { x_height = std::min(x_height, x_budget); const f32 y_title_width = std::min(y_title_w, y_budget - y_width); const f32 x_title_height = std::min(x_title_h, x_budget - x_height); + // The right-hand band, out of what is left of the same allowance the two left + // strips were served from -- so the three together cannot take more than + // `1 - kMinAxesShare` of the width however much each asks for. Nothing is drawn + // in it, so unlike the strips above it is a want and never a measurement, and it + // cannot feed the fixed point below: the y labels are chosen from the row's range + // and height, so a narrower `axes` does not change what they need. + const f32 right_gutter = + std::min(right_gutter_, y_budget - (y_width + y_title_width)); layout_.y_axis_width = y_width; layout_.x_axis_height = x_height; layout_.y_title_width = y_title_width; layout_.x_title_height = x_title_height; + layout_.right_gutter = right_gutter; layout_.axes = Rect{viewport_.x + y_width + y_title_width, viewport_.y, - vw - (y_width + y_title_width), + vw - (y_width + y_title_width + right_gutter), vh - (x_height + x_title_height)}; layout_.y_title_area = Rect{viewport_.x, layout_.axes.y, y_title_width, layout_.axes.h}; layout_.y_label_area = diff --git a/tests/test_figure.cpp b/tests/test_figure.cpp index 251fbbe..72a37b4 100644 --- a/tests/test_figure.cpp +++ b/tests/test_figure.cpp @@ -425,6 +425,52 @@ PLOT_TEST(figure_gutter_never_squeezes_the_axes_below_the_floor) { } } +/// The other side of the same band. `axes` reached the viewport's right edge, which left +/// the rightmost x tick label -- centred on it -- half outside the plot, so a figure can +/// ask for an empty band there. One value for every row, like the left gutter, because +/// the rows' axes have to end on a common column as well as start on one. +PLOT_TEST(figure_rows_share_one_right_gutter) { + plot::Figure figure = different_magnitude_figure(); + figure.layout(); + const plot::f32 flush = figure.plot(0u).layout().axes.right(); + PLOT_CHECK_NEAR(static_cast(figure.right_gutter()), 0.0, 1e-12); + // The default is the layout as it always was: nothing between the data and the frame. + PLOT_CHECK_NEAR(static_cast(flush), static_cast(kW), 1e-4); + + figure.set_right_gutter(30.0f); + figure.layout(); + const plot::Rect first = figure.plot(0u).layout().axes; + for (plot::u32 row = 0; row < 3u; ++row) { + const plot::PlotLayout& rl = figure.plot(row).layout(); + PLOT_CHECK_NEAR(static_cast(rl.right_gutter), 30.0, 1e-4); + PLOT_CHECK_NEAR(static_cast(rl.axes.right()), static_cast(kW) - 30.0, + 1e-4); + // The band comes off the right alone: the left gutter the rows agreed on is untouched, + // so a host can balance the two sides without the one moving when it sets the other. + PLOT_CHECK_NEAR(static_cast(rl.axes.x), static_cast(first.x), 1e-4); + PLOT_CHECK_NEAR(static_cast(rl.axes.w), static_cast(first.w), 1e-4); + // ... and the x strip follows the data area rather than the viewport, or the shared + // axis would be labelled over a band that holds no data. + PLOT_CHECK_NEAR(static_cast(rl.x_label_area.w), static_cast(rl.axes.w), + 1e-4); + } + // Clamped by the same cap as the strips: a band wider than the whole figure cannot + // squeeze `axes` below the floor `Plot::layout()` guarantees. + figure.set_right_gutter(10.0f * kW); + figure.layout(); + for (plot::u32 row = 0; row < 3u; ++row) { + const plot::Rect& axes = figure.plot(row).layout().axes; + PLOT_CHECK(axes.w >= 0.5f * kW - 1e-3f); + } + // Negative means "none", like the gutter and the row gap, and the data area goes back + // to the viewport's edge. + figure.set_right_gutter(-5.0f); + PLOT_CHECK_NEAR(static_cast(figure.right_gutter()), 0.0, 1e-12); + figure.layout(); + PLOT_CHECK_NEAR(static_cast(figure.plot(0u).layout().axes.right()), + static_cast(flush), 1e-4); +} + /// The degenerate neighbour: a figure of one row imposes exactly what that row /// measured, so it lays out as the plot would on its own -- the gutter cannot move a /// row that has no sibling to line up with.