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

+88 -19
View File
@@ -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