fix(plot): a figure row's strip shows labels and title independently, and the shared axis is the bottom row's strip

This commit is contained in:
Martino Ferrari committed 2026-09-28 15:07:39 +02:00
1 parent 8f1e60a105
commit 770da4c2b2
9 files changed
+415 -175

No files matched your search

+18 -9
View File
@@ -24,12 +24,18 @@ 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;
/// The strip the figure's one shared x axis is drawn in: the full width of the
/// figure, directly under the last row, and zero-sized when the figure draws no
/// shared axis (`set_x_labels_only_on_bottom(false)`, or no rows). It is outside
/// every row rect, because the strip is the figure's -- the rows gave it up
/// (`Plot::set_x_label_strip()`), which is what keeps the labels from appearing
/// once per row.
/// 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
/// 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.
///
/// 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
/// the shared axis lives in a row rather than in a band of its own: a strip outside
/// every row would be a zero-height rect no press can ever land in.
Rect x_axis_strip;
/// The vertical gap between two rows, in device pixels. 0 when the rows tile the
/// figure edge to edge, which is the default; it is dropped for a figure too
@@ -50,9 +56,12 @@ 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 rows reserve no x label strip of their own and the
/// figure draws one shared axis under the last row, from the last row's own ticks
/// and transform: one set of labels, one baseline, no duplication.
/// 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.
///
/// **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.
+43 -21
View File
@@ -17,6 +17,34 @@
namespace plot {
/// Which parts of a plot's bottom x chrome `layout()` reserves.
///
/// The bottom chrome is two strips -- the *label* strip the tick labels live in and
/// the *title* strip the axis titles live in -- and they are one enum rather than two
/// flags because a strip means little on its own: a title with no labels needs a row
/// of its own below where the labels would have been, and a figure's interior rows want
/// neither. Naming the combinations keeps a caller from switching one off and silently
/// taking the other with it, which is what a boolean would do.
///
/// The one caller that selects a mode is `Figure`: an interior row of a figure that
/// draws its x labels on the bottom only reserves no x chrome at all (`None`), so its
/// `axes` reaches its rect's bottom edge and the figure's one x axis -- the bottom row's
/// own, whose strip the shared axis occupies -- is the figure's only one. A plot drawn
/// on its own is `Both`, which is what it has always been; `LabelsOnly` and `TitleOnly`
/// are for a host that wants one without the other.
enum class XStrip { Both, LabelsOnly, TitleOnly, None };
/// Whether a strip mode reserves the label strip. One rule, read by `Plot::layout()`
/// when it sizes the strip and by the renderer when it decides whether to stage the
/// labels -- a row that reserves none would otherwise stage them over its own data.
constexpr bool x_strip_has_labels(XStrip mode) noexcept {
return mode == XStrip::Both || mode == XStrip::LabelsOnly;
}
/// Whether a strip mode reserves the title strip, the same one rule for the title.
constexpr bool x_strip_has_title(XStrip mode) noexcept {
return mode == XStrip::Both || mode == XStrip::TitleOnly;
}
/// Pixel geometry of a plot's chrome, all of it in the same pixel space as
/// `Rect` (origin top-left) and therefore in **device** pixels: everything here is
/// what the renderer draws with, already converted from the logical metrics in
@@ -43,9 +71,8 @@ struct PlotLayout {
/// Left strip width. The default is used when no text backend has been set.
f32 y_axis_width = 56.0f;
/// Bottom strip height. The default is used when no text backend has been set, and
/// it is 0.0f when the strip is not reserved at all -- see
/// `Plot::set_x_label_strip()`, which is how a row of a `Figure` hands the band to
/// the figure's one shared axis.
/// it is 0.0f when the label strip is not reserved at all -- see `XStrip`, which is
/// how a row of a `Figure` hands the band to the figure's one shared x axis.
f32 x_axis_height = 22.0f;
/// Width of `y_title_area` and height of `x_title_area`, both 0.0f when nothing
/// is reserved. Every titled axis of a direction gets its own text line inside
@@ -134,23 +161,18 @@ class Plot {
/// The scale the last layout was computed at. 1.0 until a host says otherwise.
f32 ui_scale() const noexcept { return style_.ui_scale; }
/// Whether `layout()` reserves the bottom x label strip -- and, with it, the x
/// title strip: a title is the labels' caption and goes where they do.
/// Which parts of the bottom x chrome `layout()` reserves; `XStrip::Both` by
/// default, which is what a plot drawn on its own has always been.
///
/// True by default, which is what a plot drawn on its own has always been. A
/// `Figure` turns it off on every row while it draws one shared x axis under its
/// last row (see `Figure::set_x_labels_only_on_bottom()`): the strip is not
/// merely left blank, it is not reserved at all, so such a row's `axes` reaches
/// the row rect's bottom edge and the row gives the band to the figure's shared
/// axis instead of growing a second, duplicate one. Nothing else about the row
/// changes -- its ticks still refresh and its grid still builds, because the
/// tick list is what the shared axis and the grid are drawn from, not the strip.
///
/// The plot it belongs to draws no x tick labels while it is off (their strip
/// has no height left to hold them); whoever turned it off is the one that puts
/// them somewhere -- `Renderer::draw(Figure&)` draws them in the figure's strip.
void set_x_label_strip(bool on) noexcept { x_label_strip_ = on; }
bool x_label_strip() const noexcept { return x_label_strip_; }
/// A part that is not reserved costs nothing: the strip has no height (or the title
/// no row), `axes` grows into the band, and the renderer stages no run for it. A
/// figure's interior rows are the case that asked for it -- they sit under one shared
/// x axis, so they reserve no x chrome at all -- and `Figure::set_x_labels_only_on_bottom()`
/// is what selects their mode. Nothing else about such a row changes: its ticks still
/// refresh and its grid still builds, because the tick list is what a grid and a
/// shared axis are drawn from, not the strip.
void set_x_strip_mode(XStrip mode) noexcept { x_strip_ = mode; }
XStrip x_strip_mode() const noexcept { return x_strip_; }
/// The font layout() measures tick labels with. Borrowed, not owned, and may be
/// null -- then the label areas fall back to PlotLayout's defaults and no string
@@ -555,8 +577,8 @@ class Plot {
render::Style style_{};
const render::TextBackend* text_ = nullptr;
Legend legend_{};
/// Whether the bottom x label strip is reserved at all; see set_x_label_strip().
bool x_label_strip_ = true;
/// Which parts of the bottom x chrome are reserved at all; see set_x_strip_mode().
XStrip x_strip_ = XStrip::Both;
/// Which handles a pointer is on, published by the input layer (see HotHandles).
HotHandles hot_{};
+6 -10
View File
@@ -140,11 +140,12 @@ class Renderer {
/// exactly as a stand-alone plot is, and `draw(Plot&)` itself is unchanged for
/// single-plot hosts.
/// The shared axis is the frame's last text: when the figure says labels belong on
/// the bottom only (the default), the rows reserved no x label strip of their own
/// and the labels of the last row's x axes are staged once, in the strip
/// `FigureLayout::x_axis_strip` measured, under the last row. With that switched
/// off, every row draws its own labels and this draws no shared axis at all.
/// 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.
/// 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
@@ -363,11 +364,6 @@ class Renderer {
/// ticks, the strips and the grid, so it is computed once, in `draw()`, and handed
/// down -- never re-derived per helper.
void draw_tick_labels(const Plot& plot, const PlotLayout& layout);
/// The x tick labels of every x axis of `plot`, centred on their ticks in `strip`:
/// what `draw_tick_labels()` uses with the plot's own `x_label_area`, and what
/// `draw(Figure&)` uses with the figure's shared strip -- one rule for a plot's
/// labels and a figure's, so the two can never place a label differently.
void draw_x_tick_labels(const Plot& plot, const Rect& strip);
void draw_titles(const Plot& plot, const PlotLayout& layout);
/// The data helpers take it too; they read the axes rect and the transforms it
/// pins.