1659 lines
79 KiB
C++
1659 lines
79 KiB
C++
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
// `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 <plot/render/embedded_mono_font.hpp>
|
|
|
|
#include <cmath>
|
|
#include <span>
|
|
#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, on the bottom
|
|
/// row alone, height to the shared x strip -- which lives inside that row rather than
|
|
/// in a band of its own.
|
|
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;
|
|
}
|
|
|
|
/// Gives one plot the axes of the misalignment cases: a fixed x range and a fixed y
|
|
/// range from `give_magnitude_row()`'s table. Fixed (`auto_scale` off), so the labels a row
|
|
/// measures are the ones its magnitude produces whatever order the rows are laid out
|
|
/// in -- which is what makes two rows' strips comparable.
|
|
void give_magnitude_row(plot::Plot& p, plot::u32 which) {
|
|
static plot::render::EmbeddedMonoFont font;
|
|
const plot::Range y_ranges[3] = {{0.0, 671.0e6}, {0.0, 90.0}, {0.0, 2.51}};
|
|
p.set_text_backend(&font);
|
|
p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
|
|
p.add_y_axis({"v", y_ranges[which % 3u], false, plot::AxisPlace::Left, false});
|
|
}
|
|
|
|
/// A figure whose rows carry deliberately different magnitudes: the "671m"-scale
|
|
/// row's tick labels are four characters wide, the "90" row's two and the "2.51"
|
|
/// row's three. That is the shape the reported misalignment had -- each row sized its
|
|
/// own y strip from its own widest label, so the rows' axes started at three different
|
|
/// x.
|
|
plot::Figure different_magnitude_figure(plot::u32 rows = 3u) {
|
|
plot::Figure figure(rows);
|
|
figure.set_viewport(whole_figure());
|
|
for (plot::u32 row = 0; row < rows; ++row) give_magnitude_row(figure.plot(row), row);
|
|
return figure;
|
|
}
|
|
|
|
/// The left gutter `which`'s row would reserve for itself if it were a plot *standing
|
|
/// alone* in `viewport` -- the label strip plus the title column, which is the width a
|
|
/// figure's common gutter is a maximum over. The same axes, text backend and metrics,
|
|
/// laid out with no figure floor, so reading it leaves the figure's rows untouched.
|
|
plot::f32 natural_gutter_width(plot::u32 which, plot::Rect viewport) {
|
|
plot::Plot solo;
|
|
give_magnitude_row(solo, which);
|
|
solo.set_viewport(viewport);
|
|
solo.layout();
|
|
return solo.layout().y_axis_width + solo.layout().y_title_width;
|
|
}
|
|
|
|
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 figure's bottom edge -- the rows tile the
|
|
// whole figure, because the shared x axis is the bottom row's own rather than a band
|
|
// taken off the rows.
|
|
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>(kH), 1e-6);
|
|
// The shared strip *is* the bottom row's own x label area: inside that row, below the
|
|
// row's axes and above the row's title strip, and as wide as its axes -- which is what
|
|
// puts it where the controller can hit-test it and where the renderer stages the one
|
|
// set of labels.
|
|
const plot::PlotLayout& bottom = figure.plot(2u).layout();
|
|
PLOT_CHECK(layout.x_axis_strip.h > 0.0f);
|
|
PLOT_CHECK(layout.x_axis_strip.y >= layout.rows.back().y);
|
|
PLOT_CHECK(layout.x_axis_strip.bottom() <= layout.rows.back().bottom() + 1e-6f);
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout.x_axis_strip.h),
|
|
static_cast<plot::f64>(bottom.x_label_area.h), 1e-6);
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout.x_axis_strip.x),
|
|
static_cast<plot::f64>(bottom.x_label_area.x), 1e-6);
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout.x_axis_strip.y),
|
|
static_cast<plot::f64>(bottom.x_label_area.y), 1e-6);
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout.x_axis_strip.w),
|
|
static_cast<plot::f64>(bottom.x_label_area.w), 1e-6);
|
|
|
|
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
|
|
// The last spaced row still ends on the figure's bottom edge -- the gaps came out of
|
|
// the rows' share of the figure, not out of the figure -- and so does the strip.
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(spaced.back().bottom()), static_cast<plot::f64>(kH), 1e-6);
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(figure.layout().x_axis_strip.bottom()),
|
|
static_cast<plot::f64>(kH), 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 whole figure (the shared strip lives inside the bottom row, so
|
|
/// it costs the division nothing), 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;
|
|
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 cannot take a row's height for the same reason: it is the bottom row's
|
|
/// own, so chrome may shrink one row's *axes*, never erase a row.
|
|
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 the whole figure: a gap that does not fit is dropped, and the shared
|
|
// strip is inside the bottom row rather than a band beside it, so it costs the
|
|
// division nothing.
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(total), 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 row-height floor is chrome-sized, so it must be *logical*: it is applied in
|
|
/// device pixels, but the 24 it was written as is a logical size like every other chrome
|
|
/// metric, and at 2x a device-pixel floor is half the physical height -- exactly where the
|
|
/// chrome it protects is twice as large. It is converted through the row's own
|
|
/// `ui_scale()`, so the guarantee is the same physical height at any DPI.
|
|
PLOT_TEST(the_row_height_floor_is_logical_and_converts_with_the_display) {
|
|
const float weights[] = {1.0f, 1.0f, 1e-30f};
|
|
for (const plot::f32 scale : {1.0f, 2.0f}) {
|
|
plot::Figure figure(3u);
|
|
figure.set_viewport({0.0f, 0.0f, 400.0f, 600.0f});
|
|
for (plot::u32 row = 0; row < 3u; ++row) figure.plot(row).set_ui_scale(scale);
|
|
PLOT_CHECK(figure.set_row_weights(weights));
|
|
const std::vector<plot::Rect> rows = figure.layout().rows;
|
|
PLOT_CHECK(rows.size() == 3u);
|
|
// The underflowing-weight row gets the floor and nothing else: the leftover after
|
|
// the floor is shared by weight, and 1e-30 of it is zero in float.
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(rows[2].h), 24.0 * static_cast<plot::f64>(scale), 1e-2);
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------- y gutter alignment
|
|
|
|
/// Spec §16, the invariant: a figure gives its rows one common left gutter, so every
|
|
/// row's `axes` starts and ends on the same pixel columns. This is the mechanism; the
|
|
/// case below is the drawn consequence a user sees as "the y axes line up".
|
|
PLOT_TEST(figure_rows_share_one_y_gutter) {
|
|
plot::Figure figure = different_magnitude_figure();
|
|
const plot::FigureLayout layout = figure.layout();
|
|
const plot::Rect first = figure.plot(0u).layout().axes;
|
|
for (plot::u32 row = 0; row < 3u; ++row) {
|
|
const plot::Rect& axes = figure.plot(row).layout().axes;
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(axes.x), static_cast<plot::f64>(first.x), 1e-4);
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(axes.w), static_cast<plot::f64>(first.w), 1e-4);
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(axes.right()), static_cast<plot::f64>(first.right()),
|
|
1e-4);
|
|
// The rows are still their own plots vertically: the equality is the *left gutter*
|
|
// and not three rows accidentally given one rect.
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(axes.y), static_cast<plot::f64>(layout.rows[row].y),
|
|
1e-4);
|
|
}
|
|
// The rows really do disagree about the strip they need, which is what makes the
|
|
// gutter a decision the figure has to make: each row's own labels, measured with no
|
|
// floor in the row's own viewport, ask for a different width -- and every row's
|
|
// applied width is at least what it asked for, so the figure widened the narrow ones
|
|
// rather than inventing a width they did not need.
|
|
plot::f32 natural[3] = {};
|
|
for (plot::u32 row = 0; row < 3u; ++row) {
|
|
natural[row] = natural_gutter_width(row, layout.rows[row]);
|
|
const plot::PlotLayout& rl = figure.plot(row).layout();
|
|
PLOT_CHECK(rl.y_axis_width + rl.y_title_width >= natural[row] - 1e-4f);
|
|
}
|
|
PLOT_CHECK(!(natural[0] == natural[1] && natural[1] == natural[2]));
|
|
// The shared x axis is still the *bottom row's own* label area -- aligning the rows'
|
|
// left chrome must not move where a press on the shared axis lands.
|
|
const plot::PlotLayout& bottom = figure.plot(2u).layout();
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout.x_axis_strip.x),
|
|
static_cast<plot::f64>(bottom.x_label_area.x), 1e-4);
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout.x_axis_strip.w),
|
|
static_cast<plot::f64>(bottom.x_label_area.w), 1e-4);
|
|
PLOT_CHECK(layout.x_axis_strip.h > 0.0f);
|
|
}
|
|
|
|
/// Spec §16, the drawn consequence: with one gutter, a given data x maps to **one**
|
|
/// pixel column of the figure, because every row's x transform is built from the same
|
|
/// axes rect. That is the property a vertical feature -- a cursor, a stacked sample --
|
|
/// relies on, and it is what "the y axis are not vertically aligned" complained about.
|
|
PLOT_TEST(figure_rows_map_a_data_x_to_one_column) {
|
|
plot::Figure figure = different_magnitude_figure();
|
|
figure.layout();
|
|
const plot::f32 column = static_cast<plot::f32>(figure.plot(0u).x_transform(0u).to_screen_x(37.5));
|
|
for (plot::u32 row = 0; row < 3u; ++row) {
|
|
const plot::Plot& p = figure.plot(row);
|
|
const plot::f32 x = static_cast<plot::f32>(p.x_transform(0u).to_screen_x(37.5));
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(x), static_cast<plot::f64>(column), 1e-3);
|
|
// ... and the leftmost data column lands on the axes' left edge in every row, so
|
|
// the alignment is the full width and not a common offset into three different ones.
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(p.x_transform(0u).to_screen_x(0.0)),
|
|
static_cast<plot::f64>(p.layout().axes.x), 1e-3);
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(p.x_transform(0u).to_screen_x(100.0)),
|
|
static_cast<plot::f64>(p.layout().axes.right()), 1e-2);
|
|
}
|
|
}
|
|
|
|
/// The neighbour: a `Plot` standing alone is not a figure and keeps sizing its strip
|
|
/// from its own data. The floor is the figure's imposition, unset by default, and the
|
|
/// only thing that widens a lone plot's *gutter* is an explicit one.
|
|
PLOT_TEST(a_plot_on_its_own_keeps_its_own_y_gutter) {
|
|
plot::Plot p;
|
|
give_magnitude_row(p, 1u); // the "90" row: the narrowest labels
|
|
p.set_viewport(whole_figure());
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(p.min_y_gutter()), 0.0, 1e-12);
|
|
const plot::PlotLayout& natural_layout = p.layout();
|
|
const plot::f32 natural = natural_layout.y_axis_width + natural_layout.y_title_width;
|
|
PLOT_CHECK(natural > 0.0f);
|
|
|
|
// An explicit floor widens the gutter, which is the same lever `Figure` pulls -- and
|
|
// it is a floor on the *gutter*, so the label strip and the title column together
|
|
// reach it rather than the label strip alone.
|
|
p.set_min_y_gutter(natural + 20.0f);
|
|
const plot::PlotLayout& wide = p.layout();
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(wide.y_axis_width + wide.y_title_width),
|
|
static_cast<plot::f64>(natural + 20.0f), 1e-3);
|
|
// Negative values mean "no floor", like `set_row_gap()`, and the gutter goes back to
|
|
// what the labels and titles ask for.
|
|
p.set_min_y_gutter(-5.0f);
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(p.min_y_gutter()), 0.0, 1e-12);
|
|
const plot::PlotLayout& back = p.layout();
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(back.y_axis_width + back.y_title_width),
|
|
static_cast<plot::f64>(natural), 1e-4);
|
|
}
|
|
|
|
/// The figure's gutter is clamped by the same cap as the measured strip, so a figure
|
|
/// whose rows are much wider than its viewport still cannot let the gutter squeeze the
|
|
/// axes below the half-viewport floor `Plot::layout()` guarantees.
|
|
PLOT_TEST(figure_gutter_never_squeezes_the_axes_below_the_floor) {
|
|
plot::Figure figure = different_magnitude_figure();
|
|
const plot::f32 w = 40.0f;
|
|
figure.set_viewport({0.0f, 0.0f, w, 60.0f});
|
|
figure.layout();
|
|
const plot::Rect first = figure.plot(0u).layout().axes;
|
|
for (plot::u32 row = 0; row < 3u; ++row) {
|
|
const plot::Rect& axes = figure.plot(row).layout().axes;
|
|
PLOT_CHECK(axes.w >= 0.5f * w - 1e-3f);
|
|
PLOT_CHECK(axes.w > 0.0f);
|
|
// ... and the rows still agree, which is the point: the cap is the figure's width,
|
|
// and every row shares it.
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(axes.x), static_cast<plot::f64>(first.x), 1e-4);
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(axes.w), static_cast<plot::f64>(first.w), 1e-4);
|
|
}
|
|
}
|
|
|
|
/// The other side of the same band. `axes` reached the viewport's right edge, which left
|
|
/// the rightmost x tick label -- centred on it -- half outside the plot, so a figure can
|
|
/// ask for an empty band there. One value for every row, like the left gutter, because
|
|
/// the rows' axes have to end on a common column as well as start on one.
|
|
PLOT_TEST(figure_rows_share_one_right_gutter) {
|
|
plot::Figure figure = different_magnitude_figure();
|
|
figure.layout();
|
|
const plot::f32 flush = figure.plot(0u).layout().axes.right();
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(figure.right_gutter()), 0.0, 1e-12);
|
|
// The default is the layout as it always was: nothing between the data and the frame.
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(flush), static_cast<plot::f64>(kW), 1e-4);
|
|
|
|
figure.set_right_gutter(30.0f);
|
|
figure.layout();
|
|
const plot::Rect first = figure.plot(0u).layout().axes;
|
|
for (plot::u32 row = 0; row < 3u; ++row) {
|
|
const plot::PlotLayout& rl = figure.plot(row).layout();
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.right_gutter), 30.0, 1e-4);
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.axes.right()), static_cast<plot::f64>(kW) - 30.0,
|
|
1e-4);
|
|
// The band comes off the right alone: the left gutter the rows agreed on is untouched,
|
|
// so a host can balance the two sides without the one moving when it sets the other.
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.axes.x), static_cast<plot::f64>(first.x), 1e-4);
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.axes.w), static_cast<plot::f64>(first.w), 1e-4);
|
|
// ... and the x strip follows the data area rather than the viewport, or the shared
|
|
// axis would be labelled over a band that holds no data.
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.x_label_area.w), static_cast<plot::f64>(rl.axes.w),
|
|
1e-4);
|
|
}
|
|
// Clamped by the same cap as the strips: a band wider than the whole figure cannot
|
|
// squeeze `axes` below the floor `Plot::layout()` guarantees.
|
|
figure.set_right_gutter(10.0f * kW);
|
|
figure.layout();
|
|
for (plot::u32 row = 0; row < 3u; ++row) {
|
|
const plot::Rect& axes = figure.plot(row).layout().axes;
|
|
PLOT_CHECK(axes.w >= 0.5f * kW - 1e-3f);
|
|
}
|
|
// Negative means "none", like the gutter and the row gap, and the data area goes back
|
|
// to the viewport's edge.
|
|
figure.set_right_gutter(-5.0f);
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(figure.right_gutter()), 0.0, 1e-12);
|
|
figure.layout();
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(figure.plot(0u).layout().axes.right()),
|
|
static_cast<plot::f64>(flush), 1e-4);
|
|
}
|
|
|
|
/// The degenerate neighbour: a figure of one row imposes exactly what that row
|
|
/// measured, so it lays out as the plot would on its own -- the gutter cannot move a
|
|
/// row that has no sibling to line up with.
|
|
PLOT_TEST(a_one_row_figure_lays_out_as_the_plot_alone_does) {
|
|
plot::Figure figure = different_magnitude_figure(1u);
|
|
const plot::FigureLayout layout = figure.layout();
|
|
plot::Plot solo;
|
|
give_magnitude_row(solo, 0u);
|
|
solo.set_viewport(layout.rows[0]);
|
|
solo.layout();
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(figure.plot(0u).layout().axes.x),
|
|
static_cast<plot::f64>(solo.layout().axes.x), 1e-4);
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(figure.plot(0u).layout().axes.w),
|
|
static_cast<plot::f64>(solo.layout().axes.w), 1e-4);
|
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(figure.plot(0u).layout().y_axis_width),
|
|
static_cast<plot::f64>(solo.layout().y_axis_width), 1e-4);
|
|
}
|
|
|
|
// --------------------------------------------------------------- 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);
|
|
}
|
|
|
|
/// The post-link hole the door refusal cannot close: `set_x_follow(Live)` on a member
|
|
/// *after* it linked. The group owns the shared window and the follow state, so arming
|
|
/// the member's axis would make `push_link()` write the group's range into an axis that
|
|
/// reinterprets it against its own live edge, and `adopt_member_changes()` read it back
|
|
/// as an external change -- the group then tracking one member instead of the union. The
|
|
/// arm is refused, and taking the row out of the group restores the ability to arm it.
|
|
PLOT_TEST(arming_live_on_a_linked_member_is_refused) {
|
|
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));
|
|
|
|
figure.plot(0u).set_x_follow(0u, plot::XFollow::Live);
|
|
PLOT_CHECK(figure.plot(0u).x_follow(0u) == plot::XFollow::Off); // refused, not armed
|
|
PLOT_CHECK(figure.plot(1u).x_follow(0u) == plot::XFollow::Off);
|
|
|
|
// Arming `Off` is not refused, and leaving the group frees the axis again: the
|
|
// refusal is about the membership, not about the call.
|
|
figure.plot(0u).set_x_follow(0u, plot::XFollow::Off);
|
|
PLOT_CHECK(figure.link_x(0u, 0u));
|
|
figure.plot(0u).set_x_follow(0u, plot::XFollow::Live);
|
|
PLOT_CHECK(figure.plot(0u).x_follow(0u) == plot::XFollow::Live);
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
}
|
|
|
|
/// B4, on a following *log* group: the group's span and offset live in the shared
|
|
/// axis' axis space -- decades -- exactly as a single `Live` axis' do, so the
|
|
/// window slides with the newest sample without rescaling. The visible decades
|
|
/// stay what was asked for on every row, and a member panned mid-follow restates
|
|
/// its scroll state in the same decades rather than in a linear width that would
|
|
/// decay as the edge moved.
|
|
PLOT_TEST(a_following_log_group_holds_decades) {
|
|
plot::data::StreamSeries left(1024u, 1.0, 10.0); // samples at x = 1, 11, 21, ...
|
|
plot::data::StreamSeries right(1024u, 1.0, 10.0);
|
|
for (int i = 0; i < 30; ++i) left.push(0.5f);
|
|
for (int i = 0; i < 20; ++i) right.push(0.5f);
|
|
left.acquire();
|
|
right.acquire();
|
|
|
|
plot::Figure figure(2u);
|
|
figure.set_viewport(whole_figure());
|
|
for (plot::u32 row = 0; row < 2u; ++row) {
|
|
plot::Plot& p = figure.plot(row);
|
|
p.add_x_axis({"t", {1.0, 1000.0}, false, plot::AxisPlace::Bottom, /*log=*/true});
|
|
p.add_y_axis({"v", {0.0, 1.0}, false, plot::AxisPlace::Left, false});
|
|
}
|
|
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));
|
|
|
|
const auto decades_of = [](const plot::Plot& p) {
|
|
const plot::Range r = p.x_range(0u);
|
|
return std::log10(r.max) - std::log10(r.min);
|
|
};
|
|
|
|
// One decade wide, ending at the fastest row's edge.
|
|
const plot::f64 edge0 = left.x_bounds().max;
|
|
PLOT_CHECK(figure.set_x_link_range(group, {edge0 / 10.0, edge0}));
|
|
figure.layout();
|
|
PLOT_CHECK_NEAR(decades_of(figure.plot(0u)), 1.0, 1e-9);
|
|
PLOT_CHECK_NEAR(decades_of(figure.plot(1u)), 1.0, 1e-9);
|
|
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).max, edge0, 1e-8);
|
|
|
|
// The streams advance at different rates; the window follows the group's edge --
|
|
// the faster row's -- and holds its decade on every row.
|
|
for (int step = 1; step <= 8; ++step) {
|
|
for (int i = 0; i < 3; ++i) left.push(0.5f);
|
|
for (int i = 0; i < 1; ++i) right.push(0.5f);
|
|
left.acquire();
|
|
right.acquire();
|
|
figure.layout();
|
|
PLOT_CHECK_NEAR(decades_of(figure.plot(0u)), 1.0, 1e-9);
|
|
PLOT_CHECK_NEAR(decades_of(figure.plot(1u)), 1.0, 1e-9);
|
|
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).max, left.x_bounds().max, 1e-8);
|
|
PLOT_CHECK_NEAR(figure.plot(1u).x_range(0u).max, left.x_bounds().max, 1e-8);
|
|
}
|
|
|
|
// A member panned while the group follows: the adopted scroll state is the
|
|
// adopted range's span *in decades* -- two here (edge/1000 to edge/10 is a
|
|
// factor of 100) -- and the offset as far behind the edge as the retained data
|
|
// allows, both of which survive the next frames' advances.
|
|
//
|
|
// The pan *asks* for a full decade back, and the group may not take it: the offset is bounded by
|
|
// `retained - span`, so the window stops with its left edge on the oldest sample. Asserting
|
|
// `max == edge/10` here was asserting the *old* bound, which put a whole span of the window over
|
|
// data the members never held -- the empty-left-margin defect this bound exists for. The
|
|
// log-space assertions above are this test's subject and are unchanged.
|
|
const plot::f64 edge = left.x_bounds().max;
|
|
figure.plot(1u).set_x_range(0u, {edge / 1000.0, edge / 10.0});
|
|
figure.layout();
|
|
PLOT_CHECK_NEAR(decades_of(figure.plot(0u)), 2.0, 1e-9);
|
|
PLOT_CHECK_NEAR(decades_of(figure.plot(1u)), 2.0, 1e-9);
|
|
const plot::f64 oldest = std::min(left.x_bounds().min, right.x_bounds().min);
|
|
PLOT_CHECK(figure.plot(0u).x_range(0u).min >= oldest - 1e-6);
|
|
PLOT_CHECK(figure.plot(0u).x_range(0u).max <= edge + 1e-6);
|
|
for (int step = 0; step < 3; ++step) {
|
|
left.push(0.5f);
|
|
left.acquire();
|
|
figure.layout();
|
|
PLOT_CHECK_NEAR(decades_of(figure.plot(0u)), 2.0, 1e-9);
|
|
PLOT_CHECK_NEAR(decades_of(figure.plot(1u)), 2.0, 1e-9);
|
|
PLOT_CHECK(figure.plot(0u).x_range(0u).min >= oldest - 1e-6);
|
|
}
|
|
PLOT_CHECK(figure.x_link_follow(group) == plot::XFollow::Live);
|
|
}
|
|
|
|
// ------------------------------------------------- the crosshair (spec §15)
|
|
|
|
namespace {
|
|
|
|
/// The figure the crosshair cases hover: three rows at deliberately *different* sample rates on
|
|
/// the one shared x, plus a fourth whose data does not reach the hovered x at all. Rows that
|
|
/// sample at different rates are the point -- one x, and each row reporting a sample off its own
|
|
/// grid -- and the last row is the case a readout must not invent a value for.
|
|
plot::Figure crosshair_figure() {
|
|
plot::Figure figure(4u);
|
|
figure.set_viewport(whole_figure());
|
|
static plot::data::ArraySeries fast(std::vector<plot::f32>{0.0f, 10.0f, 20.0f, 30.0f, 40.0f,
|
|
50.0f, 60.0f, 70.0f, 80.0f, 90.0f},
|
|
0.0, 1.0);
|
|
static plot::data::ArraySeries slow(std::vector<plot::f32>{0.0f, 100.0f, 200.0f, 300.0f,
|
|
400.0f},
|
|
0.0, 2.0);
|
|
static plot::data::ArraySeries elsewhere(std::vector<plot::f32>{5.0f, 6.0f, 7.0f}, 100.0, 1.0);
|
|
static plot::render::EmbeddedMonoFont font;
|
|
const plot::data::Series* const series[4] = {&fast, &slow, nullptr, &elsewhere};
|
|
const char* const names[4] = {"fast", "slow", "pending", "elsewhere"};
|
|
for (plot::u32 row = 0; row < 4u; ++row) {
|
|
plot::Plot& p = figure.plot(row);
|
|
p.set_text_backend(&font);
|
|
p.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false});
|
|
p.add_y_axis({"v", {0.0, 400.0}, false, plot::AxisPlace::Left, false});
|
|
p.add_trace({series[row], plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u, names[row]});
|
|
}
|
|
return figure;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
/// Spec §15, the sync: `Figure::set_crosshair()` applies **one** data x to every row, so one
|
|
/// hover is one instant and N readouts rather than N independent ones. Rows at different sample
|
|
/// rates therefore report *their own* nearest sample to the same x -- the shared axis is x, and
|
|
/// each row's series is read on its own grid -- while a row whose series does not reach that x
|
|
/// reports no value at all instead of a fabricated one.
|
|
PLOT_TEST(figure_crosshair_applies_one_x_to_every_row) {
|
|
plot::Figure figure = crosshair_figure();
|
|
figure.layout();
|
|
|
|
figure.set_crosshair(3.0, true);
|
|
for (plot::u32 row = 0; row < 4u; ++row) {
|
|
PLOT_CHECK(figure.plot(row).crosshair().active);
|
|
PLOT_CHECK_NEAR(figure.plot(row).crosshair().x, 3.0, 1e-12);
|
|
}
|
|
// Laid out per row, so each row's readout is the one it would have on its own -- and the rows
|
|
// are their own plots' layouts, which is where a renderer reads them.
|
|
figure.layout();
|
|
|
|
// Row 0 samples every unit: x = 3 is sample 3, y = 30.
|
|
const plot::CrosshairLayout& first = figure.plot(0u).layout().crosshair;
|
|
PLOT_CHECK(first.active);
|
|
PLOT_CHECK(first.rows.size() == 1u);
|
|
PLOT_CHECK(first.rows[0].valid);
|
|
PLOT_CHECK(first.rows[0].label == "fast");
|
|
PLOT_CHECK_NEAR(first.rows[0].y, 30.0, 1e-9);
|
|
|
|
// Row 1 samples every *two* units: x = 3 is halfway between sample 1 (x = 2, y = 100) and
|
|
// sample 2 (x = 4, y = 200), which is the tie, so it reports sample 1's 100 -- a different
|
|
// number for the same x, which is what makes this case about the rows rather than about one
|
|
// series read four times. Not 150: a readout quotes a sample, and this row has none at x = 3.
|
|
const plot::CrosshairLayout& second = figure.plot(1u).layout().crosshair;
|
|
PLOT_CHECK(second.rows.size() == 1u);
|
|
PLOT_CHECK(second.rows[0].valid);
|
|
PLOT_CHECK(second.rows[0].label == "slow");
|
|
PLOT_CHECK_NEAR(second.rows[0].y, 100.0, 1e-9);
|
|
PLOT_CHECK(second.rows[0].value != first.rows[0].value);
|
|
|
|
// Row 2's trace has no series yet: no row at all -- it is not a signal under the pointer.
|
|
const plot::CrosshairLayout& third = figure.plot(2u).layout().crosshair;
|
|
PLOT_CHECK(third.active);
|
|
PLOT_CHECK(third.rows.empty());
|
|
PLOT_CHECK(third.panel.w == 0.0f);
|
|
|
|
// Row 3's series starts at x = 100: it is a visible signal with nothing at x = 3, so it is
|
|
// listed with **no number** rather than one clamped to the end of its window -- and the absence
|
|
// is said out loud with the documented placeholder, not left as a blank cell that would read as a
|
|
// value still to come.
|
|
const plot::CrosshairLayout& fourth = figure.plot(3u).layout().crosshair;
|
|
PLOT_CHECK(fourth.rows.size() == 1u);
|
|
PLOT_CHECK(fourth.rows[0].trace == 0u);
|
|
PLOT_CHECK(fourth.rows[0].label == "elsewhere");
|
|
PLOT_CHECK(!fourth.rows[0].valid);
|
|
PLOT_CHECK(fourth.rows[0].value == plot::kCrosshairNoValue);
|
|
PLOT_CHECK(fourth.rows[0].marker.x == 0.0f && fourth.rows[0].marker.y == 0.0f); // no intersection
|
|
// ... and it is still listed, with a value column, so the row's presence and its shape do not
|
|
// depend on having a sample there.
|
|
PLOT_CHECK(fourth.rows[0].listed);
|
|
PLOT_CHECK(fourth.rows[0].value_box.w > 0.0f);
|
|
PLOT_CHECK(fourth.rows[0].value_box.right() <= fourth.panel.right());
|
|
|
|
// The crosshair is a data coordinate, so the x is the *same* whether or not the rows sample
|
|
// there -- moving it moves every row, and the rows that have data report their own value at
|
|
// the new x.
|
|
figure.set_crosshair(1.0, true);
|
|
figure.layout();
|
|
for (plot::u32 row = 0; row < 4u; ++row) {
|
|
PLOT_CHECK_NEAR(figure.plot(row).crosshair().x, 1.0, 1e-12);
|
|
}
|
|
PLOT_CHECK_NEAR(figure.plot(0u).layout().crosshair.rows[0].y, 10.0, 1e-9);
|
|
// Not 50: x = 1 is the tie between this row's samples at x = 0 (y = 0) and x = 2 (y = 100),
|
|
// and a readout quotes one of them.
|
|
PLOT_CHECK_NEAR(figure.plot(1u).layout().crosshair.rows[0].y, 0.0, 1e-9);
|
|
PLOT_CHECK(!figure.plot(3u).layout().crosshair.rows[0].valid);
|
|
|
|
// On *every* row at once, and only the rows free it.
|
|
figure.clear_crosshair();
|
|
figure.layout();
|
|
for (plot::u32 row = 0; row < 4u; ++row) {
|
|
PLOT_CHECK(!figure.plot(row).crosshair().active);
|
|
PLOT_CHECK(figure.plot(row).layout().crosshair.rows.empty());
|
|
}
|
|
|
|
// A coordinate that is not a number is refused by every row's `Plot::set_crosshair()`, so a
|
|
// figure handed one keeps the crosshair it had rather than clearing some rows and not others.
|
|
figure.set_crosshair(2.0, true);
|
|
figure.set_crosshair(std::nan(""), true);
|
|
for (plot::u32 row = 0; row < 4u; ++row) {
|
|
PLOT_CHECK(figure.plot(row).crosshair().active);
|
|
PLOT_CHECK_NEAR(figure.plot(row).crosshair().x, 2.0, 1e-12);
|
|
}
|
|
}
|
|
|
|
/// Spec §17, the reported defect: a figure's crosshair showed **no horizontal line** because its
|
|
/// sync was vertical-only. The fix is not a figure-wide y -- which would mean a different value in
|
|
/// every row -- but the *pointer row's own* y: the shared x goes to every row, and the horizontal
|
|
/// line to the hovered row alone, where a y is a value of that row's own scale.
|
|
///
|
|
/// The rows here carry **different** y ranges on purpose, so "the hovered row's y" and "a sibling's
|
|
/// y" are genuinely different pixel rows; the exclusivity asserted below is therefore about the
|
|
/// fix and not a coincidence of equal axes.
|
|
PLOT_TEST(figure_crosshair_draws_a_horizontal_line_only_in_the_hovered_row) {
|
|
plot::Figure figure(3u);
|
|
figure.set_viewport(plot::Rect{0.0f, 0.0f, 640.0f, 480.0f});
|
|
static plot::data::ArraySeries ramp(std::vector<plot::f32>{0.0f, 10.0f, 20.0f, 30.0f, 40.0f,
|
|
50.0f, 60.0f, 70.0f, 80.0f, 90.0f},
|
|
0.0, 1.0);
|
|
static plot::render::EmbeddedMonoFont font;
|
|
const plot::Range y_range[3] = {{0.0, 100.0}, {0.0, 1000.0}, {-50.0, 50.0}};
|
|
for (plot::u32 row = 0; row < 3u; ++row) {
|
|
plot::Plot& p = figure.plot(row);
|
|
p.set_text_backend(&font);
|
|
p.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false});
|
|
p.add_y_axis({"v", y_range[row], false, plot::AxisPlace::Left, false});
|
|
p.add_trace({&ramp, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u, "ramp"});
|
|
}
|
|
figure.layout();
|
|
|
|
// The pointer in row 1 at y = 150: inside row 1's [0, 1000] scale, and *outside* rows 0 and 2.
|
|
figure.set_crosshair(3.0, 1u, 150.0, true);
|
|
figure.layout();
|
|
|
|
// Every row carries the shared x's vertical line, all at one pixel column; no row but the
|
|
// hovered one draws a horizontal line at all (a zero-sized box, not a line at y = 0).
|
|
plot::f32 column = 0.0f;
|
|
for (plot::u32 row = 0; row < 3u; ++row) {
|
|
const plot::CrosshairLayout& c = figure.plot(row).layout().crosshair;
|
|
PLOT_CHECK(c.active);
|
|
PLOT_CHECK(c.vertical.w > 0.0f);
|
|
PLOT_CHECK(c.vertical.h > 0.0f);
|
|
if (row == 0u) {
|
|
column = c.vertical.x;
|
|
} else {
|
|
PLOT_CHECK_NEAR(c.vertical.x, column, 1e-6);
|
|
}
|
|
if (row != 1u) {
|
|
PLOT_CHECK(c.horizontal.w == 0.0f);
|
|
PLOT_CHECK(c.horizontal.h == 0.0f);
|
|
}
|
|
}
|
|
|
|
// Row 1 draws it, at *its own* y: the stroke box is centred where 150 maps on row 1's y axis.
|
|
const plot::CrosshairLayout& hovered = figure.plot(1u).layout().crosshair;
|
|
PLOT_CHECK(hovered.horizontal.w > 0.0f);
|
|
PLOT_CHECK(hovered.horizontal.h > 0.0f);
|
|
const plot::Transform ty1 = figure.plot(1u).y_transform(0u);
|
|
const plot::f32 row1_at = hovered.horizontal.y + 0.5f * hovered.horizontal.h;
|
|
PLOT_CHECK_NEAR(row1_at, static_cast<plot::f32>(ty1.to_screen_y(150.0)), 0.75);
|
|
|
|
// The pointer moves to row 2: the horizontal line moves *there*, to row 2's own y, and the
|
|
// vertical lines are left exactly where they were -- one hover, one row's y, N rows' x.
|
|
figure.set_crosshair(3.0, 2u, 0.0, true);
|
|
figure.layout();
|
|
PLOT_CHECK(figure.plot(1u).layout().crosshair.horizontal.h == 0.0f);
|
|
PLOT_CHECK(figure.plot(1u).layout().crosshair.horizontal.w == 0.0f);
|
|
const plot::CrosshairLayout& second = figure.plot(2u).layout().crosshair;
|
|
PLOT_CHECK(second.horizontal.h > 0.0f);
|
|
const plot::Transform ty2 = figure.plot(2u).y_transform(0u);
|
|
const plot::f32 row2_at = second.horizontal.y + 0.5f * second.horizontal.h;
|
|
PLOT_CHECK_NEAR(row2_at, static_cast<plot::f32>(ty2.to_screen_y(0.0)), 0.75);
|
|
PLOT_CHECK(row2_at != row1_at); // a different row of the figure, not the same line re-placed
|
|
for (plot::u32 row = 0; row < 3u; ++row) {
|
|
PLOT_CHECK_NEAR(figure.plot(row).layout().crosshair.vertical.x, column, 1e-6);
|
|
}
|
|
|
|
// A row that does not exist -- the pointer in the gap between rows -- is "no row under the
|
|
// pointer": every row keeps the vertical line and none gets a horizontal one.
|
|
figure.set_crosshair(3.0, 9u, 10.0, true);
|
|
figure.layout();
|
|
for (plot::u32 row = 0; row < 3u; ++row) {
|
|
const plot::CrosshairLayout& c = figure.plot(row).layout().crosshair;
|
|
PLOT_CHECK(c.vertical.w > 0.0f);
|
|
PLOT_CHECK(c.horizontal.h == 0.0f);
|
|
}
|
|
|
|
// The two-argument overload stays vertical-only on every row -- a caller with only a coordinate
|
|
// gets no horizontal line, because there is a y it never gave.
|
|
figure.set_crosshair(3.0, true);
|
|
figure.layout();
|
|
for (plot::u32 row = 0; row < 3u; ++row) {
|
|
const plot::CrosshairLayout& c = figure.plot(row).layout().crosshair;
|
|
PLOT_CHECK(c.vertical.w > 0.0f);
|
|
PLOT_CHECK(c.horizontal.h == 0.0f);
|
|
}
|
|
|
|
// A y that is not a number leaves the hovered row vertical-only rather than clearing the whole
|
|
// hover: the pointer is still in that row, it just has no y to mark.
|
|
figure.set_crosshair(3.0, 1u, std::nan(""), true);
|
|
figure.layout();
|
|
PLOT_CHECK(figure.plot(1u).layout().crosshair.vertical.w > 0.0f);
|
|
PLOT_CHECK(figure.plot(1u).layout().crosshair.horizontal.h == 0.0f);
|
|
}
|
|
|
|
// ------------------------------------------------------------------ cell grid
|
|
|
|
namespace {
|
|
|
|
/// A figure whose rows hold `cells[r]` plots each (the flattened plots in row-major
|
|
/// order), with an x and a y axis on every plot unless `axes` is off.
|
|
plot::Figure split_figure(const std::vector<plot::u32>& cells, bool axes = true) {
|
|
plot::Figure figure{std::span<const plot::u32>(cells)}; // braces: parentheses would be a vexing parse
|
|
figure.set_viewport(whole_figure());
|
|
if (axes) {
|
|
for (plot::u32 i = 0; i < figure.plot_count(); ++i) {
|
|
figure.plot(i).add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
|
|
figure.plot(i).add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
|
}
|
|
}
|
|
return figure;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
PLOT_TEST(figure_cells_all_ones_equals_rows) {
|
|
plot::Figure split = split_figure({1, 1, 1});
|
|
plot::Figure plain = figure_with_axes(3u);
|
|
const plot::FigureLayout& sl = split.layout();
|
|
const plot::FigureLayout& pl = plain.layout();
|
|
PLOT_CHECK(split.row_count() == 3u && split.plot_count() == 3u);
|
|
PLOT_CHECK(sl.cells.size() == 3u);
|
|
for (plot::u32 i = 0; i < 3u; ++i) {
|
|
PLOT_CHECK_NEAR(sl.cells[i].y, pl.rows[i].y, 0.01);
|
|
PLOT_CHECK_NEAR(sl.cells[i].h, pl.rows[i].h, 0.01);
|
|
PLOT_CHECK_NEAR(sl.cells[i].w, pl.rows[i].w, 0.01);
|
|
}
|
|
}
|
|
|
|
PLOT_TEST(figure_cells_tile_exactly) {
|
|
plot::Figure figure = split_figure({1, 2, 1});
|
|
figure.set_col_gap(10.0f);
|
|
const plot::FigureLayout& fl = figure.layout();
|
|
PLOT_CHECK(figure.row_count() == 3u && figure.plot_count() == 4u);
|
|
PLOT_CHECK(figure.cells_in_row(1u) == 2u);
|
|
PLOT_CHECK(figure.first_plot_of_row(1u) == 1u && figure.first_plot_of_row(3u) == 4u);
|
|
PLOT_CHECK(figure.row_of_plot(2u) == 1u);
|
|
PLOT_CHECK_NEAR(fl.cells[2].x - fl.cells[1].right(), 10.0, 0.01);
|
|
PLOT_CHECK_NEAR(fl.cells[2].right(), fl.rows[1].right(), 0.001);
|
|
PLOT_CHECK_NEAR(fl.cells[1].x, fl.rows[1].x, 0.001);
|
|
PLOT_CHECK_NEAR(fl.cells[1].y, fl.cells[2].y, 0.001);
|
|
PLOT_CHECK_NEAR(fl.cells[1].h, fl.cells[2].h, 0.001);
|
|
PLOT_CHECK_NEAR(fl.cells[1].w, fl.cells[2].w, 0.01);
|
|
PLOT_CHECK_NEAR(fl.col_gap, 10.0, 0.001);
|
|
}
|
|
|
|
PLOT_TEST(figure_cell_weights_and_floor) {
|
|
plot::Figure figure = split_figure({2});
|
|
figure.set_col_gap(0.0f);
|
|
const std::vector<plot::f32> w{3.0f, 1.0f};
|
|
PLOT_CHECK(figure.set_cell_weights(0u, w));
|
|
PLOT_CHECK(figure.has_cell_weights(0u));
|
|
{
|
|
const auto& c = figure.layout().cells;
|
|
PLOT_CHECK_NEAR((c[0].w - 64.0), (c[1].w - 64.0) * 3.0, 0.01);
|
|
}
|
|
const std::vector<plot::f32> tiny{1000.0f, 0.001f};
|
|
PLOT_CHECK(figure.set_cell_weights(0u, tiny));
|
|
PLOT_CHECK(figure.layout().cells[1].w >= 64.0f - 0.001f);
|
|
const std::vector<plot::f32> three{1.0f, 1.0f, 1.0f};
|
|
const std::vector<plot::f32> zero{1.0f, 0.0f};
|
|
PLOT_CHECK(!figure.set_cell_weights(0u, three));
|
|
PLOT_CHECK(!figure.set_cell_weights(0u, zero));
|
|
PLOT_CHECK(!figure.set_cell_weights(5u, w));
|
|
figure.clear_cell_weights(0u);
|
|
PLOT_CHECK(!figure.has_cell_weights(0u));
|
|
const auto& c = figure.layout().cells;
|
|
PLOT_CHECK_NEAR(c[0].w, c[1].w, 0.01);
|
|
}
|
|
|
|
PLOT_TEST(figure_cells_narrow_degrades) {
|
|
plot::Figure figure = split_figure({4}, false);
|
|
figure.set_viewport(plot::Rect{0.0f, 0.0f, 20.0f, 100.0f});
|
|
for (plot::u32 i = 0; i < 4u; ++i) {
|
|
figure.plot(i).add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
|
|
figure.plot(i).add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
|
}
|
|
figure.set_col_gap(10.0f);
|
|
const plot::FigureLayout& fl = figure.layout();
|
|
double sum = 0.0;
|
|
for (const plot::Rect& c : fl.cells) {
|
|
PLOT_CHECK(c.w >= 0.0f && c.h >= 0.0f);
|
|
PLOT_CHECK_NEAR(c.h, 100.0, 0.01);
|
|
sum += c.w;
|
|
}
|
|
PLOT_CHECK_NEAR(sum, 20.0, 0.01);
|
|
PLOT_CHECK_NEAR(fl.col_gap, 0.0, 0.001);
|
|
}
|
|
|
|
PLOT_TEST(figure_cells_share_x_link_and_crosshair) {
|
|
plot::Figure figure = split_figure({2, 1});
|
|
const plot::u32 group = figure.add_x_link();
|
|
for (plot::u32 i = 0; i < 3u; ++i) PLOT_CHECK(figure.link_x(i, group));
|
|
figure.plot(1u).set_x_range(0u, {3.0, 9.0});
|
|
figure.layout();
|
|
for (plot::u32 i = 0; i < 3u; ++i) {
|
|
PLOT_CHECK_NEAR(figure.plot(i).x_range(0u).min, 3.0, 1e-9);
|
|
PLOT_CHECK_NEAR(figure.plot(i).x_range(0u).max, 9.0, 1e-9);
|
|
}
|
|
figure.set_crosshair(4.0, true);
|
|
for (plot::u32 i = 0; i < 3u; ++i) {
|
|
PLOT_CHECK(figure.plot(i).crosshair().active);
|
|
PLOT_CHECK_NEAR(figure.plot(i).crosshair().x, 4.0, 1e-9);
|
|
}
|
|
figure.clear_crosshair();
|
|
for (plot::u32 i = 0; i < 3u; ++i) PLOT_CHECK(!figure.plot(i).crosshair().active);
|
|
}
|
|
|
|
PLOT_TEST(figure_cells_x_labels_bottom_row_only) {
|
|
// Rows with the same columns share the bottom one's axis: {1,1,1} labels the last row only.
|
|
plot::Figure figure = split_figure({1, 1, 1});
|
|
const plot::FigureLayout& fl = figure.layout();
|
|
PLOT_CHECK(figure.plot(0u).layout().x_label_area.h == 0.0f);
|
|
PLOT_CHECK(figure.plot(1u).layout().x_label_area.h == 0.0f);
|
|
PLOT_CHECK(figure.plot(2u).layout().x_label_area.h > 0.0f);
|
|
PLOT_CHECK_NEAR(fl.x_axis_strip.h, figure.plot(2u).layout().x_label_area.h, 0.001);
|
|
}
|
|
|
|
PLOT_TEST(figure_cells_common_y_gutter) {
|
|
plot::Figure figure = split_figure({2}, false);
|
|
give_magnitude_row(figure.plot(0u), 0u);
|
|
give_magnitude_row(figure.plot(1u), 1u);
|
|
const plot::FigureLayout& fl = figure.layout();
|
|
const double g0 = figure.plot(0u).layout().axes.x - fl.cells[0].x;
|
|
const double g1 = figure.plot(1u).layout().axes.x - fl.cells[1].x;
|
|
PLOT_CHECK(g0 > 0.0);
|
|
PLOT_CHECK_NEAR(g0, g1, 0.01);
|
|
}
|
|
|
|
PLOT_TEST(figure_row_y_cells_share_a_range_after_a_gesture) {
|
|
plot::Figure figure = split_figure({2, 1});
|
|
figure.set_share_row_y(true);
|
|
figure.layout();
|
|
figure.plot(1u).set_y_range(0u, {-3.0, 7.0}); // what a wheel / drag / pan ends in
|
|
figure.layout();
|
|
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).min, -3.0, 1e-9);
|
|
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).max, 7.0, 1e-9);
|
|
PLOT_CHECK_NEAR(figure.plot(1u).y_range(0u).min, -3.0, 1e-9);
|
|
PLOT_CHECK_NEAR(figure.plot(1u).y_range(0u).max, 7.0, 1e-9);
|
|
// The unsplit row is its own thing.
|
|
PLOT_CHECK_NEAR(figure.plot(2u).y_range(0u).min, -1.0, 1e-9);
|
|
PLOT_CHECK(!figure.row_y_auto(0u));
|
|
}
|
|
|
|
PLOT_TEST(figure_row_y_auto_fits_the_union_and_ignores_empty_cells) {
|
|
plot::Figure figure = split_figure({3});
|
|
for (plot::u32 i = 0; i < 3u; ++i) figure.plot(i).set_y_auto_scale(0u, true);
|
|
plot::data::ArraySeries a(std::vector<plot::f32>{0.0f, 10.0f, 5.0f}, 0.0, 1.0);
|
|
plot::data::ArraySeries b(std::vector<plot::f32>{-20.0f, 5.0f, 0.0f}, 0.0, 1.0);
|
|
figure.plot(0u).add_trace({&a, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
|
|
figure.plot(1u).add_trace({&b, plot::Color::hex(0xFF8080), 1.5f, 0u, 0u});
|
|
figure.set_share_row_y(true); // cell 2 has no trace
|
|
figure.layout();
|
|
PLOT_CHECK(figure.row_y_auto(0u));
|
|
const plot::Range r0 = figure.plot(0u).y_range(0u);
|
|
PLOT_CHECK(r0.min <= -20.0 && r0.max >= 10.0);
|
|
PLOT_CHECK(r0.max - r0.min < 40.0); // padded, not blown up
|
|
for (plot::u32 i = 1; i < 3u; ++i) {
|
|
PLOT_CHECK_NEAR(figure.plot(i).y_range(0u).min, r0.min, 1e-9);
|
|
PLOT_CHECK_NEAR(figure.plot(i).y_range(0u).max, r0.max, 1e-9);
|
|
}
|
|
}
|
|
|
|
PLOT_TEST(figure_row_y_manual_then_fit_returns_to_auto) {
|
|
plot::Figure figure = split_figure({2});
|
|
for (plot::u32 i = 0; i < 2u; ++i) figure.plot(i).set_y_auto_scale(0u, true);
|
|
plot::data::ArraySeries a(std::vector<plot::f32>{0.0f, 10.0f}, 0.0, 1.0);
|
|
plot::data::ArraySeries b(std::vector<plot::f32>{-4.0f, 4.0f}, 0.0, 1.0);
|
|
figure.plot(0u).add_trace({&a, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
|
|
figure.plot(1u).add_trace({&b, plot::Color::hex(0xFF8080), 1.5f, 0u, 0u});
|
|
figure.set_share_row_y(true);
|
|
figure.layout();
|
|
figure.plot(1u).set_y_range(0u, {1.0, 2.0});
|
|
figure.layout();
|
|
PLOT_CHECK(!figure.row_y_auto(0u));
|
|
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).min, 1.0, 1e-9);
|
|
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).max, 2.0, 1e-9);
|
|
figure.layout(); // manual holds: no fight with the plots' own autoscale
|
|
PLOT_CHECK_NEAR(figure.plot(1u).y_range(0u).max, 2.0, 1e-9);
|
|
figure.plot(0u).auto_fit_y(0u); // what fit-all / double-click does to one cell
|
|
figure.layout();
|
|
PLOT_CHECK(figure.row_y_auto(0u));
|
|
PLOT_CHECK(figure.plot(1u).y_range(0u).min <= -4.0 && figure.plot(1u).y_range(0u).max >= 10.0);
|
|
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).min, figure.plot(1u).y_range(0u).min, 1e-9);
|
|
}
|
|
|
|
PLOT_TEST(figure_row_y_sharing_off_keeps_cells_independent) {
|
|
plot::Figure figure = split_figure({2});
|
|
figure.layout();
|
|
figure.plot(1u).set_y_range(0u, {-3.0, 7.0});
|
|
figure.layout();
|
|
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).max, 1.0, 1e-9);
|
|
PLOT_CHECK(!figure.share_row_y());
|
|
}
|
|
|
|
PLOT_TEST(figure_row_y_only_the_first_cell_has_a_gutter) {
|
|
plot::Figure figure = split_figure({1, 3}, false);
|
|
for (plot::u32 i = 0; i < figure.plot_count(); ++i) give_magnitude_row(figure.plot(i), 0u);
|
|
figure.set_share_row_y(true);
|
|
figure.layout();
|
|
PLOT_CHECK(figure.plot(0u).layout().y_axis_width > 0.0f); // unsplit row keeps its axis
|
|
PLOT_CHECK(figure.plot(1u).layout().y_axis_width > 0.0f); // first cell of the split row
|
|
for (plot::u32 i = 2; i < 4u; ++i) {
|
|
const plot::PlotLayout& l = figure.plot(i).layout();
|
|
PLOT_CHECK(l.y_axis_width == 0.0f && l.y_title_width == 0.0f && l.y_label_area.w == 0.0f);
|
|
PLOT_CHECK(l.axes.w > 0.0f);
|
|
}
|
|
// The gutter is one figure-wide value: the unsplit row and the split row's first cell agree.
|
|
PLOT_CHECK_NEAR(figure.plot(0u).layout().axes.x - figure.layout().cells[0].x,
|
|
figure.plot(1u).layout().axes.x - figure.layout().cells[1].x, 0.01);
|
|
}
|
|
|
|
PLOT_TEST(figure_row_y_equal_weights_give_equal_plot_widths_and_cells_tile) {
|
|
plot::Figure figure = split_figure({3}, false);
|
|
for (plot::u32 i = 0; i < 3u; ++i) give_magnitude_row(figure.plot(i), 0u);
|
|
figure.set_col_gap(8.0f);
|
|
figure.set_share_row_y(true);
|
|
const plot::FigureLayout& fl = figure.layout();
|
|
const double w0 = figure.plot(0u).layout().axes.w;
|
|
PLOT_CHECK_NEAR(figure.plot(1u).layout().axes.w, w0, 0.6);
|
|
PLOT_CHECK_NEAR(figure.plot(2u).layout().axes.w, w0, 0.6);
|
|
PLOT_CHECK_NEAR(fl.cells[0].x, fl.rows[0].x, 0.001);
|
|
PLOT_CHECK_NEAR(fl.cells[2].right(), fl.rows[0].right(), 0.001);
|
|
PLOT_CHECK_NEAR(fl.cells[1].x - fl.cells[0].right(), 8.0, 0.01);
|
|
PLOT_CHECK_NEAR(fl.cells[2].x - fl.cells[1].right(), 8.0, 0.01);
|
|
}
|
|
|
|
PLOT_TEST(figure_row_y_weights_apply_to_plot_widths) {
|
|
plot::Figure figure = split_figure({2}, false);
|
|
for (plot::u32 i = 0; i < 2u; ++i) give_magnitude_row(figure.plot(i), 0u);
|
|
figure.set_col_gap(0.0f);
|
|
figure.set_share_row_y(true);
|
|
const std::vector<plot::f32> w{3.0f, 1.0f};
|
|
PLOT_CHECK(figure.set_cell_weights(0u, w));
|
|
figure.layout();
|
|
const double a0 = figure.plot(0u).layout().axes.w;
|
|
const double a1 = figure.plot(1u).layout().axes.w;
|
|
PLOT_CHECK_NEAR(a0 - 64.0, (a1 - 64.0) * 3.0, 1.0);
|
|
}
|
|
|
|
PLOT_TEST(figure_row_y_narrow_row_degrades_without_negative_rects) {
|
|
plot::Figure figure = split_figure({4}, false);
|
|
figure.set_viewport(plot::Rect{0.0f, 0.0f, 30.0f, 100.0f});
|
|
for (plot::u32 i = 0; i < 4u; ++i) give_magnitude_row(figure.plot(i), 0u);
|
|
figure.set_col_gap(10.0f);
|
|
figure.set_share_row_y(true);
|
|
double sum = 0.0;
|
|
for (const plot::Rect& c : figure.layout().cells) {
|
|
PLOT_CHECK(c.w >= 0.0f && c.h >= 0.0f);
|
|
sum += c.w;
|
|
}
|
|
PLOT_CHECK_NEAR(sum, 30.0, 0.05);
|
|
}
|
|
|
|
PLOT_TEST(figure_row_y_sharing_off_still_gives_every_cell_a_gutter) {
|
|
plot::Figure figure = split_figure({2}, false);
|
|
give_magnitude_row(figure.plot(0u), 0u);
|
|
give_magnitude_row(figure.plot(1u), 1u);
|
|
figure.layout();
|
|
PLOT_CHECK(figure.plot(1u).layout().y_axis_width > 0.0f);
|
|
}
|
|
|
|
// ------------------------------------------------- x axes follow the columns
|
|
|
|
namespace {
|
|
|
|
/// A split figure with a text backend and a y axis of a real magnitude on every plot (so the
|
|
/// shared y gutter is non-zero), the host's configuration: row-shared y, a 6 px column gap and
|
|
/// a 35 px right gutter.
|
|
plot::Figure host_like_figure(const std::vector<plot::u32>& cells, bool share_y = true) {
|
|
plot::Figure figure = split_figure(cells, false);
|
|
for (plot::u32 i = 0; i < figure.plot_count(); ++i) give_magnitude_row(figure.plot(i), i);
|
|
figure.set_col_gap(6.0f);
|
|
figure.set_right_gutter(35.0f);
|
|
figure.set_share_row_y(share_y);
|
|
return figure;
|
|
}
|
|
|
|
bool shows_x_axis(plot::Figure& figure, plot::u32 plot_index) {
|
|
const plot::PlotLayout& l = figure.plot(plot_index).layout();
|
|
return l.x_axis_height > 0.0f && l.x_label_area.h > 0.0f;
|
|
}
|
|
|
|
bool hides_x_axis(plot::Figure& figure, plot::u32 plot_index) {
|
|
const plot::PlotLayout& l = figure.plot(plot_index).layout();
|
|
return l.x_axis_height == 0.0f && l.x_label_area.h == 0.0f;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
PLOT_TEST(figure_x_axis_a_split_row_over_an_unsplit_one_keeps_its_own) {
|
|
plot::Figure figure = host_like_figure({2, 1});
|
|
figure.layout();
|
|
PLOT_CHECK(shows_x_axis(figure, 0u));
|
|
PLOT_CHECK(shows_x_axis(figure, 1u));
|
|
PLOT_CHECK(shows_x_axis(figure, 2u));
|
|
// ... and the other way round.
|
|
plot::Figure other = host_like_figure({1, 2});
|
|
other.layout();
|
|
PLOT_CHECK(shows_x_axis(other, 0u));
|
|
PLOT_CHECK(shows_x_axis(other, 1u));
|
|
PLOT_CHECK(shows_x_axis(other, 2u));
|
|
}
|
|
|
|
PLOT_TEST(figure_x_axis_rows_with_the_same_columns_share_the_bottom_axis) {
|
|
plot::Figure figure = host_like_figure({2, 2});
|
|
figure.layout();
|
|
PLOT_CHECK(hides_x_axis(figure, 0u));
|
|
PLOT_CHECK(hides_x_axis(figure, 1u));
|
|
PLOT_CHECK(shows_x_axis(figure, 2u));
|
|
PLOT_CHECK(shows_x_axis(figure, 3u));
|
|
// The hidden row's cells have exactly the x extents of the cells below, so the axis below
|
|
// serves them.
|
|
const plot::FigureLayout& fl = figure.layout();
|
|
for (plot::u32 c = 0; c < 2u; ++c) {
|
|
PLOT_CHECK_NEAR(fl.cells[c].x, fl.cells[2u + c].x, 1e-4);
|
|
PLOT_CHECK_NEAR(fl.cells[c].w, fl.cells[2u + c].w, 1e-4);
|
|
const plot::PlotLayout& up = figure.plot(c).layout();
|
|
const plot::PlotLayout& down = figure.plot(2u + c).layout();
|
|
PLOT_CHECK_NEAR(up.axes.x, down.axes.x, 1e-4);
|
|
PLOT_CHECK_NEAR(up.axes.w, down.axes.w, 1e-4);
|
|
PLOT_CHECK_NEAR(up.y_axis_width, down.y_axis_width, 1e-4);
|
|
}
|
|
// The same without row-shared y (every cell has its own y strip).
|
|
plot::Figure own_y = host_like_figure({2, 2}, false);
|
|
own_y.layout();
|
|
PLOT_CHECK(hides_x_axis(own_y, 0u) && hides_x_axis(own_y, 1u));
|
|
PLOT_CHECK(shows_x_axis(own_y, 2u) && shows_x_axis(own_y, 3u));
|
|
for (plot::u32 c = 0; c < 2u; ++c) {
|
|
PLOT_CHECK_NEAR(own_y.plot(c).layout().axes.x, own_y.plot(2u + c).layout().axes.x, 1e-4);
|
|
PLOT_CHECK_NEAR(own_y.plot(c).layout().axes.w, own_y.plot(2u + c).layout().axes.w, 1e-4);
|
|
}
|
|
}
|
|
|
|
PLOT_TEST(figure_x_axis_matching_compares_normalised_weights) {
|
|
const std::vector<plot::f32> one_three{1.0f, 3.0f};
|
|
const std::vector<plot::f32> two_six{2.0f, 6.0f};
|
|
const std::vector<plot::f32> halves{1.0f, 1.0f};
|
|
{ // 1:3 over 1:3
|
|
plot::Figure figure = host_like_figure({2, 2});
|
|
PLOT_CHECK(figure.set_cell_weights(0u, one_three));
|
|
PLOT_CHECK(figure.set_cell_weights(1u, one_three));
|
|
figure.layout();
|
|
PLOT_CHECK(hides_x_axis(figure, 0u) && hides_x_axis(figure, 1u));
|
|
PLOT_CHECK(shows_x_axis(figure, 2u) && shows_x_axis(figure, 3u));
|
|
const plot::FigureLayout& fl = figure.layout();
|
|
PLOT_CHECK_NEAR(fl.cells[0].w, fl.cells[2].w, 1e-3);
|
|
PLOT_CHECK_NEAR(figure.plot(1u).layout().axes.x, figure.plot(3u).layout().axes.x, 1e-3);
|
|
}
|
|
{ // 1:3 over 2:6 -- normalised, so the same columns
|
|
plot::Figure figure = host_like_figure({2, 2});
|
|
PLOT_CHECK(figure.set_cell_weights(0u, one_three));
|
|
PLOT_CHECK(figure.set_cell_weights(1u, two_six));
|
|
figure.layout();
|
|
PLOT_CHECK(hides_x_axis(figure, 0u) && hides_x_axis(figure, 1u));
|
|
}
|
|
{ // 1:3 over 1:1
|
|
plot::Figure figure = host_like_figure({2, 2});
|
|
PLOT_CHECK(figure.set_cell_weights(0u, one_three));
|
|
PLOT_CHECK(figure.set_cell_weights(1u, halves));
|
|
figure.layout();
|
|
PLOT_CHECK(shows_x_axis(figure, 0u) && shows_x_axis(figure, 1u));
|
|
PLOT_CHECK(shows_x_axis(figure, 2u) && shows_x_axis(figure, 3u));
|
|
}
|
|
{ // 1:3 over (no weights = equal)
|
|
plot::Figure figure = host_like_figure({2, 2});
|
|
PLOT_CHECK(figure.set_cell_weights(0u, one_three));
|
|
figure.layout();
|
|
PLOT_CHECK(shows_x_axis(figure, 0u) && shows_x_axis(figure, 1u));
|
|
}
|
|
{ // explicit 1:1 over (no weights = equal)
|
|
plot::Figure figure = host_like_figure({2, 2});
|
|
PLOT_CHECK(figure.set_cell_weights(0u, halves));
|
|
figure.layout();
|
|
PLOT_CHECK(hides_x_axis(figure, 0u) && hides_x_axis(figure, 1u));
|
|
}
|
|
{ // a different number of cells never matches, whatever the weights
|
|
plot::Figure figure = host_like_figure({3, 2});
|
|
figure.layout();
|
|
PLOT_CHECK(shows_x_axis(figure, 0u) && shows_x_axis(figure, 2u));
|
|
}
|
|
}
|
|
|
|
PLOT_TEST(figure_x_axis_unsplit_rows_are_unchanged) {
|
|
plot::Figure figure = host_like_figure({1, 1, 1});
|
|
figure.layout();
|
|
PLOT_CHECK(hides_x_axis(figure, 0u));
|
|
PLOT_CHECK(hides_x_axis(figure, 1u));
|
|
PLOT_CHECK(shows_x_axis(figure, 2u));
|
|
PLOT_CHECK_NEAR(figure.layout().x_axis_strip.h, figure.plot(2u).layout().x_label_area.h, 1e-4);
|
|
}
|
|
|
|
PLOT_TEST(figure_x_axis_follows_a_chain_of_matching_rows) {
|
|
{ // [2]/[2]/[1]: row 0 matches row 1 and hides; row 1 does not match row 2 and shows.
|
|
plot::Figure figure = host_like_figure({2, 2, 1});
|
|
figure.layout();
|
|
PLOT_CHECK(hides_x_axis(figure, 0u) && hides_x_axis(figure, 1u));
|
|
PLOT_CHECK(shows_x_axis(figure, 2u) && shows_x_axis(figure, 3u));
|
|
PLOT_CHECK(shows_x_axis(figure, 4u));
|
|
}
|
|
{ // [2]/[2]/[2]: transitive -- only the last row carries the axis.
|
|
plot::Figure figure = host_like_figure({2, 2, 2});
|
|
figure.layout();
|
|
for (plot::u32 i = 0; i < 4u; ++i) PLOT_CHECK(hides_x_axis(figure, i));
|
|
PLOT_CHECK(shows_x_axis(figure, 4u) && shows_x_axis(figure, 5u));
|
|
}
|
|
{ // [1]/[2]/[2]: the unsplit row matches nothing below it.
|
|
plot::Figure figure = host_like_figure({1, 2, 2});
|
|
figure.layout();
|
|
PLOT_CHECK(shows_x_axis(figure, 0u));
|
|
PLOT_CHECK(hides_x_axis(figure, 1u) && hides_x_axis(figure, 2u));
|
|
PLOT_CHECK(shows_x_axis(figure, 3u) && shows_x_axis(figure, 4u));
|
|
}
|
|
{ // the figure's x_axis_strip is still the last plot's label area
|
|
plot::Figure figure = host_like_figure({2, 1});
|
|
const plot::FigureLayout& fl = figure.layout();
|
|
PLOT_CHECK(fl.x_axis_strip.h > 0.0f);
|
|
PLOT_CHECK_NEAR(fl.x_axis_strip.h, figure.plot(2u).layout().x_label_area.h, 1e-4);
|
|
}
|
|
}
|
|
|
|
PLOT_TEST(figure_x_axis_labels_on_every_row_when_asked) {
|
|
plot::Figure figure = host_like_figure({2, 2, 1});
|
|
figure.set_x_labels_only_on_bottom(false);
|
|
figure.layout();
|
|
for (plot::u32 i = 0; i < figure.plot_count(); ++i) PLOT_CHECK(shows_x_axis(figure, i));
|
|
}
|
|
|
|
// -------------------------------------------- the gap between cells of a row
|
|
|
|
PLOT_TEST(figure_split_row_gap_between_plot_frames_is_the_column_gap) {
|
|
for (const plot::u32 n : {2u, 3u, 4u}) {
|
|
plot::Figure figure = host_like_figure({n});
|
|
const plot::FigureLayout& fl = figure.layout();
|
|
PLOT_CHECK_NEAR(fl.col_gap, 6.0, 1e-4);
|
|
for (plot::u32 k = 0; k + 1u < n; ++k) {
|
|
PLOT_CHECK_NEAR(figure.plot(k + 1u).layout().axes.x - figure.plot(k).layout().axes.right(),
|
|
6.0, 1e-3);
|
|
PLOT_CHECK_NEAR(fl.cells[k + 1u].x - fl.cells[k].right(), 6.0, 1e-3);
|
|
// Only the last cell holds the right gutter.
|
|
PLOT_CHECK_NEAR(figure.plot(k).layout().right_gutter, 0.0, 1e-6);
|
|
}
|
|
// Equal weights give equal axes widths, the first cell included.
|
|
for (plot::u32 k = 1; k < n; ++k) {
|
|
PLOT_CHECK_NEAR(figure.plot(k).layout().axes.w, figure.plot(0u).layout().axes.w, 0.6);
|
|
}
|
|
// The cells tile the row exactly.
|
|
PLOT_CHECK_NEAR(fl.cells[0].x, fl.rows[0].x, 1e-4);
|
|
PLOT_CHECK_NEAR(fl.cells[n - 1u].right(), fl.rows[0].right(), 1e-3);
|
|
// The right gutter is still there, on the last cell.
|
|
const plot::PlotLayout& last = figure.plot(n - 1u).layout();
|
|
PLOT_CHECK_NEAR(fl.cells[n - 1u].right() - last.axes.right(), 35.0, 1e-3);
|
|
PLOT_CHECK_NEAR(last.right_gutter, 35.0, 1e-3);
|
|
// The y gutter still sits on the first cell alone.
|
|
PLOT_CHECK(figure.plot(0u).layout().axes.x > fl.cells[0].x);
|
|
PLOT_CHECK_NEAR(figure.plot(1u).layout().axes.x, fl.cells[1].x, 1e-4);
|
|
}
|
|
}
|
|
|
|
PLOT_TEST(figure_split_row_gap_with_weights_and_a_y_gutter) {
|
|
plot::Figure figure = host_like_figure({1, 2});
|
|
const std::vector<plot::f32> w{3.0f, 1.0f};
|
|
PLOT_CHECK(figure.set_cell_weights(1u, w));
|
|
const plot::FigureLayout& fl = figure.layout();
|
|
const plot::Rect a0 = figure.plot(1u).layout().axes;
|
|
const plot::Rect a1 = figure.plot(2u).layout().axes;
|
|
PLOT_CHECK_NEAR(a1.x - a0.right(), 6.0, 1e-3);
|
|
PLOT_CHECK_NEAR(a0.w - 64.0, (a1.w - 64.0) * 3.0, 1.0);
|
|
PLOT_CHECK_NEAR(fl.cells[2].right(), fl.rows[1].right(), 1e-3);
|
|
PLOT_CHECK_NEAR(fl.cells[1].x, fl.rows[1].x, 1e-4);
|
|
PLOT_CHECK_NEAR(fl.cells[2].right() - a1.right(), 35.0, 1e-3);
|
|
// The unsplit row above keeps its own right gutter on its one plot.
|
|
PLOT_CHECK_NEAR(fl.cells[0].right() - figure.plot(0u).layout().axes.right(), 35.0, 1e-3);
|
|
}
|
|
|
|
PLOT_TEST(figure_split_row_gutters_degrade_on_a_narrow_row) {
|
|
plot::Figure figure = host_like_figure({3});
|
|
figure.set_viewport(plot::Rect{0.0f, 0.0f, 60.0f, 100.0f});
|
|
const plot::FigureLayout& fl = figure.layout();
|
|
double sum = 0.0;
|
|
for (const plot::Rect& c : fl.cells) {
|
|
PLOT_CHECK(c.w >= 0.0f && c.h >= 0.0f);
|
|
sum += c.w;
|
|
}
|
|
PLOT_CHECK_NEAR(sum + fl.col_gap * 2.0, 60.0, 0.05);
|
|
PLOT_CHECK_NEAR(fl.cells[2].right(), 60.0, 0.05);
|
|
for (plot::u32 i = 0; i < 3u; ++i) PLOT_CHECK(figure.plot(i).layout().axes.w >= 0.0f);
|
|
}
|
|
|
|
PLOT_TEST(figure_row_gutter_without_sharing_is_the_old_layout) {
|
|
plot::Figure figure = host_like_figure({2}, false);
|
|
const plot::FigureLayout& fl = figure.layout();
|
|
// Plain cells: equal halves of the row less the gap, every plot with its own right gutter.
|
|
PLOT_CHECK_NEAR(fl.cells[0].w, (fl.rows[0].w - 6.0) / 2.0, 1e-3);
|
|
PLOT_CHECK_NEAR(fl.cells[1].w, (fl.rows[0].w - 6.0) / 2.0, 1e-3);
|
|
PLOT_CHECK_NEAR(fl.cells[1].x - fl.cells[0].right(), 6.0, 1e-4);
|
|
for (plot::u32 i = 0; i < 2u; ++i) {
|
|
PLOT_CHECK_NEAR(figure.plot(i).layout().right_gutter, 35.0, 1e-3);
|
|
PLOT_CHECK_NEAR(fl.cells[i].right() - figure.plot(i).layout().axes.right(), 35.0, 1e-3);
|
|
}
|
|
// An unsplit row is unchanged by sharing being on.
|
|
plot::Figure unsplit = host_like_figure({1, 1});
|
|
const plot::FigureLayout& ul = unsplit.layout();
|
|
for (plot::u32 i = 0; i < 2u; ++i) {
|
|
PLOT_CHECK_NEAR(ul.cells[i].w, ul.rows[i].w, 1e-4);
|
|
PLOT_CHECK_NEAR(ul.cells[i].right() - unsplit.plot(i).layout().axes.right(), 35.0, 1e-3);
|
|
}
|
|
}
|