test(plot): pin the crosshair's lookup, readout, placement, sync and clip

This commit is contained in:
Martino Ferrari committed 2026-09-28 19:07:22 +02:00
1 parent fd0c471430
commit 8e27e22ebe
6 files changed
+798 -17

No files matched your search

+118
View File
@@ -10,6 +10,7 @@
#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>
@@ -572,3 +573,120 @@ PLOT_TEST(a_following_group_advances_all_rows_together) {
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 value** rather than a value clamped to the end of its window.
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.empty());
// ... and it is still listed, so the row's presence does not depend on having a value.
PLOT_CHECK(fourth.rows[0].listed);
PLOT_CHECK(fourth.rows[0].value_box.w == 0.0f);
// 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);
}
}