Files
plotter/tests/test_figure.cpp
T
Martino Ferrari f8a364e963 feat(plot): the figure crosshair marks the hovered row, and the stroke is thin, dashed and configurable
The figure sync learns which row the pointer is in and that row's local y, so the
shared x goes to every row and the horizontal line only to the hovered one -- a
crosshair that finally marks a row where its y means something. The stroke gains
Style::crosshair_width/_dash_on/_dash_off (logical; 0.75 / 4 / 4), a per-Crosshair
override, and a fragment-shader dash on v_along (off == 0 is solid), anchored to the
axes edge each line is staged from so the phase does not crawl.
2026-09-28 20:29:09 +02:00

963 lines
47 KiB
C++

// `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 <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);
}
}
// ------------------------------------------------------- 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 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);
}
/// 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);
}
}
// ------------------------------------------------- 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, three different interpolations -- 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* interpolations of 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 (y = 100) and sample 2
// (y = 200), so it reports 150 -- a different number for the same x, which is what makes this
// case about the rows rather than about one series read four times.
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, 150.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);
PLOT_CHECK_NEAR(figure.plot(1u).layout().crosshair.rows[0].y, 50.0, 1e-9); // 0 + 100 * 0.5
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);
}