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
@@ -11,6 +11,7 @@
|
||||
#include <plot/data/array_series.hpp>
|
||||
#include <plot/render/buffer.hpp>
|
||||
#include <plot/render/detail/ring_stats.hpp>
|
||||
#include <plot/plot/figure.hpp>
|
||||
#include <plot/render/renderer.hpp>
|
||||
#include <plot/input/controller.hpp>
|
||||
|
||||
@@ -454,6 +455,22 @@ std::vector<plot::u8> read_frame(plottest::HeadlessContext& ctx) {
|
||||
return frame;
|
||||
}
|
||||
|
||||
/// The pixels of an already-read frame that hold `text`-coloured ink, anti-aliased
|
||||
/// fringe and all (see `text_ink`). `predicate_pixels` takes a predicate and not a
|
||||
/// colour, so a count that has to name *one* colour reads the frame this way -- and
|
||||
/// reading the frame once is what makes counting it twice cheap enough to compare two
|
||||
/// whole frames.
|
||||
plot::u32 frame_text_ink(const std::vector<plot::u8>& frame, plot::u32 fb_w, plot::u32 fb_h,
|
||||
plot::Color text) {
|
||||
plot::u32 count = 0;
|
||||
for (plot::u32 y = 0; y < fb_h; ++y) {
|
||||
for (plot::u32 x = 0; x < fb_w; ++x) {
|
||||
if (text_ink(&frame[(plot::usize(y) * fb_w + x) * 4u], text)) ++count;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/// Holds one GL entry point at a stub for as long as it lives. The entry points
|
||||
/// are the library's own function pointers (`plot/gl/api.hpp`), so a driver
|
||||
/// refusal -- a map or a fence that fails while the caps still advertise support
|
||||
@@ -3430,3 +3447,280 @@ PLOT_TEST(grabbed_handle_is_highlighted) {
|
||||
|
||||
ctx.destroy();
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
/// Two traces whose colours no other element of these frames produces, each with a
|
||||
/// predicate that refuses the other's: the top row's is blue (`trace_pixel`), the
|
||||
/// second row's magenta (`magenta_pixel`). One figure's rows can then be told apart by
|
||||
/// their pixels, which is what makes "the rows hold different content" a statement
|
||||
/// about the rows and not about the frame.
|
||||
const plot::Color kRowBlue = plot::Color::hex(0x40C0FF);
|
||||
const plot::Color kRowMagenta = plot::Color::hex(0xFF00FF);
|
||||
|
||||
/// A sampled wave, one sample per x unit, so a row's trace crosses every column of its
|
||||
/// axes exactly once: every coverage statement below is then about the row it names.
|
||||
std::vector<plot::f32> wave(plot::u32 n, plot::f32 phase) {
|
||||
std::vector<plot::f32> y(n);
|
||||
for (plot::u32 i = 0; i < n; ++i) {
|
||||
y[i] = 0.8f * std::sin(phase + 0.05f * static_cast<plot::f32>(i));
|
||||
}
|
||||
return y;
|
||||
}
|
||||
|
||||
/// Gives one figure row the axes and the trace these cases draw: an x axis spanning
|
||||
/// the series and a fixed y axis, so the geometry a row is drawn with is the geometry
|
||||
/// the case measured.
|
||||
void add_row_trace(plot::Plot& p, const plot::data::Series& s, plot::Color color) {
|
||||
const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), false, plot::AxisPlace::Bottom, false});
|
||||
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||
p.add_trace({&s, color, 1.5f, ax, ay});
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
/// A figure's rows are drawn into their own rects, so what one row renders never
|
||||
/// reaches another's: two rows carrying traces of two colours put each colour in its
|
||||
/// own row's rect and in no other, which is the whole point of stacking plots rather
|
||||
/// than drawing one.
|
||||
PLOT_TEST(two_rows_render_distinct_content) {
|
||||
plottest::HeadlessContext ctx(kW, kH);
|
||||
plot::Status st;
|
||||
PLOT_CHECK(ctx.create(&st));
|
||||
PLOT_CHECK(plot::gl::load(nullptr));
|
||||
plot::Renderer r;
|
||||
PLOT_CHECK(r.init(&st));
|
||||
|
||||
const std::vector<plot::f32> top = wave(640u, 0.0f);
|
||||
const std::vector<plot::f32> bottom = wave(640u, 1.0f);
|
||||
plot::data::ArraySeries top_series(top, 0.0, 1.0);
|
||||
plot::data::ArraySeries bottom_series(bottom, 0.0, 1.0);
|
||||
|
||||
plot::Figure figure(2u);
|
||||
figure.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
add_row_trace(figure.plot(0u), top_series, kRowBlue);
|
||||
add_row_trace(figure.plot(1u), bottom_series, kRowMagenta);
|
||||
|
||||
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
r.draw(figure);
|
||||
r.end_frame();
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
dump_if_requested(r, "figure_two_rows.ppm");
|
||||
|
||||
const std::vector<plot::u8> frame = read_frame(ctx);
|
||||
const plot::FigureLayout layout = figure.layout();
|
||||
PLOT_CHECK(layout.rows.size() == 2u);
|
||||
const plot::Rect& row0 = layout.rows[0];
|
||||
const plot::Rect& row1 = layout.rows[1];
|
||||
PLOT_CHECK(row0.h > 0.0f && row1.h > 0.0f);
|
||||
|
||||
// Each row's own colour is present in its own rect...
|
||||
PLOT_CHECK(predicate_pixels(frame, kW, row0, trace_pixel) > 0u);
|
||||
PLOT_CHECK(predicate_pixels(frame, kW, row1, magenta_pixel) > 0u);
|
||||
// ... and each row's colour is absent from the other's, which is what "the rows
|
||||
// hold distinct content" means: a frame drawn as one plot, or one row drawn over
|
||||
// the other, fails here whichever way it mixes them.
|
||||
PLOT_CHECK(predicate_pixels(frame, kW, row1, trace_pixel) == 0u);
|
||||
PLOT_CHECK(predicate_pixels(frame, kW, row0, magenta_pixel) == 0u);
|
||||
// The frame holds exactly the two rows' traces: nothing of either colour was drawn
|
||||
// outside the rect the row owns.
|
||||
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, trace_pixel) ==
|
||||
predicate_pixels(frame, kW, row0, trace_pixel));
|
||||
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, magenta_pixel) ==
|
||||
predicate_pixels(frame, kW, row1, magenta_pixel));
|
||||
|
||||
// Each trace is drawn inside its row's *axes*, not merely somewhere in the row: the
|
||||
// row's y strip is the other half of the rect and holds no data.
|
||||
const plot::PlotLayout& layout0 = figure.plot(0u).layout();
|
||||
const plot::PlotLayout& layout1 = figure.plot(1u).layout();
|
||||
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout0.axes.h), static_cast<plot::f64>(row0.h), 1e-3);
|
||||
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout1.axes.h), static_cast<plot::f64>(row1.h), 1e-3);
|
||||
PLOT_CHECK(predicate_pixels(frame, kW, layout0.axes, trace_pixel) > 0u);
|
||||
PLOT_CHECK(predicate_pixels(frame, kW, layout1.axes, magenta_pixel) > 0u);
|
||||
// The rows are stacked, not overlapped: the second starts below the first.
|
||||
PLOT_CHECK(row1.y >= row0.bottom() - 1e-3f);
|
||||
|
||||
ctx.destroy();
|
||||
}
|
||||
|
||||
/// The figure draws *one* x axis. With labels on the bottom only -- the default --
|
||||
/// the rows reserve no x label strip of their own and their labels appear once, in
|
||||
/// the strip the figure measured under the last row; the interior row's labels
|
||||
/// are not in the frame at all. Switching the figure to label every row puts them in
|
||||
/// every row, so the difference between the two frames is exactly the ink the shared
|
||||
/// axis exists to prevent -- which is what makes the count below a statement rather
|
||||
/// than a scan of somewhere nothing is ever drawn.
|
||||
PLOT_TEST(shared_x_axis_is_drawn_once) {
|
||||
plottest::HeadlessContext ctx(kW, kH);
|
||||
plot::Status st;
|
||||
PLOT_CHECK(ctx.create(&st));
|
||||
PLOT_CHECK(plot::gl::load(nullptr));
|
||||
plot::Renderer r;
|
||||
PLOT_CHECK(r.init(&st));
|
||||
|
||||
const std::vector<plot::f32> samples = wave(640u, 0.0f);
|
||||
plot::data::ArraySeries series(samples, 0.0, 1.0);
|
||||
|
||||
plot::Figure figure(3u);
|
||||
figure.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
for (plot::u32 row = 0; row < 3u; ++row) add_row_trace(figure.plot(row), series, kRowBlue);
|
||||
PLOT_CHECK(figure.x_labels_only_on_bottom());
|
||||
|
||||
const auto draw_frame = [&] {
|
||||
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
r.draw(figure);
|
||||
r.end_frame();
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
return read_frame(ctx);
|
||||
};
|
||||
|
||||
const plot::Color ink = r.style().axis_text;
|
||||
|
||||
// --- labels on the bottom only: one set, and it is the figure's -----------------
|
||||
const std::vector<plot::u8> shared = draw_frame();
|
||||
dump_if_requested(r, "figure_shared_x.ppm");
|
||||
const plot::FigureLayout layout = figure.layout();
|
||||
PLOT_CHECK(layout.rows.size() == 3u);
|
||||
PLOT_CHECK(layout.x_axis_strip.h > 0.0f);
|
||||
// The strip is the figure's, under the last row: the rows end where it starts, and
|
||||
// it ends on the figure's bottom edge.
|
||||
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout.rows.back().bottom()),
|
||||
static_cast<plot::f64>(layout.x_axis_strip.y), 1e-3);
|
||||
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout.x_axis_strip.bottom()),
|
||||
static_cast<plot::f64>(kH), 1e-3);
|
||||
const plot::u32 strip_ink = scan_rect_ink(ctx, layout.x_axis_strip, ink).pixels;
|
||||
PLOT_CHECK(strip_ink > 0u);
|
||||
|
||||
// Every row gave its x label strip away: a row's axes reaches its rect's bottom edge
|
||||
// and the row has no band inside itself for a second set of labels to be drawn in.
|
||||
for (plot::u32 row = 0; row < 3u; ++row) {
|
||||
const plot::PlotLayout& row_layout = figure.plot(row).layout();
|
||||
PLOT_CHECK(!figure.plot(row).x_label_strip());
|
||||
PLOT_CHECK_NEAR(static_cast<plot::f64>(row_layout.x_axis_height), 0.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(static_cast<plot::f64>(row_layout.x_label_area.h), 0.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(static_cast<plot::f64>(row_layout.axes.bottom()),
|
||||
static_cast<plot::f64>(layout.rows[row].bottom()), 1e-3);
|
||||
}
|
||||
// ... and the band a second copy of them *would* land in holds none. A row that
|
||||
// kept no strip of its own would have its x labels staged against the top of the
|
||||
// zero-height strip it does have, i.e. centred on its `axes`' bottom edge, so the
|
||||
// band is the figure's strip height at the bottom of the row. Two insets keep it to
|
||||
// labels and nothing else: four columns past `axes.x` clear the row's y axis line
|
||||
// (the vertical edge `text_ink`, which is deliberately blind to one chalk tone or
|
||||
// the other, happily accepts), and two rows above the row's bottom edge clear the
|
||||
// row's own x axis line.
|
||||
for (plot::u32 row = 0; row + 1u < 3u; ++row) {
|
||||
const plot::PlotLayout& row_layout = figure.plot(row).layout();
|
||||
const plot::Rect band{row_layout.axes.x + 4.0f,
|
||||
layout.rows[row].bottom() - layout.x_axis_strip.h,
|
||||
row_layout.axes.w - 8.0f, layout.x_axis_strip.h - 2.0f};
|
||||
PLOT_CHECK(band.h > 0.0f);
|
||||
PLOT_CHECK(scan_rect_ink(ctx, band, ink).pixels == 0u);
|
||||
}
|
||||
const plot::u32 shared_ink = frame_text_ink(shared, kW, kH, ink);
|
||||
|
||||
// --- every row labels itself: the same labels, in three places -----------------
|
||||
figure.set_x_labels_only_on_bottom(false);
|
||||
const std::vector<plot::u8> per_row = draw_frame();
|
||||
const plot::FigureLayout per_row_layout = figure.layout();
|
||||
PLOT_CHECK(per_row_layout.x_axis_strip.h == 0.0f); // no shared strip any more
|
||||
PLOT_CHECK(per_row_layout.rows.size() == 3u);
|
||||
for (plot::u32 row = 0; row < 3u; ++row) {
|
||||
const plot::PlotLayout& own = figure.plot(row).layout();
|
||||
PLOT_CHECK(figure.plot(row).x_label_strip());
|
||||
PLOT_CHECK(own.x_label_area.h > 0.0f);
|
||||
// A row's own labels are in its own strip, inside its own rect.
|
||||
PLOT_CHECK(own.x_label_area.y >= per_row_layout.rows[row].y);
|
||||
PLOT_CHECK(own.x_label_area.bottom() <= per_row_layout.rows[row].bottom() + 1e-3f);
|
||||
PLOT_CHECK(scan_rect_ink(ctx, own.x_label_area, ink).pixels > 0u);
|
||||
}
|
||||
const plot::u32 per_row_ink = frame_text_ink(per_row, kW, kH, ink);
|
||||
|
||||
// The two interior row's labels are the difference -- they are what the shared axis
|
||||
// replaced, and in the default frame they are simply not there. The y labels, the
|
||||
// chrome and the traces are in both frames, and the shared strip's labels are one
|
||||
// row's worth, so a frame that drew the x labels twice per row cannot land here.
|
||||
PLOT_CHECK(per_row_ink > shared_ink);
|
||||
PLOT_CHECK(per_row_ink - shared_ink >= strip_ink / 2u);
|
||||
|
||||
ctx.destroy();
|
||||
}
|
||||
|
||||
/// A resize recomputes the layout: the rows are re-derived from the new viewport --
|
||||
/// their rects, the shared strip, the axes and the transforms they pin -- rather than
|
||||
/// rescaled from, or left at, the rects of the frame before. The second frame here is
|
||||
/// taller than the first, so a stale layout would paint the rows at the old
|
||||
/// coordinates and a scaled one would put them somewhere between the two; the pixels
|
||||
/// say which happened.
|
||||
PLOT_TEST(a_row_resize_recomputes_rather_than_rescaling_a_stale_layout) {
|
||||
plottest::HeadlessContext ctx(kW, kH);
|
||||
plot::Status st;
|
||||
PLOT_CHECK(ctx.create(&st));
|
||||
PLOT_CHECK(plot::gl::load(nullptr));
|
||||
plot::Renderer r;
|
||||
PLOT_CHECK(r.init(&st));
|
||||
|
||||
const std::vector<plot::f32> top = wave(640u, 0.0f);
|
||||
const std::vector<plot::f32> bottom = wave(640u, 1.0f);
|
||||
plot::data::ArraySeries top_series(top, 0.0, 1.0);
|
||||
plot::data::ArraySeries bottom_series(bottom, 0.0, 1.0);
|
||||
|
||||
plot::Figure figure(2u);
|
||||
add_row_trace(figure.plot(0u), top_series, kRowBlue);
|
||||
add_row_trace(figure.plot(1u), bottom_series, kRowMagenta);
|
||||
|
||||
// First frame: half the height, so the rows are short and the second row of the
|
||||
// taller frame below is nowhere near where this one puts it.
|
||||
const plot::f32 short_h = 0.5f * static_cast<plot::f32>(kH);
|
||||
figure.set_viewport({0, 0, static_cast<plot::f32>(kW), short_h});
|
||||
r.begin_frame(kW, kH, figure.viewport());
|
||||
r.draw(figure);
|
||||
r.end_frame();
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
const plot::FigureLayout short_layout = figure.layout();
|
||||
PLOT_CHECK(short_layout.rows.size() == 2u);
|
||||
|
||||
// Second frame: the whole framebuffer.
|
||||
figure.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
r.begin_frame(kW, kH, figure.viewport());
|
||||
r.draw(figure);
|
||||
r.end_frame();
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
dump_if_requested(r, "figure_resized.ppm");
|
||||
|
||||
const std::vector<plot::u8> frame = read_frame(ctx);
|
||||
const plot::FigureLayout layout = figure.layout();
|
||||
PLOT_CHECK(layout.rows.size() == 2u);
|
||||
const plot::Rect& row1 = layout.rows[1];
|
||||
|
||||
// The rows are the *new* figure's: twice the first frame's height, to the pixel,
|
||||
// and the second row reaches the shared strip rather than stopping where the short
|
||||
// frame's strip was.
|
||||
PLOT_CHECK(row1.h > 1.5f * short_layout.rows[1].h);
|
||||
PLOT_CHECK_NEAR(static_cast<plot::f64>(row1.bottom()),
|
||||
static_cast<plot::f64>(layout.x_axis_strip.y), 1e-3);
|
||||
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout.x_axis_strip.bottom()),
|
||||
static_cast<plot::f64>(kH), 1e-3);
|
||||
PLOT_CHECK(row1.y > short_layout.rows[1].y);
|
||||
|
||||
// The transforms the frame drew with are the new rects': the second row's axes
|
||||
// spans its rect exactly, and its trace is in there -- not at the old coordinates.
|
||||
const plot::PlotLayout& row1_layout = figure.plot(1u).layout();
|
||||
PLOT_CHECK_NEAR(static_cast<plot::f64>(row1_layout.axes.y), static_cast<plot::f64>(row1.y), 1e-3);
|
||||
PLOT_CHECK_NEAR(static_cast<plot::f64>(row1_layout.axes.bottom()),
|
||||
static_cast<plot::f64>(row1.bottom()), 1e-3);
|
||||
PLOT_CHECK(predicate_pixels(frame, kW, row1_layout.axes, magenta_pixel) > 0u);
|
||||
// Nothing of the second row's trace is above it, where the short frame's row 1 was:
|
||||
// a stale layout would have left the magenta trace up there.
|
||||
PLOT_CHECK(predicate_pixels(frame, kW, plot::Rect{0.0f, 0.0f, static_cast<plot::f32>(kW), row1.y},
|
||||
magenta_pixel) == 0u);
|
||||
// And the top row is still the top row: its own trace is above the second row and
|
||||
// nowhere below it.
|
||||
PLOT_CHECK(predicate_pixels(frame, kW, layout.rows[0], trace_pixel) > 0u);
|
||||
PLOT_CHECK(predicate_pixels(frame, kW,
|
||||
plot::Rect{0.0f, row1.y, static_cast<plot::f32>(kW),
|
||||
static_cast<plot::f32>(kH) - row1.y},
|
||||
trace_pixel) == 0u);
|
||||
|
||||
ctx.destroy();
|
||||
}
|
||||
Reference in new issue
Block a user