feat(plot): Figure with stacked rows and a synchronised x axis
This commit is contained in:
1 parent
7cc06f07f5
commit
8f1e60a105
11 files changed
+1665
-24
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user