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
|
||||
+12
-2
@@ -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;
|
||||
|
||||
+88
-19
@@ -288,11 +288,7 @@ void Renderer::begin_frame(u32 fb_w, u32 fb_h, Rect viewport_px) noexcept {
|
||||
gl::glBlendFunc(gl::GL_SRC_ALPHA, gl::GL_ONE_MINUS_SRC_ALPHA);
|
||||
}
|
||||
|
||||
void Renderer::draw(Plot& plot) {
|
||||
stats_ = FrameStats{};
|
||||
if (!ready_) {
|
||||
return;
|
||||
}
|
||||
bool Renderer::begin_frame_text() noexcept {
|
||||
// The plot reserves its label strips from TextBackend metrics, so it must
|
||||
// measure with the size the text pipeline draws with, and re-read the style
|
||||
// each frame so a changed axis_font_px takes effect on the next one.
|
||||
@@ -308,9 +304,10 @@ void Renderer::draw(Plot& plot) {
|
||||
// never changes costs a comparison a frame. A driver that refuses the re-upload
|
||||
// is reported the way the stores report a refused fence: the frame draws on (it
|
||||
// has the previous atlas), and `last_frame_had_gl_error()` says so.
|
||||
if (!atlas_.sync(font_backend_)) {
|
||||
gl_error_ = true;
|
||||
}
|
||||
return atlas_.sync(font_backend_);
|
||||
}
|
||||
|
||||
void Renderer::prepare_plot(Plot& plot) {
|
||||
#ifndef NDEBUG
|
||||
// The renderer owns the text of the frames it draws: every label goes through
|
||||
// the text pipeline in `default_font_`, so the plot is handed this backend's metrics
|
||||
@@ -332,17 +329,78 @@ void Renderer::draw(Plot& plot) {
|
||||
// propagation point the contract names, and it is what keeps the strips the
|
||||
// layout reserves and the strokes drawn into them at one scale.
|
||||
plot.set_ui_scale(style_.ui_scale);
|
||||
// One layout for the whole frame: autoscale, the strip fixed-point loop with
|
||||
// its tick rebuilds, and the grid build all run in here, and every helper
|
||||
// below reads the strips, the axes rect and the transforms it pins.
|
||||
const PlotLayout& layout = plot.layout();
|
||||
}
|
||||
|
||||
void Renderer::draw(Plot& plot) {
|
||||
stats_ = FrameStats{};
|
||||
if (!ready_) {
|
||||
return;
|
||||
}
|
||||
if (!begin_frame_text()) {
|
||||
gl_error_ = true;
|
||||
}
|
||||
prepare_plot(plot);
|
||||
// The plot's one layout for the frame: autoscale, the strip fixed-point loop with
|
||||
// its tick rebuilds, and the grid build all run in here, and every helper below
|
||||
// reads the strips, the axes rect and the transforms it pins.
|
||||
draw_plot(plot, plot.layout());
|
||||
// The frame's single text flush, after every line batch of the frame: text is
|
||||
// texture-bound, so it is one draw call whatever it holds (see draw_plot).
|
||||
flush_text();
|
||||
}
|
||||
|
||||
void Renderer::draw(Figure& figure) {
|
||||
stats_ = FrameStats{};
|
||||
if (!ready_) {
|
||||
return;
|
||||
}
|
||||
if (!begin_frame_text()) {
|
||||
gl_error_ = true;
|
||||
}
|
||||
// Every row is prepared before the figure lays itself out, because a row's strips
|
||||
// are measured from the metrics this installs: a row measuring with one backend and
|
||||
// drawing with another is the defect the propagation point exists to prevent.
|
||||
const u32 rows = figure.row_count();
|
||||
for (u32 row = 0; row < rows; ++row) prepare_plot(figure.plot(row));
|
||||
|
||||
// The figure's one layout for the frame. It hands every row its rect and its
|
||||
// x-label policy, advances the link groups, and lays every row out against its own
|
||||
// rect -- which is why the rows below are drawn with the layout it produced rather
|
||||
// than laying each row out a second time. Nothing draws before this: a row's
|
||||
// viewport is the figure's to set.
|
||||
const FigureLayout& layout = figure.layout();
|
||||
for (u32 row = 0; row < rows; ++row) {
|
||||
Plot& plot = figure.plot(row);
|
||||
// The layout the figure just computed, read through the non-recomputing
|
||||
// overload -- the one layout of this frame, not a second derivation of it.
|
||||
draw_plot(plot, static_cast<const Plot&>(plot).layout());
|
||||
}
|
||||
|
||||
// The figure's one shared x axis, staged last so it joins the frame's single text
|
||||
// flush: the labels of the last row's x axes, in the strip the figure measured
|
||||
// under it. The rows reserved no strip of their own for them (see
|
||||
// `Plot::set_x_label_strip()`), which is what keeps one set of labels from being
|
||||
// drawn once per row; with the figure set to label every row, there is no shared
|
||||
// strip and each row drew its own above.
|
||||
if (rows != 0u && figure.x_labels_only_on_bottom() && layout.x_axis_strip.h > 0.0f) {
|
||||
draw_x_tick_labels(figure.plot(rows - 1u), layout.x_axis_strip);
|
||||
}
|
||||
flush_text();
|
||||
}
|
||||
|
||||
void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) {
|
||||
|
||||
// Draw order: chrome, then data, then the overlays over it, and the text last (it
|
||||
// is texture-bound, so it is one draw call after every line batch). The legend
|
||||
// closes the line batches: its panel is a backplate, so it is drawn over
|
||||
// everything geometric -- a legend under a trace is a legend nobody can read --
|
||||
// while its names join the frame's single text flush with every other label, which
|
||||
// is what keeps the two-pipeline order the whole frame is built on.
|
||||
// while its names wait for the frame's single text flush with every other label,
|
||||
// which is what keeps the two-pipeline order the whole frame is built on.
|
||||
//
|
||||
// The flush itself belongs to the frame, not to one plot: `draw(Plot&)` flushes
|
||||
// after this returns, and `draw(Figure&)` after it has drawn every row and the
|
||||
// figure's shared x axis, so a figure is one text draw call rather than one per
|
||||
// row -- and the shared axis' labels join the same batch as the labels above them.
|
||||
draw_grid(plot);
|
||||
draw_axis_lines(plot);
|
||||
draw_tick_labels(plot, layout);
|
||||
@@ -355,7 +413,6 @@ void Renderer::draw(Plot& plot) {
|
||||
draw_measures(plot, layout);
|
||||
draw_annotations(plot);
|
||||
draw_legend(plot, layout);
|
||||
flush_text();
|
||||
}
|
||||
|
||||
void Renderer::end_frame() noexcept {
|
||||
@@ -597,20 +654,32 @@ void Renderer::draw_axis_lines(const Plot& plot) {
|
||||
}
|
||||
}
|
||||
|
||||
void Renderer::draw_tick_labels(const Plot& plot, const PlotLayout& layout) {
|
||||
void Renderer::draw_x_tick_labels(const Plot& plot, const Rect& strip) {
|
||||
// Centred under their tick, on the strip's single text line -- the one rule for a
|
||||
// plot's own label strip and for the strip a figure shares out under its last row.
|
||||
// A plot that reserved no strip of its own hands its labels to the figure, which
|
||||
// is why this takes the rect rather than reading the layout it came from.
|
||||
const f32 line_h = font_backend_.height();
|
||||
|
||||
// X labels: centred under their tick, on the strip's single text line.
|
||||
for (u32 i = 0; i < plot.x_axis_count(); ++i) {
|
||||
const Transform transform = plot.x_transform(i);
|
||||
for (const Tick& tick : plot.x_axis(i).ticks) {
|
||||
if (!tick.major || tick.label.empty()) continue;
|
||||
const f32 width = font_backend_.measure(tick.label.c_str());
|
||||
stage_text({static_cast<f32>(transform.to_screen_x(tick.value)) - 0.5f * width,
|
||||
layout.x_label_area.y + 0.5f * (layout.x_label_area.h - line_h)},
|
||||
strip.y + 0.5f * (strip.h - line_h)},
|
||||
tick.label.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Renderer::draw_tick_labels(const Plot& plot, const PlotLayout& layout) {
|
||||
const f32 line_h = font_backend_.height();
|
||||
|
||||
// X labels: unless the plot gave its x label strip away -- a row of a `Figure`
|
||||
// whose figure drew one shared axis instead -- in which case it has no strip to
|
||||
// put them in and their absence is the point. A plot with no strip would otherwise
|
||||
// stage its labels just above `axes`' bottom edge, i.e. over its own data.
|
||||
if (plot.x_label_strip()) draw_x_tick_labels(plot, layout.x_label_area);
|
||||
|
||||
// Y labels: right-aligned in their strip, centred on the tick and pulled back
|
||||
// inside the strip at the two extremes (a label centred on the top tick would
|
||||
|
||||
Reference in new issue
Block a user