feat(plot): Figure with stacked rows and a synchronised x axis

This commit is contained in:
Martino Ferrari committed 2026-09-28 14:42:17 +02:00
1 parent 7cc06f07f5
commit 8f1e60a105
11 files changed
+1665 -24

No files matched your search

+392
View File
@@ -0,0 +1,392 @@
#include <plot/plot/figure.hpp>
#include <algorithm>
#include <cmath>
#include <cstddef>
namespace plot {
namespace {
/// The height a row never drops below, in device pixels: the floor that keeps a
/// plot's chrome from erasing it in a figure with many rows or a small viewport. It
/// yields to the figure -- it is capped at an equal share of the height there is --
/// rather than pushing rows past the viewport.
///
/// Device pixels, like every rect a figure computes: a `Figure` carries no `Style`
/// -- the renderer's is the one its rows are laid out with -- so there is no logical
/// unit here to convert from. A host that wants a taller floor gives the figure a
/// taller viewport, or gives the rows weights.
constexpr f32 kMinRowHeightPx = 24.0f;
} // namespace
Figure::Figure(u32 rows) : plots_(rows) {}
Plot& Figure::plot(u32 row) noexcept {
return row < plots_.size() ? plots_[row] : fallback_;
}
const Plot& Figure::plot(u32 row) const noexcept {
return row < plots_.size() ? plots_[row] : fallback_;
}
Status Figure::set_row_weights(std::span<const f32> weights) {
if (weights.size() != plots_.size()) {
return Status{ErrorCode::InvalidArgument, "row weights must name every row"};
}
f32 total = 0.0f;
for (const f32 w : weights) {
if (!std::isfinite(w) || !(w > 0.0f)) {
return Status{ErrorCode::InvalidArgument,
"row weights must be positive finite numbers: a weight is a share of "
"the figure, and a row with none of it has no rect"};
}
total += w;
}
if (!std::isfinite(total) || !(total > 0.0f)) {
return Status{ErrorCode::InvalidArgument, "row weights must sum to a positive weight"};
}
row_weights_.assign(weights.begin(), weights.end());
return Status{};
}
// --------------------------------------------------------------- the x link
u32 Figure::add_x_link() {
links_.emplace_back();
return static_cast<u32>(links_.size());
}
u32 Figure::x_link(u32 row) const noexcept {
for (std::size_t g = 0; g < links_.size(); ++g) {
for (const u32 member : links_[g].rows) {
if (member == row) return static_cast<u32>(g) + 1u;
}
}
return 0u;
}
void Figure::unlink_row(u32 row) noexcept {
for (XLink& group : links_) {
for (std::size_t i = 0; i < group.rows.size(); ++i) {
if (group.rows[i] != row) continue;
group.rows.erase(group.rows.begin() + static_cast<std::ptrdiff_t>(i));
group.pushed.erase(group.pushed.begin() + static_cast<std::ptrdiff_t>(i));
break;
}
}
}
Status Figure::link_x(u32 row, u32 group) {
if (row >= plots_.size()) {
return Status{ErrorCode::InvalidArgument, "no such row"};
}
if (group > x_link_count()) {
return Status{ErrorCode::InvalidArgument, "no such x link group"};
}
Plot& p = plots_[row];
if (p.x_axis_count() == 0u) {
return Status{ErrorCode::InvalidArgument,
"the row has no x axis to link: a figure links every row's x axis 0"};
}
// A link group owns the shared x state, including whether it follows. A `Live`
// axis reinterprets the one range the group pushes as *its own* span and offset
// behind its own live edge, so linking one would leave the caller with a state the
// group cannot drive and cannot read -- refused rather than quietly changed.
if (p.x_follow(0u) == XFollow::Live) {
return Status{ErrorCode::InvalidArgument,
"x axis 0 follows live; the link group owns the shared x range and the "
"follow state (set_x_link_follow), so the member's axis must be Off"};
}
unlink_row(row);
if (group == 0u) return Status{};
XLink& g = links_[group - 1u];
const auto at = std::lower_bound(g.rows.begin(), g.rows.end(), row);
const std::size_t index = static_cast<std::size_t>(at - g.rows.begin());
g.rows.insert(at, row);
g.pushed.insert(g.pushed.begin() + static_cast<std::ptrdiff_t>(index), Range{});
if (!g.has_range) {
// The first row into a group is the one that frames it: there is no shared range
// to apply yet, and adopting this row's is what makes the next joiner land on a
// range rather than resetting everyone's.
g.range = p.x_range(0u);
g.span = std::max(0.0, g.range.max - g.range.min);
g.has_range = true;
g.pushed[index] = g.range;
return Status{};
}
p.set_x_range(0u, g.range);
g.pushed[index] = g.range;
return Status{};
}
Status Figure::set_x_link_range(u32 group, Range r) {
if (group == 0u || group > x_link_count()) {
return Status{ErrorCode::InvalidArgument, "no such x link group"};
}
if (!std::isfinite(r.min) || !std::isfinite(r.max)) {
return Status{ErrorCode::InvalidArgument, "an x range must be finite"};
}
XLink& g = links_[group - 1u];
Range live{};
if (g.follow == XFollow::Live && live_edge(g, live)) {
// Under a following group the argument is not a pair of positions: it states the
// span the window should have and, through its maximum, where the window sits
// behind the live edge -- the same reinterpretation `Plot::set_x_range()` makes
// on a `Live` axis, applied to the group's own edge. A span that is not a span is
// refused rather than stored, because a following group has nothing to place a
// window from otherwise.
const f64 lo = std::min(r.min, r.max);
const f64 hi = std::max(r.min, r.max);
if (!(hi > lo)) {
return Status{ErrorCode::InvalidArgument,
"a following group's range states the window's span, so its bounds "
"must differ"};
}
g.span = hi - lo;
g.offset = live.max - hi;
g.has_range = true;
place_live_window(g);
push_link(g);
return Status{};
}
// Not following, or following with nothing streaming to follow: the range is
// stored as given, which is what an `Off` axis does.
g.range = r;
g.span = std::max(0.0, r.max - r.min);
g.has_range = true;
push_link(g);
return Status{};
}
Range Figure::x_link_range(u32 group) const noexcept {
return (group == 0u || group > x_link_count()) ? Range{} : links_[group - 1u].range;
}
Status Figure::set_x_link_follow(u32 group, XFollow follow) {
if (group == 0u || group > x_link_count()) {
return Status{ErrorCode::InvalidArgument, "no such x link group"};
}
XLink& g = links_[group - 1u];
g.follow = follow;
if (follow != XFollow::Live) {
// Disarming leaves the range exactly where it is, as it does on a single axis:
// the last live window becomes an ordinary stored range.
return Status{};
}
// Arming starts from the span the group already holds -- as `Plot::set_x_follow()`
// starts from the span the axis already holds -- and places the window at the live
// edge at once, so x_link_range() reports the window the next layout produces.
g.span = std::max(0.0, g.range.max - g.range.min);
place_live_window(g);
push_link(g);
return Status{};
}
XFollow Figure::x_link_follow(u32 group) const noexcept {
return (group == 0u || group > x_link_count()) ? XFollow::Off : links_[group - 1u].follow;
}
bool Figure::live_edge(const XLink& group, Range& out) const {
bool any = false;
for (const u32 row : group.rows) {
const Plot& p = plots_[row];
for (u32 t = 0; t < p.trace_count(); ++t) {
const Trace& trace = p.trace(t);
// The predicate `Plot::live_x_bounds()` applies to one axis, applied across the
// group: a visible trace on x axis 0 whose series owns a sliding window. Only
// such a series has an edge that moves on its own, so an `ArraySeries` -- whose
// bounds change only when the caller replaces the data -- cannot anchor a
// window that is recomputed every frame.
if (trace.axis_x != 0u || !trace.visible() || !trace.series->streaming()) continue;
const Range b = trace.series->x_bounds();
if (!std::isfinite(b.min) || !std::isfinite(b.max)) continue;
if (!any) {
out = b;
any = true;
continue;
}
// The union of what the members retain, exactly as one axis takes the union of
// its traces': the newest x any member holds is the edge the window is anchored
// to, and the oldest any of them still reaches back to bounds how far it can be
// scrolled away.
out.min = std::min(out.min, b.min);
out.max = std::max(out.max, b.max);
}
}
return any;
}
bool Figure::place_live_window(XLink& group) {
if (group.follow != XFollow::Live) return false;
Range live{};
if (!live_edge(group, live)) return false;
// The span is the group's own -- what the user zoomed to, or what the last range
// it was given stated -- and the follow window only moves it: the span is what
// makes this a scroll rather than a rescale. A span that is not a span is nothing
// to place a window from, so the group's range is left exactly as it is.
if (!std::isfinite(group.span) || !(group.span > 0.0)) return false;
if (!std::isfinite(group.offset)) group.offset = 0.0;
// The offset can outlive the window it names: a ring that dropped samples moves the
// oldest x forward under it, so it is clamped against what the members hold *now*.
// An offset past that would put the window's right edge behind the oldest sample
// left, i.e. over data that is gone.
group.offset = std::clamp(group.offset, 0.0, std::max(0.0, live.max - live.min));
group.range = Range{live.max - group.offset - group.span, live.max - group.offset};
return true;
}
void Figure::push_link(XLink& group) {
for (std::size_t i = 0; i < group.rows.size(); ++i) {
plots_[group.rows[i]].set_x_range(0u, group.range);
group.pushed[i] = group.range;
}
}
bool Figure::adopt_member_changes(XLink& group) {
for (std::size_t i = 0; i < group.rows.size(); ++i) {
const Range now = plots_[group.rows[i]].x_range(0u);
const Range& pushed = group.pushed[i];
if (now.min == pushed.min && now.max == pushed.max) continue;
group.range = now;
group.has_range = true;
return true;
}
return false;
}
void Figure::sync_x_links() {
for (XLink& group : links_) {
if (group.rows.empty()) continue;
const bool adopted = adopt_member_changes(group);
if (group.follow == XFollow::Live) {
// A group that was changed from outside restates its scroll state the way a
// single `Live` axis does -- the new range's span, and the offset its maximum
// implies -- and against the group's own edge, not the edge of the row the
// change arrived on. Then the window is recomputed from the live edge, which is
// the whole of what following is.
if (adopted) {
Range live{};
if (live_edge(group, live)) {
const f64 span = std::max(0.0, group.range.max - group.range.min);
if (span > 0.0) {
group.span = span;
group.offset = live.max - group.range.max;
}
}
}
place_live_window(group);
}
// Pushed after the window was placed, so a member never sees a range this frame's
// recompute is about to replace, and so the pushed value is what the next frame's
// comparison reads: that equality is what makes this terminate.
push_link(group);
}
}
// ----------------------------------------------------------------- geometry
const FigureLayout& Figure::layout() {
const u32 rows = row_count();
layout_.rows.assign(rows, Rect{});
layout_.row_gap = row_gap_;
layout_.x_axis_strip = Rect{};
if (rows == 0u) {
layout_.row_gap = 0.0f;
return 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 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.
f32 gap = row_gap_;
f32 usable = content_h - gap * static_cast<f32>(rows - 1u);
if (usable <= 0.0f) {
gap = 0.0f;
usable = content_h;
}
// ... 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.
usable = std::max(0.0f, usable);
// The floor, then the weights' share of what is left. Giving every row the floor
// first is what makes a weight a *share of the leftover* rather than a height: a
// row can never be squeezed out of the figure by another row's weight, and the
// heights still sum to exactly the usable height, so the rows tile the figure and
// the last one ends on its bottom edge.
const bool equal = row_weights_.empty();
const f32 floor_px = std::min(kMinRowHeightPx, usable / static_cast<f32>(rows));
const f32 extra = usable - floor_px * static_cast<f32>(rows);
f32 total_w = 0.0f;
for (const f32 w : row_weights_) total_w += w;
const auto share_of = [&](u32 row) {
return equal ? 1.0f / static_cast<f32>(rows) : row_weights_[row] / total_w;
};
f32 y = viewport_.y;
f32 consumed = 0.0f;
for (u32 row = 0; row < rows; ++row) {
// The last row closes the figure exactly, absorbing the rounding of the ones
// above it, so a tiled figure has no seam and no row past its bottom edge.
const f32 end = row + 1u == rows ? usable : consumed + floor_px + extra * share_of(row);
layout_.rows[row] = Rect{viewport_.x, y, viewport_.w, end - consumed};
consumed = end;
y = layout_.rows[row].bottom() + gap;
}
layout_.row_gap = gap;
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_);
}
// 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
// out below, so a following group's window is the range they lay out with.
sync_x_links();
// Every row is laid out against its own rect here, so after this call each row's
// chrome is current: `plot(i).layout()` reads back what the figure computed rather
// than what the row last held. That is the contract `Renderer::draw(Figure&)`
// relies on -- it draws each row with the layout this produced, and `draw(Plot&)`
// 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};
return layout_;
}
} // namespace plot
+12 -2
View File
@@ -661,6 +661,12 @@ 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;
// 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
@@ -670,7 +676,7 @@ 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 = titled_x_axis_count();
const u32 x_titles = x_label_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
@@ -710,8 +716,12 @@ const PlotLayout& Plot::layout() {
// The same caps as above, so this compares like with like and the loop still
// reaches its fixed point on the second pass.
const f32 need_y = std::min(std::clamp(y_label_width(), 0.0f, viewport_.w), y_budget);
// 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 =
std::min(std::clamp(text_->height() + 2.0f * pad, 0.0f, viewport_.h), x_budget);
x_label_strip_
? 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;