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

+31 -33
View File
@@ -299,28 +299,13 @@ const FigureLayout& Figure::layout() {
}
const f32 figure_h = std::max(0.0f, viewport_.h);
// The shared strip's height is what one of these rows' own x label strip would ask
// for, and only a plot can measure that: it is the text backend's line height plus
// the label padding, clamped against the area it has. So the last row is laid out
// against the whole figure once, with its own strip switched back on, and its
// answer is what the figure reserves. The probe's viewport is the figure's rather
// than the row's on purpose -- the strip belongs to the figure, so the row's own
// budget must not shrink it.
//
// That probe is also why the strip cannot eat the rows, and why there is no second
// clamp here to say so: the plot's own chrome cap (`Plot::layout()`'s kMinAxesShare)
// already holds a label strip to half of the viewport it was laid out against, and
// the viewport it was laid out against is the figure. A figure therefore always has
// at least half its height to divide between the rows.
f32 strip_h = 0.0f;
if (labels_bottom_only_) {
Plot& probe = plots_.back();
probe.set_viewport(viewport_);
probe.set_x_label_strip(true);
strip_h = probe.layout().x_axis_height;
}
const f32 content_h = figure_h - strip_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.
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
// height.
@@ -332,11 +317,11 @@ const FigureLayout& Figure::layout() {
}
// ... and the height to divide is clamped non-negative here, so every height below
// is a share of a non-negative total and no row rect can come out negative: a row
// rect is handed to a plot, and a plot laid out against a negative one is not a
// case that layout arithmetic is defined for. The clamp cannot fire -- the strip is
// at most half the figure (above), so there is always at least half a figure of
// height to divide -- and it is what keeps that reasoning in the arithmetic rather
// than in a comment.
// rect is handed to a plot, and a plot laid out against a negative one is not a case
// that layout arithmetic is defined for. Nothing above can make it negative -- the
// figure's height is clamped at zero and a gap that would eat it is dropped, both
// just above -- so the clamp is the arithmetic carrying its own invariant rather
// than a comment asking the reader to carry it.
usable = std::max(0.0f, usable);
// The floor, then the weights' share of what is left. Giving every row the floor
@@ -363,14 +348,23 @@ const FigureLayout& Figure::layout() {
consumed = end;
y = layout_.rows[row].bottom() + gap;
}
// The gaps are only *between* rows: the rows advance by `gap`, but they divide
// `usable`, which is already the figure less every gap, so the last row lands on the
// figure's bottom edge for any `row_gap_` -- the figure is tiled, not padded at the
// end. `row_gap` reports the gap actually left, which is 0 when the figure was too
// short to hold the one asked for.
layout_.row_gap = gap;
const u32 bottom_row = rows - 1u;
for (u32 row = 0; row < rows; ++row) {
Plot& p = plots_[row];
p.set_viewport(layout_.rows[row]);
// The rows give their x label strip to the figure's one shared axis; with the
// shared axis switched off each row keeps its own and draws its own labels.
p.set_x_label_strip(!labels_bottom_only_);
// One x axis for the whole figure, and it is the bottom row's: every row 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);
}
// 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
@@ -383,9 +377,13 @@ const FigureLayout& Figure::layout() {
// is the one that computes its own.
for (u32 row = 0; row < rows; ++row) plots_[row].layout();
// The strip hugs the figure's bottom, directly under the last row (a gap, if any,
// separates the two exactly as it separates two rows).
layout_.x_axis_strip = Rect{viewport_.x, viewport_.bottom() - strip_h, viewport_.w, strip_h};
// 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.
layout_.x_axis_strip = plots_.back().layout().x_label_area;
return layout_;
}
+13 -10
View File
@@ -661,12 +661,13 @@ 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, so it
// reserves none: 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.
if (!x_label_strip_) x_height = 0.0f;
// 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
// 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.
const bool x_labels = x_strip_has_labels(x_strip_);
if (!x_labels) x_height = 0.0f;
// The title strips, though, are needed before the loop and never change, unlike
// the label strips: a strip holds exactly one text line per titled axis and no
// tick label can change that. Nothing at all is reserved without a text backend
@@ -676,7 +677,10 @@ const PlotLayout& Plot::layout() {
// per titled y axis *wide*, because the y title is drawn rotated and so spends
// its length along the axes' height and takes a column, not a row, per titled
// axis (see y_title_strip_width).
const u32 x_titles = x_label_strip_ ? titled_x_axis_count() : 0u;
// The title strip is its own part of the mode: a mode that reserves the labels and
// no title reserves no row for a title either, and one that reserves the title and no
// labels still gets its row -- below where the labels would have been.
const u32 x_titles = x_strip_has_title(x_strip_) ? titled_x_axis_count() : 0u;
const f32 y_title_w = text_ != nullptr ? y_title_strip_width() : 0.0f;
const f32 x_title_h = text_ != nullptr && x_titles != 0u
? static_cast<f32>(x_titles) * text_->height() + 2.0f * pad
@@ -719,9 +723,8 @@ const PlotLayout& Plot::layout() {
// Overridden, not merely unmeasured: the strip is not reserved, so the fixed
// point has to be zero for a row that gave its strip away.
const f32 need_x =
x_label_strip_
? std::min(std::clamp(text_->height() + 2.0f * pad, 0.0f, viewport_.h), x_budget)
: 0.0f;
x_labels ? std::min(std::clamp(text_->height() + 2.0f * pad, 0.0f, viewport_.h), x_budget)
: 0.0f;
if (need_y == y_width && need_x == x_height) break; // strips are a fixed point
y_width = need_y;
x_height = need_x;