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
@@ -35,6 +35,7 @@ add_executable(plot_tests
|
||||
test_ticks.cpp
|
||||
test_measure.cpp
|
||||
test_plot.cpp
|
||||
test_figure.cpp
|
||||
test_render_smoke.cpp
|
||||
test_controller.cpp
|
||||
egl_headless.cpp
|
||||
@@ -134,9 +135,11 @@ file(GENERATE OUTPUT "${plot_umbrella_check_source}" CONTENT
|
||||
|
||||
int main() {
|
||||
plot::Plot plot;
|
||||
plot::Figure figure(2);
|
||||
plot::Renderer renderer;
|
||||
plot::data::StreamSeries series(16, 0.0, 1.0);
|
||||
plot::input::Controller controller(plot);
|
||||
(void)figure;
|
||||
(void)renderer;
|
||||
(void)series;
|
||||
(void)controller;
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
// `Figure`: several plots stacked in one column, sharing one x range.
|
||||
//
|
||||
// The layout cases pin the geometry the renderer draws with -- the row rects, the
|
||||
// weights, the floor and the shared strip -- and the link cases pin the propagation
|
||||
// contract: one range for a whole group, applied to every member in one pass, and a
|
||||
// group that follows deriving one window from the newest sample across all of its
|
||||
// members rather than one per row.
|
||||
#include "harness.hpp"
|
||||
|
||||
#include <plot/data/array_series.hpp>
|
||||
#include <plot/data/stream_series.hpp>
|
||||
#include <plot/plot/figure.hpp>
|
||||
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr plot::f32 kW = 640.0f;
|
||||
constexpr plot::f32 kH = 480.0f;
|
||||
|
||||
plot::Rect whole_figure() { return plot::Rect{0.0f, 0.0f, kW, kH}; }
|
||||
|
||||
/// A figure whose every row has a y axis and an x axis, so a row is a plot in the
|
||||
/// shape the layout tests care about: it loses width to a y strip and height to
|
||||
/// nothing else (the x strip belongs to the figure under `labels_bottom_only`).
|
||||
plot::Figure figure_with_axes(plot::u32 rows) {
|
||||
plot::Figure figure(rows);
|
||||
figure.set_viewport(whole_figure());
|
||||
for (plot::u32 row = 0; row < rows; ++row) {
|
||||
plot::Plot& p = figure.plot(row);
|
||||
p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
|
||||
p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||
}
|
||||
return figure;
|
||||
}
|
||||
|
||||
/// The rows of a laid-out figure, so a case can assert on them without repeating the
|
||||
/// call that produces them.
|
||||
std::vector<plot::Rect> rows_of(plot::Figure& figure) {
|
||||
return figure.layout().rows;
|
||||
}
|
||||
|
||||
bool rows_overlap(const plot::Rect& a, const plot::Rect& b) {
|
||||
return a.x < b.right() && b.x < a.right() && a.y < b.bottom() && b.y < a.bottom();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ------------------------------------------------------------------- layout
|
||||
|
||||
/// The rows tile the figure: they cover it from top to bottom without a gap and
|
||||
/// without two of them overlapping, which is what makes the shared strip's position
|
||||
/// -- and every row's axes -- a statement rather than a hope.
|
||||
PLOT_TEST(row_rects_tile_the_figure_without_overlap) {
|
||||
plot::Figure figure = figure_with_axes(3u);
|
||||
const std::vector<plot::Rect> rows = rows_of(figure);
|
||||
PLOT_CHECK(rows.size() == 3u);
|
||||
|
||||
// The figure's width, straight through: a row's own y strips are inside its rect,
|
||||
// not carved out of the figure.
|
||||
for (const plot::Rect& row : rows) {
|
||||
PLOT_CHECK_NEAR(static_cast<plot::f64>(row.x), 0.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(static_cast<plot::f64>(row.w), static_cast<plot::f64>(kW), 1e-6);
|
||||
PLOT_CHECK(row.h > 0.0f);
|
||||
}
|
||||
// Top to bottom, in row order, touching: each row starts where the one above it
|
||||
// ended, and the last one ends on the rows' region's bottom edge (the figure's
|
||||
// height less the shared strip beneath it).
|
||||
const plot::FigureLayout layout = figure.layout();
|
||||
PLOT_CHECK_NEAR(static_cast<plot::f64>(rows[0].y), 0.0, 1e-6);
|
||||
for (std::size_t i = 1; i < rows.size(); ++i) {
|
||||
PLOT_CHECK_NEAR(static_cast<plot::f64>(rows[i].y), static_cast<plot::f64>(rows[i - 1].bottom()),
|
||||
1e-6);
|
||||
}
|
||||
PLOT_CHECK_NEAR(static_cast<plot::f64>(rows.back().bottom()),
|
||||
static_cast<plot::f64>(layout.x_axis_strip.y), 1e-6);
|
||||
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout.x_axis_strip.bottom()), static_cast<plot::f64>(kH),
|
||||
1e-6);
|
||||
// ... and the strip shares the figure's width with the rows above it.
|
||||
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout.x_axis_strip.w), static_cast<plot::f64>(kW), 1e-6);
|
||||
PLOT_CHECK(layout.x_axis_strip.h > 0.0f);
|
||||
|
||||
for (std::size_t i = 0; i < rows.size(); ++i) {
|
||||
for (std::size_t j = i + 1; j < rows.size(); ++j) {
|
||||
PLOT_CHECK(!rows_overlap(rows[i], rows[j]));
|
||||
}
|
||||
}
|
||||
|
||||
// A gap keeps the rows apart by exactly that much and is not overlap by another
|
||||
// name: the rows cover the region between the strip and the top, minus the gaps.
|
||||
figure.set_row_gap(10.0f);
|
||||
const std::vector<plot::Rect> spaced = rows_of(figure);
|
||||
PLOT_CHECK(spaced.size() == 3u);
|
||||
PLOT_CHECK_NEAR(static_cast<plot::f64>(spaced[1].y - spaced[0].bottom()), 10.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(static_cast<plot::f64>(spaced[2].y - spaced[1].bottom()), 10.0, 1e-6);
|
||||
PLOT_CHECK(spaced[0].h < rows[0].h); // the gaps came out of the rows
|
||||
PLOT_CHECK_NEAR(static_cast<plot::f64>(spaced.back().bottom()),
|
||||
static_cast<plot::f64>(figure.layout().x_axis_strip.y), 1e-6);
|
||||
|
||||
// A figure with no rows has no geometry -- and no row to reach for while computing
|
||||
// it, which is the case that would read past the end of its own plot list.
|
||||
plot::Figure empty(0u);
|
||||
empty.set_viewport(whole_figure());
|
||||
PLOT_CHECK(empty.row_count() == 0u);
|
||||
PLOT_CHECK(empty.layout().rows.empty());
|
||||
PLOT_CHECK(empty.layout().x_axis_strip.h == 0.0f);
|
||||
PLOT_CHECK(empty.layout().row_gap == 0.0f);
|
||||
}
|
||||
|
||||
/// Weights change how the figure's height is divided -- a taller main plot, shorter
|
||||
/// rows under it -- and the rows still account for all of it: the sum of the heights
|
||||
/// plus the gaps is the figure less the shared strip, so no weight can leave a band
|
||||
/// unclaimed or push a row past the bottom.
|
||||
PLOT_TEST(row_weights_change_relative_heights_and_sum_to_the_viewport) {
|
||||
plot::Figure figure = figure_with_axes(3u);
|
||||
const std::vector<plot::Rect> equal = rows_of(figure);
|
||||
PLOT_CHECK_NEAR(static_cast<plot::f64>(equal[0].h), static_cast<plot::f64>(equal[1].h), 1e-4);
|
||||
PLOT_CHECK_NEAR(static_cast<plot::f64>(equal[1].h), static_cast<plot::f64>(equal[2].h), 1e-4);
|
||||
|
||||
const float main_taller[] = {3.0f, 1.0f, 1.0f};
|
||||
plot::Status st = figure.set_row_weights(main_taller);
|
||||
PLOT_CHECK(st);
|
||||
PLOT_CHECK(figure.has_row_weights());
|
||||
// By value, not a reference into the figure: a later `layout()` rewrites the
|
||||
// figure's own vectors in place, and a case that kept pointing at them would
|
||||
// compare a division with itself.
|
||||
const plot::FigureLayout layout = figure.layout();
|
||||
const std::vector<plot::Rect> rows = layout.rows;
|
||||
PLOT_CHECK(rows[0].h > rows[1].h);
|
||||
PLOT_CHECK_NEAR(static_cast<plot::f64>(rows[1].h), static_cast<plot::f64>(rows[2].h), 1e-4);
|
||||
// Monotone in the weights: a bigger share is a taller row, and what the taller row
|
||||
// gained the others lost -- the figure's height is divided, never added to. Every
|
||||
// row keeps the floor, so a weight is a share of what is left above it and the
|
||||
// *order* is the exact statement, not the ratio.
|
||||
const float main_taller_still[] = {6.0f, 1.0f, 1.0f};
|
||||
PLOT_CHECK(figure.set_row_weights(main_taller_still));
|
||||
const std::vector<plot::Rect> taller = rows_of(figure);
|
||||
PLOT_CHECK(taller[0].h > rows[0].h);
|
||||
PLOT_CHECK(taller[1].h < rows[1].h);
|
||||
PLOT_CHECK(taller[0].h + taller[1].h + taller[2].h < rows[0].h + rows[1].h + rows[2].h + 1e-3f);
|
||||
PLOT_CHECK(figure.set_row_weights(main_taller));
|
||||
PLOT_CHECK_NEAR(static_cast<plot::f64>(figure.layout().rows[0].h),
|
||||
static_cast<plot::f64>(rows[0].h), 1e-4);
|
||||
|
||||
plot::f32 total = 0.0f;
|
||||
for (const plot::Rect& row : rows) total += row.h;
|
||||
total += layout.row_gap * 2.0f + layout.x_axis_strip.h;
|
||||
PLOT_CHECK_NEAR(static_cast<plot::f64>(total), static_cast<plot::f64>(kH), 1e-3);
|
||||
|
||||
// Any positive scale is the same division: normalised, not absolute.
|
||||
const float same_ratio[] = {30.0f, 10.0f, 10.0f};
|
||||
st = figure.set_row_weights(same_ratio);
|
||||
PLOT_CHECK(st);
|
||||
const std::vector<plot::Rect> scaled = rows_of(figure);
|
||||
for (std::size_t i = 0; i < rows.size(); ++i) {
|
||||
PLOT_CHECK_NEAR(static_cast<plot::f64>(scaled[i].h), static_cast<plot::f64>(rows[i].h), 1e-4);
|
||||
}
|
||||
|
||||
// Back to an equal division.
|
||||
figure.clear_row_weights();
|
||||
PLOT_CHECK(!figure.has_row_weights());
|
||||
const std::vector<plot::Rect> again = rows_of(figure);
|
||||
for (std::size_t i = 0; i < again.size(); ++i) {
|
||||
PLOT_CHECK_NEAR(static_cast<plot::f64>(again[i].h), static_cast<plot::f64>(equal[i].h), 1e-4);
|
||||
}
|
||||
}
|
||||
|
||||
/// A weight set that cannot divide the figure is refused outright -- a row with no
|
||||
/// share has no rect to give it, and a weight the arithmetic cannot use would produce
|
||||
/// exactly that -- and the figure keeps the division it had.
|
||||
PLOT_TEST(degenerate_row_weights_are_rejected) {
|
||||
plot::Figure figure = figure_with_axes(3u);
|
||||
const std::vector<plot::Rect> before = rows_of(figure);
|
||||
|
||||
const float zero[] = {1.0f, 0.0f, 1.0f};
|
||||
PLOT_CHECK(!figure.set_row_weights(zero));
|
||||
const float negative[] = {1.0f, -2.0f, 1.0f};
|
||||
PLOT_CHECK(!figure.set_row_weights(negative));
|
||||
const float none[] = {0.0f, 0.0f, 0.0f};
|
||||
PLOT_CHECK(!figure.set_row_weights(none));
|
||||
const float nan_weight[] = {1.0f, std::nanf(""), 1.0f};
|
||||
PLOT_CHECK(!figure.set_row_weights(nan_weight));
|
||||
const float inf_weight[] = {1.0f, INFINITY, 1.0f};
|
||||
PLOT_CHECK(!figure.set_row_weights(inf_weight));
|
||||
// The wrong count is not a division of *these* rows either.
|
||||
const float too_few[] = {1.0f, 1.0f};
|
||||
PLOT_CHECK(!figure.set_row_weights(too_few));
|
||||
const float too_many[] = {1.0f, 1.0f, 1.0f, 1.0f};
|
||||
PLOT_CHECK(!figure.set_row_weights(too_many));
|
||||
|
||||
PLOT_CHECK(!figure.has_row_weights());
|
||||
const std::vector<plot::Rect> after = rows_of(figure);
|
||||
PLOT_CHECK(after.size() == before.size());
|
||||
for (std::size_t i = 0; i < after.size(); ++i) {
|
||||
PLOT_CHECK_NEAR(static_cast<plot::f64>(after[i].h), static_cast<plot::f64>(before[i].h), 1e-6);
|
||||
}
|
||||
|
||||
// A refused set is refused *before* it is stored, so one good set after six bad
|
||||
// ones is what the figure divides by.
|
||||
const float good[] = {2.0f, 1.0f, 1.0f};
|
||||
PLOT_CHECK(figure.set_row_weights(good));
|
||||
const std::vector<plot::Rect> weighted = rows_of(figure);
|
||||
PLOT_CHECK(weighted[0].h > weighted[1].h);
|
||||
}
|
||||
|
||||
/// Every row keeps a positive height, whatever the figure's size and whatever the
|
||||
/// weights say -- a row that is not there is a plot that is not there, silently. The
|
||||
/// shared strip yields to the rows for the same reason: chrome may shrink a figure,
|
||||
/// never erase one.
|
||||
PLOT_TEST(every_row_keeps_a_positive_height) {
|
||||
// Five rows in a figure shorter than the strip one row would ask for.
|
||||
plot::Figure tiny(5u);
|
||||
tiny.set_viewport({0.0f, 0.0f, 200.0f, 12.0f});
|
||||
tiny.set_row_gap(6.0f); // more gap than the figure has height
|
||||
const plot::FigureLayout layout = tiny.layout();
|
||||
PLOT_CHECK(layout.rows.size() == 5u);
|
||||
plot::f32 total = 0.0f;
|
||||
for (const plot::Rect& row : layout.rows) {
|
||||
PLOT_CHECK(row.h > 0.0f);
|
||||
total += row.h;
|
||||
}
|
||||
// The rows took what they needed from the gap: a gap that does not fit is dropped,
|
||||
// so what is left of the figure is the rows' and the strip's, not the gap's.
|
||||
PLOT_CHECK_NEAR(static_cast<plot::f64>(total + layout.x_axis_strip.h), 12.0, 1e-3);
|
||||
|
||||
// A wildly unbalanced weight set still cannot squeeze a row out. The tiny weights
|
||||
// here are small enough that a row's *share* of the leftover underflows to zero
|
||||
// once it is expressed as a float -- which is exactly the share that would have been
|
||||
// its whole height without a floor under it.
|
||||
plot::Figure lopsided(4u);
|
||||
lopsided.set_viewport({0.0f, 50.0f, 400.0f, 300.0f});
|
||||
const float lopsided_weights[] = {1.0f, 1e-45f, 1.0f, 1e-45f};
|
||||
PLOT_CHECK(lopsided.set_row_weights(lopsided_weights));
|
||||
for (const plot::Rect& row : lopsided.layout().rows) {
|
||||
PLOT_CHECK(row.h > 0.0f);
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- the x link
|
||||
|
||||
/// Linking a row to a group that already has a range puts it on that range at once,
|
||||
/// not on the next frame: the group's window is the group's, and a member is on it
|
||||
/// from the call that made it a member.
|
||||
PLOT_TEST(link_x_applies_the_group_range_immediately) {
|
||||
plot::Figure figure = figure_with_axes(3u);
|
||||
PLOT_CHECK(figure.x_link(0u) == 0u); // every row starts unlinked
|
||||
PLOT_CHECK(figure.x_link(1u) == 0u);
|
||||
const plot::u32 group = figure.add_x_link();
|
||||
PLOT_CHECK(group == 1u); // 0 is the unlinked group, so ids start at 1
|
||||
PLOT_CHECK(figure.add_x_link() == 2u);
|
||||
PLOT_CHECK(figure.x_link_count() == 2u);
|
||||
|
||||
PLOT_CHECK(figure.link_x(0u, group));
|
||||
PLOT_CHECK(figure.x_link(0u) == group);
|
||||
// The first row into an empty group frames it, so the group is not a range the
|
||||
// caller never chose.
|
||||
PLOT_CHECK_NEAR(figure.x_link_range(group).min, 0.0, 1e-12);
|
||||
PLOT_CHECK_NEAR(figure.x_link_range(group).max, 100.0, 1e-12);
|
||||
|
||||
PLOT_CHECK(figure.set_x_link_range(group, {10.0, 20.0}));
|
||||
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).min, 10.0, 1e-12);
|
||||
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).max, 20.0, 1e-12);
|
||||
|
||||
// The row joining now lands on the group's window rather than keeping its own.
|
||||
figure.plot(1u).set_x_range(0u, {500.0, 900.0});
|
||||
PLOT_CHECK(figure.link_x(1u, group));
|
||||
PLOT_CHECK_NEAR(figure.plot(1u).x_range(0u).min, 10.0, 1e-12);
|
||||
PLOT_CHECK_NEAR(figure.plot(1u).x_range(0u).max, 20.0, 1e-12);
|
||||
|
||||
// Group 0 is the way back out, and a row that leaves keeps the range it holds.
|
||||
PLOT_CHECK(figure.link_x(1u, 0u));
|
||||
PLOT_CHECK(figure.x_link(1u) == 0u);
|
||||
PLOT_CHECK_NEAR(figure.plot(1u).x_range(0u).min, 10.0, 1e-12);
|
||||
|
||||
// A row or a group that does not exist is refused, and nothing moves.
|
||||
PLOT_CHECK(!figure.link_x(9u, group));
|
||||
PLOT_CHECK(!figure.link_x(0u, 9u));
|
||||
PLOT_CHECK(!figure.set_x_link_range(0u, {1.0, 2.0})); // group 0 has no shared range
|
||||
PLOT_CHECK(!figure.set_x_link_range(9u, {1.0, 2.0}));
|
||||
PLOT_CHECK(!figure.set_x_link_follow(0u, plot::XFollow::Live));
|
||||
PLOT_CHECK(figure.x_link(0u) == group);
|
||||
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).min, 10.0, 1e-12);
|
||||
|
||||
// A row with no x axis has nothing to link, and linking it must not pretend
|
||||
// otherwise.
|
||||
plot::Figure bare(1u);
|
||||
const plot::u32 g = bare.add_x_link();
|
||||
PLOT_CHECK(!bare.link_x(0u, g));
|
||||
PLOT_CHECK(bare.x_link(0u) == 0u);
|
||||
}
|
||||
|
||||
/// A group owns one range, so the range any single member is given becomes every
|
||||
/// member's before the next frame -- whether it arrived from a caller or from a
|
||||
/// controller, which reach a member exactly the same way.
|
||||
PLOT_TEST(setting_a_range_on_any_member_propagates_to_all) {
|
||||
plot::Figure figure = figure_with_axes(3u);
|
||||
const plot::u32 group = figure.add_x_link();
|
||||
for (plot::u32 row = 0; row < 3u; ++row) PLOT_CHECK(figure.link_x(row, group));
|
||||
PLOT_CHECK(figure.set_x_link_range(group, {5.0, 15.0}));
|
||||
|
||||
// Set on each member in turn: each one is the group's range from the next layout,
|
||||
// and every other member holds it too.
|
||||
const double values[][2] = {{100.0, 200.0}, {-40.0, -10.0}, {0.5, 0.75}};
|
||||
for (plot::u32 row = 0; row < 3u; ++row) {
|
||||
figure.plot(row).set_x_range(0u, {values[row][0], values[row][1]});
|
||||
figure.layout();
|
||||
for (plot::u32 member = 0; member < 3u; ++member) {
|
||||
PLOT_CHECK_NEAR(figure.plot(member).x_range(0u).min, values[row][0], 1e-12);
|
||||
PLOT_CHECK_NEAR(figure.plot(member).x_range(0u).max, values[row][1], 1e-12);
|
||||
}
|
||||
PLOT_CHECK_NEAR(figure.x_link_range(group).min, values[row][0], 1e-12);
|
||||
PLOT_CHECK_NEAR(figure.x_link_range(group).max, values[row][1], 1e-12);
|
||||
}
|
||||
|
||||
// A member with auto scale armed and data of its own cannot drag the group back:
|
||||
// the push applies the group's range, and applying a range is what clears auto
|
||||
// scale -- so a linked row is never re-fitted out of the group's window, however
|
||||
// many frames go by. The trace's own bounds are a thousand wide, so a row that
|
||||
// fitted itself would be unmistakable.
|
||||
plot::data::ArraySeries wide(std::vector<plot::f32>(64u, 0.5f), 1000.0, 1.0);
|
||||
figure.plot(2u).add_trace({&wide, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
|
||||
figure.plot(2u).set_x_auto_scale(0u, true);
|
||||
PLOT_CHECK(figure.set_x_link_range(group, {1.0, 2.0}));
|
||||
for (int frame = 0; frame < 3; ++frame) {
|
||||
figure.layout();
|
||||
PLOT_CHECK_NEAR(figure.plot(2u).x_range(0u).min, 1.0, 1e-12);
|
||||
PLOT_CHECK_NEAR(figure.plot(2u).x_range(0u).max, 2.0, 1e-12);
|
||||
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).min, 1.0, 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
/// The propagation settles in one pass and stays settled: after a frame every member
|
||||
/// holds the group's range, and a member written back with a range that *is* the
|
||||
/// group's -- the write a re-propagating implementation would read as a change -- does
|
||||
/// not move it again. This is the case that catches a write-back loop: a design that
|
||||
/// compared a member against the group's range instead of against what it pushed would
|
||||
/// re-derive the group's state from its own output on every frame.
|
||||
PLOT_TEST(propagation_is_idempotent_and_terminates) {
|
||||
plot::Figure figure = figure_with_axes(2u);
|
||||
const plot::u32 group = figure.add_x_link();
|
||||
PLOT_CHECK(figure.link_x(0u, group));
|
||||
PLOT_CHECK(figure.link_x(1u, group));
|
||||
PLOT_CHECK(figure.set_x_link_range(group, {0.0, 10.0}));
|
||||
|
||||
// A long alternating sequence: a change on one row, one layout, and both rows
|
||||
// agree. "One pass" is exactly this -- a second layout with no new change moves
|
||||
// nothing, so the frame after a change is the frame that settles it.
|
||||
for (plot::u32 step = 0; step < 500u; ++step) {
|
||||
const plot::u32 row = step % 2u;
|
||||
const double lo = static_cast<double>(step) * 0.25;
|
||||
figure.plot(row).set_x_range(0u, {lo, lo + 3.0});
|
||||
figure.layout();
|
||||
for (plot::u32 member = 0; member < 2u; ++member) {
|
||||
PLOT_CHECK_NEAR(figure.plot(member).x_range(0u).min, lo, 1e-9);
|
||||
PLOT_CHECK_NEAR(figure.plot(member).x_range(0u).max, lo + 3.0, 1e-9);
|
||||
}
|
||||
// Nothing left to propagate: the members already hold what the group holds, so
|
||||
// further frames are the identity on the range.
|
||||
const plot::Range settled = figure.x_link_range(group);
|
||||
for (int frame = 0; frame < 3; ++frame) {
|
||||
figure.layout();
|
||||
PLOT_CHECK_NEAR(figure.x_link_range(group).min, settled.min, 1e-12);
|
||||
PLOT_CHECK_NEAR(figure.x_link_range(group).max, settled.max, 1e-12);
|
||||
for (plot::u32 member = 0; member < 2u; ++member) {
|
||||
PLOT_CHECK_NEAR(figure.plot(member).x_range(0u).min, settled.min, 1e-12);
|
||||
PLOT_CHECK_NEAR(figure.plot(member).x_range(0u).max, settled.max, 1e-12);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ... and deliberately written back in: a member handed the group's own range --
|
||||
// what a propagated range is -- must not be taken for a new change and pushed
|
||||
// again, in a frame or in a hundred.
|
||||
const plot::Range held = figure.x_link_range(group);
|
||||
for (int frame = 0; frame < 100; ++frame) {
|
||||
figure.plot(1u).set_x_range(0u, held);
|
||||
figure.layout();
|
||||
PLOT_CHECK_NEAR(figure.x_link_range(group).min, held.min, 1e-12);
|
||||
PLOT_CHECK_NEAR(figure.x_link_range(group).max, held.max, 1e-12);
|
||||
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).min, held.min, 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows that are not in a group are not in one: each keeps whatever its own plot was
|
||||
/// given, however many frames go by, and a group's window is nobody else's business.
|
||||
PLOT_TEST(unlinked_plots_keep_independent_ranges) {
|
||||
plot::Figure figure = figure_with_axes(3u);
|
||||
figure.plot(0u).set_x_range(0u, {1.0, 2.0});
|
||||
figure.plot(1u).set_x_range(0u, {10.0, 40.0});
|
||||
figure.plot(2u).set_x_range(0u, {-5.0, 5.0});
|
||||
|
||||
// A group elsewhere in the figure, to prove it is the group that shares and not
|
||||
// the figure: adding one changes no row.
|
||||
const plot::u32 group = figure.add_x_link();
|
||||
PLOT_CHECK(figure.x_link_count() == 1u);
|
||||
|
||||
for (int frame = 0; frame < 5; ++frame) {
|
||||
figure.layout();
|
||||
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).min, 1.0, 1e-12);
|
||||
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).max, 2.0, 1e-12);
|
||||
PLOT_CHECK_NEAR(figure.plot(1u).x_range(0u).min, 10.0, 1e-12);
|
||||
PLOT_CHECK_NEAR(figure.plot(1u).x_range(0u).max, 40.0, 1e-12);
|
||||
PLOT_CHECK_NEAR(figure.plot(2u).x_range(0u).min, -5.0, 1e-12);
|
||||
PLOT_CHECK_NEAR(figure.plot(2u).x_range(0u).max, 5.0, 1e-12);
|
||||
}
|
||||
PLOT_CHECK(figure.x_link(0u) == 0u && figure.x_link(1u) == 0u && figure.x_link(2u) == 0u);
|
||||
|
||||
// Linking one row does not touch the others.
|
||||
PLOT_CHECK(figure.link_x(1u, group));
|
||||
PLOT_CHECK_NEAR(figure.x_link_range(group).min, 10.0, 1e-12);
|
||||
PLOT_CHECK_NEAR(figure.x_link_range(group).max, 40.0, 1e-12);
|
||||
figure.layout();
|
||||
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).min, 1.0, 1e-12);
|
||||
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).max, 2.0, 1e-12);
|
||||
PLOT_CHECK_NEAR(figure.plot(2u).x_range(0u).min, -5.0, 1e-12);
|
||||
PLOT_CHECK_NEAR(figure.plot(2u).x_range(0u).max, 5.0, 1e-12);
|
||||
}
|
||||
|
||||
/// A `Live` axis owns its own range -- its own span, its own offset, its own live
|
||||
/// edge -- so a link group cannot own it too. Linking one is refused with a `Status`
|
||||
/// rather than silently reinterpreted: the caller is told, and the axis is left
|
||||
/// exactly as it was.
|
||||
PLOT_TEST(linking_a_live_axis_is_refused_with_a_status) {
|
||||
plot::Figure figure = figure_with_axes(2u);
|
||||
const plot::u32 group = figure.add_x_link();
|
||||
figure.plot(0u).set_x_follow(0u, plot::XFollow::Live);
|
||||
PLOT_CHECK(figure.plot(0u).x_follow(0u) == plot::XFollow::Live);
|
||||
const plot::Range before = figure.plot(0u).x_range(0u);
|
||||
|
||||
const plot::Status st = figure.link_x(0u, group);
|
||||
PLOT_CHECK(!st);
|
||||
PLOT_CHECK(st.code == plot::ErrorCode::InvalidArgument);
|
||||
PLOT_CHECK(st.message[0] != '\0'); // a refusal a caller can read
|
||||
PLOT_CHECK(figure.x_link(0u) == 0u);
|
||||
// ... and it is still Live, with the range it had: a refusal that changed the
|
||||
// state would be the silent re-interpretation it refuses to be.
|
||||
PLOT_CHECK(figure.plot(0u).x_follow(0u) == plot::XFollow::Live);
|
||||
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).min, before.min, 1e-12);
|
||||
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).max, before.max, 1e-12);
|
||||
|
||||
// The other row links, and the group's window leaves the live axis alone.
|
||||
PLOT_CHECK(figure.link_x(1u, group));
|
||||
PLOT_CHECK(figure.set_x_link_range(group, {3.0, 4.0}));
|
||||
figure.layout();
|
||||
PLOT_CHECK_NEAR(figure.plot(1u).x_range(0u).min, 3.0, 1e-12);
|
||||
PLOT_CHECK(figure.plot(0u).x_follow(0u) == plot::XFollow::Live);
|
||||
|
||||
// Turning the follow off is what makes the axis linkable -- the group's state is
|
||||
// the group's, so the axis has to be `Off` first.
|
||||
figure.plot(0u).set_x_follow(0u, plot::XFollow::Off);
|
||||
PLOT_CHECK(figure.link_x(0u, group));
|
||||
PLOT_CHECK(figure.x_link(0u) == group);
|
||||
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).min, 3.0, 1e-12);
|
||||
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).max, 4.0, 1e-12);
|
||||
}
|
||||
|
||||
/// A following group advances every row together: the window is derived once per
|
||||
/// frame from the newest x across the members' traces, exactly as one `Live` axis'
|
||||
/// would be, so a streaming multi-row figure has one window and no per-row drift --
|
||||
/// each row sees the same edge even though only one of them is being pushed to. The
|
||||
/// two rows hold *different* amounts of data, so "the group's edge" and "this row's
|
||||
/// edge" are different numbers and the assertions can tell them apart.
|
||||
PLOT_TEST(a_following_group_advances_all_rows_together) {
|
||||
plot::data::StreamSeries left(1024u, 0.0, 1.0);
|
||||
plot::data::StreamSeries right(1024u, 0.0, 1.0);
|
||||
for (int i = 0; i < 300; ++i) {
|
||||
left.push(static_cast<plot::f32>(std::sin(i * 0.1)));
|
||||
}
|
||||
for (int i = 0; i < 200; ++i) {
|
||||
right.push(static_cast<plot::f32>(std::cos(i * 0.1)));
|
||||
}
|
||||
left.acquire();
|
||||
right.acquire();
|
||||
|
||||
plot::Figure figure = figure_with_axes(2u);
|
||||
figure.plot(0u).add_trace({&left, plot::Color::hex(0x4FA3FF), 1.5f, 0u, 0u});
|
||||
figure.plot(1u).add_trace({&right, plot::Color::hex(0xFF8080), 1.5f, 0u, 0u});
|
||||
|
||||
const plot::u32 group = figure.add_x_link();
|
||||
PLOT_CHECK(figure.link_x(0u, group));
|
||||
PLOT_CHECK(figure.link_x(1u, group));
|
||||
PLOT_CHECK(figure.set_x_link_follow(group, plot::XFollow::Live));
|
||||
PLOT_CHECK(figure.x_link_follow(group) == plot::XFollow::Live);
|
||||
// Under `Live` a range is a span and an offset behind the live edge, not a pair of
|
||||
// positions, so the group is told "ten wide, ending at the edge" -- the speed row's
|
||||
// newest x, which is where the group's window will stay.
|
||||
const plot::f64 edge0 = left.x_bounds().max;
|
||||
PLOT_CHECK(figure.set_x_link_range(group, {edge0 - 10.0, edge0}));
|
||||
|
||||
const auto both_rows_hold = [&](plot::Range& out) {
|
||||
const plot::Range a = figure.plot(0u).x_range(0u);
|
||||
const plot::Range b = figure.plot(1u).x_range(0u);
|
||||
PLOT_CHECK_NEAR(a.min, b.min, 1e-9);
|
||||
PLOT_CHECK_NEAR(a.max, b.max, 1e-9);
|
||||
out = a;
|
||||
};
|
||||
|
||||
figure.layout();
|
||||
plot::Range previous{};
|
||||
both_rows_hold(previous);
|
||||
// The window ends at the edge the range said it should end at, and is ten wide:
|
||||
// both members hold it, and the slower row's own edge is not where it ends.
|
||||
PLOT_CHECK_NEAR(previous.max, edge0, 1e-9);
|
||||
PLOT_CHECK_NEAR(previous.max - previous.min, 10.0, 1e-9);
|
||||
PLOT_CHECK(right.x_bounds().max < previous.max - 50.0);
|
||||
|
||||
// The producer keeps going -- the two rows at different rates, so their *own* live
|
||||
// edges are different and a window derived from one row's stream would be
|
||||
// unmistakable -- and every frame moves *both* rows to the same window, the one the
|
||||
// group derives from the newest sample it can see.
|
||||
for (int step = 1; step <= 60; ++step) {
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
left.push(static_cast<plot::f32>(std::sin(step * 0.1)));
|
||||
}
|
||||
for (int i = 0; i < 2; ++i) {
|
||||
right.push(static_cast<plot::f32>(std::cos(step * 0.1)));
|
||||
}
|
||||
left.acquire();
|
||||
right.acquire();
|
||||
figure.layout();
|
||||
plot::Range now{};
|
||||
both_rows_hold(now);
|
||||
PLOT_CHECK(now.max > previous.max); // the window advanced
|
||||
PLOT_CHECK_NEAR(now.max - now.min, 10.0, 1e-9); // and kept its span
|
||||
// The window's right end is the *newest* of the two rows' edges, which is the
|
||||
// faster row's -- the slower row's own edge would be well behind it.
|
||||
PLOT_CHECK(left.x_bounds().max > right.x_bounds().max + 10.0);
|
||||
PLOT_CHECK_NEAR(now.max, left.x_bounds().max, 1e-9);
|
||||
previous = now;
|
||||
}
|
||||
PLOT_CHECK(previous.max > edge0);
|
||||
|
||||
// Panning a member while the group follows is a *span-and-offset* statement, so it
|
||||
// moves all rows together and leaves following on -- the group's edge still owns
|
||||
// the window's right end.
|
||||
figure.plot(1u).set_x_range(0u, {previous.max - 60.0, previous.max - 40.0});
|
||||
figure.layout();
|
||||
plot::Range panned{};
|
||||
both_rows_hold(panned);
|
||||
PLOT_CHECK_NEAR(panned.max - panned.min, 20.0, 1e-9); // the span the pan stated
|
||||
PLOT_CHECK_NEAR(panned.max, left.x_bounds().max - 40.0, 1e-9); // 40 behind the edge
|
||||
PLOT_CHECK(figure.x_link_follow(group) == plot::XFollow::Live);
|
||||
PLOT_CHECK(figure.plot(0u).x_follow(0u) == plot::XFollow::Off); // members stay Off
|
||||
|
||||
// Disarming leaves the window where it is; the rows are still one range.
|
||||
PLOT_CHECK(figure.set_x_link_follow(group, plot::XFollow::Off));
|
||||
const plot::Range frozen = figure.x_link_range(group);
|
||||
for (int frame = 0; frame < 5; ++frame) {
|
||||
left.push(1.0f);
|
||||
left.acquire();
|
||||
figure.layout();
|
||||
both_rows_hold(previous);
|
||||
PLOT_CHECK_NEAR(figure.x_link_range(group).min, frozen.min, 1e-9);
|
||||
PLOT_CHECK_NEAR(figure.x_link_range(group).max, frozen.max, 1e-9);
|
||||
}
|
||||
}
|
||||
@@ -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