// 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 #include #include #include #include #include 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 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 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(row.x), 0.0, 1e-6); PLOT_CHECK_NEAR(static_cast(row.w), static_cast(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(rows[0].y), 0.0, 1e-6); for (std::size_t i = 1; i < rows.size(); ++i) { PLOT_CHECK_NEAR(static_cast(rows[i].y), static_cast(rows[i - 1].bottom()), 1e-6); } PLOT_CHECK_NEAR(static_cast(rows.back().bottom()), static_cast(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(layout.x_axis_strip.h), static_cast(bottom.x_label_area.h), 1e-6); PLOT_CHECK_NEAR(static_cast(layout.x_axis_strip.x), static_cast(bottom.x_label_area.x), 1e-6); PLOT_CHECK_NEAR(static_cast(layout.x_axis_strip.y), static_cast(bottom.x_label_area.y), 1e-6); PLOT_CHECK_NEAR(static_cast(layout.x_axis_strip.w), static_cast(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 spaced = rows_of(figure); PLOT_CHECK(spaced.size() == 3u); PLOT_CHECK_NEAR(static_cast(spaced[1].y - spaced[0].bottom()), 10.0, 1e-6); PLOT_CHECK_NEAR(static_cast(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(spaced.back().bottom()), static_cast(kH), 1e-6); PLOT_CHECK_NEAR(static_cast(figure.layout().x_axis_strip.bottom()), static_cast(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 equal = rows_of(figure); PLOT_CHECK_NEAR(static_cast(equal[0].h), static_cast(equal[1].h), 1e-4); PLOT_CHECK_NEAR(static_cast(equal[1].h), static_cast(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 rows = layout.rows; PLOT_CHECK(rows[0].h > rows[1].h); PLOT_CHECK_NEAR(static_cast(rows[1].h), static_cast(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 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(figure.layout().rows[0].h), static_cast(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(total), static_cast(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 scaled = rows_of(figure); for (std::size_t i = 0; i < rows.size(); ++i) { PLOT_CHECK_NEAR(static_cast(scaled[i].h), static_cast(rows[i].h), 1e-4); } // Back to an equal division. figure.clear_row_weights(); PLOT_CHECK(!figure.has_row_weights()); const std::vector again = rows_of(figure); for (std::size_t i = 0; i < again.size(); ++i) { PLOT_CHECK_NEAR(static_cast(again[i].h), static_cast(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 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 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(after[i].h), static_cast(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 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(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 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(rows[2].h), 24.0 * static_cast(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(axes.x), static_cast(first.x), 1e-4); PLOT_CHECK_NEAR(static_cast(axes.w), static_cast(first.w), 1e-4); PLOT_CHECK_NEAR(static_cast(axes.right()), static_cast(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(axes.y), static_cast(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(layout.x_axis_strip.x), static_cast(bottom.x_label_area.x), 1e-4); PLOT_CHECK_NEAR(static_cast(layout.x_axis_strip.w), static_cast(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(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(p.x_transform(0u).to_screen_x(37.5)); PLOT_CHECK_NEAR(static_cast(x), static_cast(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(p.x_transform(0u).to_screen_x(0.0)), static_cast(p.layout().axes.x), 1e-3); PLOT_CHECK_NEAR(static_cast(p.x_transform(0u).to_screen_x(100.0)), static_cast(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(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(wide.y_axis_width + wide.y_title_width), static_cast(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(p.min_y_gutter()), 0.0, 1e-12); const plot::PlotLayout& back = p.layout(); PLOT_CHECK_NEAR(static_cast(back.y_axis_width + back.y_title_width), static_cast(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(axes.x), static_cast(first.x), 1e-4); PLOT_CHECK_NEAR(static_cast(axes.w), static_cast(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(figure.plot(0u).layout().axes.x), static_cast(solo.layout().axes.x), 1e-4); PLOT_CHECK_NEAR(static_cast(figure.plot(0u).layout().axes.w), static_cast(solo.layout().axes.w), 1e-4); PLOT_CHECK_NEAR(static_cast(figure.plot(0u).layout().y_axis_width), static_cast(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(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(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(std::sin(i * 0.1))); } for (int i = 0; i < 200; ++i) { right.push(static_cast(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(std::sin(step * 0.1))); } for (int i = 0; i < 2; ++i) { right.push(static_cast(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 one decade behind the edge, both of which // survive the next frames' advances. 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); PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).max, edge / 10.0, 1e-8); 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.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, 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{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{0.0f, 100.0f, 200.0f, 300.0f, 400.0f}, 0.0, 2.0); static plot::data::ArraySeries elsewhere(std::vector{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{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(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(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); }