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

+381
View File
@@ -1809,3 +1809,384 @@ PLOT_TEST(non_finite_region_bounds_are_refused) {
PLOT_CHECK(p.region_count() == before + 1u);
PLOT_CHECK_NEAR(p.region(before).x0, 30.0, 1e-12);
}
// ------------------------------------------------- the crosshair (spec §15)
namespace {
/// A series of hand-computable values -- `y[i] == 10 * i` -- so every interpolation the cases
/// below assert can be written down as a number rather than recomputed by a formula the
/// implementation might share with the test.
std::vector<f32> tens(u64 n) {
std::vector<f32> y(n);
for (u64 i = 0; i < n; ++i) y[i] = f32(10.0 * static_cast<f64>(i));
return y;
}
/// The crosshair's own scene: four `tens` samples on x = 0..3, a [0, 30] y axis, a text
/// backend to measure the readout's rows with, and a viewport with room to place a panel in.
struct CrosshairScene {
plot::render::EmbeddedMonoFont font;
plot::data::ArraySeries series;
plot::Plot plot;
u32 ax = 0;
u32 ay = 0;
CrosshairScene() : series(tens(4), 0.0, 1.0) {
plot.set_text_backend(&font);
plot.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
ax = plot.add_x_axis({"t", {0.0, 3.0}, false, plot::AxisPlace::Bottom, false});
ay = plot.add_y_axis({"v", {0.0, 30.0}, false, plot::AxisPlace::Left, false});
}
};
/// A series that counts what a reader asked it for.
///
/// The crosshair's lookup must be O(1) -- index arithmetic and at most two sample reads -- and
/// "it does not scan" is only observable through the one path a raw sample can be read from:
/// `Series::copy_raw()`, which `gather()` reaches. This is a `Series` the test owns, so the
/// count is the test's and not a counter the implementation maintains about itself: long
/// enough (a quarter of a million samples) that a walk over the data would be unmistakable in
/// it, whatever path the walk took.
class CountingSeries final : public plot::data::Series {
public:
CountingSeries(u64 n, f64 x0, f64 dx) : samples_(n), x0_(x0), dx_(dx) {
for (u64 i = 0; i < n; ++i) samples_[i] = f32(i % 100u);
pyramid_.reserve(n);
for (const f32 v : samples_) pyramid_.append(v);
}
u64 size() const override { return samples_.size(); }
f64 x0() const override { return x0_; }
f64 dx() const override { return dx_; }
plot::Range x_bounds() const override {
return {x0_, x0_ + static_cast<f64>(samples_.size() - 1u) * dx_};
}
plot::Range y_bounds() const override { return {0.0, 99.0}; }
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out) const override {
++gather_calls_;
samples_read_ += end - begin;
gather_impl(level, begin, end, out);
}
u64 gather_calls() const noexcept { return gather_calls_; }
u64 samples_read() const noexcept { return samples_read_; }
protected:
void copy_raw(u64 begin, u64 end, f32* out) const override {
for (u64 i = begin; i < end && i < samples_.size(); ++i) out[i - begin] = samples_[i];
}
private:
std::vector<f32> samples_;
f64 x0_ = 0.0;
f64 dx_ = 1.0;
plot::data::MinMaxPyramid pyramid_;
mutable u64 gather_calls_ = 0;
mutable u64 samples_read_ = 0;
};
} // namespace
/// Spec §15, the lookup: a readout's y is **linear interpolation between the two samples
/// adjacent to the crosshair's x**, and hand-computed here rather than checked against the
/// implementation's own arithmetic. The four corners of the rule are all asserted: a query
/// between two samples, the two exact-sample ends of the window (where the span collapses onto
/// one sample), and a one-sample series, whose window is that sample alone.
PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) {
CrosshairScene scene;
const u32 trace = scene.plot.add_trace(
{&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay});
// The span itself, for a query halfway between sample 1 (x = 1, y = 10) and sample 2
// (x = 2, y = 20): the two neighbours, and the fraction between them. Asserting the span and
// not only the value is what pins the convention -- a nearest-sample lookup would report the
// span's `t` as 0 or 1, and an average of a *range* would not report a span at all.
const plot::CrosshairSpan half = plot::crosshair_span(scene.series, 1.5);
PLOT_CHECK(half.valid);
PLOT_CHECK(half.begin == 1u);
PLOT_CHECK(half.end == 2u);
PLOT_CHECK_NEAR(half.t, 0.5, 1e-12);
f64 y = 0.0;
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.5, y));
PLOT_CHECK_NEAR(y, 15.0, 1e-9);
// A quarter of the way through another pair: 20 + (30 - 20) * 0.25.
PLOT_CHECK(scene.plot.crosshair_value(trace, 2.25, y));
PLOT_CHECK_NEAR(y, 22.5, 1e-9);
// The ends of the window are samples too, so a query exactly on one reads that sample --
// and reads it *as* a span of one, with nothing to its left or right to interpolate with.
const plot::CrosshairSpan first = plot::crosshair_span(scene.series, 0.0);
PLOT_CHECK(first.valid);
PLOT_CHECK(first.begin == 0u);
PLOT_CHECK(first.end == 0u);
PLOT_CHECK(first.t == 0.0);
PLOT_CHECK(scene.plot.crosshair_value(trace, 0.0, y));
PLOT_CHECK_NEAR(y, 0.0, 1e-9);
const plot::CrosshairSpan last = plot::crosshair_span(scene.series, 3.0);
PLOT_CHECK(last.valid);
PLOT_CHECK(last.begin == 3u);
PLOT_CHECK(last.end == 3u);
PLOT_CHECK(scene.plot.crosshair_value(trace, 3.0, y));
PLOT_CHECK_NEAR(y, 30.0, 1e-9);
// Outside the window there is no value at all -- not the nearest sample's, not the end of
// the window's, which are the clamped lies the header refuses -- and a series with no
// samples has no span at any x.
PLOT_CHECK(!plot::crosshair_span(scene.series, -1e-9).valid);
PLOT_CHECK(!plot::crosshair_span(scene.series, 3.0 + 1e-9).valid);
PLOT_CHECK(!scene.plot.crosshair_value(trace, 4.0, y));
PLOT_CHECK(!scene.plot.crosshair_value(trace, -1.0, y));
plot::data::ArraySeries empty(std::vector<f32>{}, 0.0, 1.0);
PLOT_CHECK(!plot::crosshair_span(empty, 0.0).valid);
const u32 empty_trace = scene.plot.add_trace(
{&empty, plot::Color::hex(0x00E070), 1.5f, scene.ax, scene.ay, "quiet"});
PLOT_CHECK(!scene.plot.crosshair_value(empty_trace, 1.0, y));
PLOT_CHECK(!scene.plot.crosshair_value(/*trace=*/99u, 1.0, y)); // an id that names nothing
// A one-sample series: the sample itself is the only query it answers, and it answers it
// without a neighbour to interpolate against.
plot::data::ArraySeries single(std::vector<f32>{7.0f}, 5.0, 1.0);
plot::Plot one;
one.add_x_axis({"t", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false});
one.add_y_axis({"v", {0.0, 10.0}, false, plot::AxisPlace::Left, false});
const u32 single_trace =
one.add_trace({&single, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
const plot::CrosshairSpan only = plot::crosshair_span(single, 5.0);
PLOT_CHECK(only.valid);
PLOT_CHECK(only.begin == 0u);
PLOT_CHECK(only.end == 0u);
PLOT_CHECK(one.crosshair_value(single_trace, 5.0, y));
PLOT_CHECK_NEAR(y, 7.0, 1e-9);
PLOT_CHECK(!one.crosshair_value(single_trace, 5.5, y));
// The readout's row is the same lookup: the number a user reads off the panel is the value
// this test computed by hand, not a second derivation of it.
scene.plot.set_crosshair(1.5, true);
const plot::CrosshairLayout& crosshair = scene.plot.layout().crosshair;
PLOT_CHECK(crosshair.active);
// One row: the sampled trace. The named-but-empty series added above is *not* a row -- a trace
// with no samples is not a signal under the pointer, so the readout lists nothing for it
// (a series that has samples but not *at this x* is the other case: a row with no value, which
// the figure's case pins).
PLOT_CHECK(crosshair.rows.size() == 1u);
PLOT_CHECK(crosshair.rows[0].valid);
PLOT_CHECK_NEAR(crosshair.rows[0].y, 15.0, 1e-9);
PLOT_CHECK(crosshair.rows[0].value == "15");
}
/// Spec §15, the readout's membership: **every visible trace gets a row, whether or not it is
/// named** -- the readout is about what is under the pointer, so the legend's rule of skipping
/// unnamed traces does not apply -- while a trace with no series or no samples is not under the
/// pointer and is not listed. An unnamed trace's row carries a positional label, so the rows
/// stay identifiable without a name being invented for it.
PLOT_TEST(crosshair_readout_lists_every_visible_trace_including_unnamed_ones) {
CrosshairScene scene;
plot::data::ArraySeries empty(std::vector<f32>{}, 0.0, 1.0);
scene.plot.add_trace({&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay,
"sine"});
scene.plot.add_trace({&scene.series, plot::Color::hex(0x00E070), 1.5f, scene.ax, scene.ay, ""});
scene.plot.add_trace({nullptr, plot::Color::hex(0xFF00FF), 1.5f, scene.ax, scene.ay, "pending"});
scene.plot.add_trace({&empty, plot::Color::hex(0xFFB020), 1.5f, scene.ax, scene.ay, "quiet"});
scene.plot.set_crosshair(1.5, true);
const plot::CrosshairLayout& crosshair = scene.plot.layout().crosshair;
PLOT_CHECK(crosshair.active);
PLOT_CHECK(crosshair.rows.size() == 2u);
// In trace order, with the ids the plot handed out: the named trace and the unnamed one.
PLOT_CHECK(crosshair.rows[0].trace == 0u);
PLOT_CHECK(crosshair.rows[0].label == "sine");
PLOT_CHECK(crosshair.rows[1].trace == 1u);
// The placeholder is positional and derived from the id alone, so the same trace keeps the
// same caption frame after frame -- and it is not the legend's behaviour of disappearing.
PLOT_CHECK(crosshair.rows[1].label == plot::crosshair_unnamed_label(1u));
PLOT_CHECK(crosshair.rows[1].label == "#1");
// Both are listed with the value their own series has at x = 1.5, the swatch, the label's box
// and the value's box -- all placed by the plot, all inside the panel it measured.
PLOT_CHECK(crosshair.panel.w > 0.0f && crosshair.panel.h > 0.0f);
for (const plot::CrosshairRow& row : crosshair.rows) {
PLOT_CHECK(row.valid);
PLOT_CHECK(row.listed);
PLOT_CHECK_NEAR(row.y, 15.0, 1e-9);
PLOT_CHECK(row.value == "15");
PLOT_CHECK(row.swatch.x >= crosshair.panel.x);
PLOT_CHECK(row.swatch.right() <= crosshair.panel.right());
PLOT_CHECK(row.swatch.y >= crosshair.panel.y);
PLOT_CHECK(row.swatch.bottom() <= crosshair.panel.bottom());
PLOT_CHECK(row.label_box.x >= crosshair.panel.x + row.swatch.w);
PLOT_CHECK(row.label_box.right() <= crosshair.panel.right());
PLOT_CHECK(row.value_box.w > 0.0f);
PLOT_CHECK(row.value_box.right() <= crosshair.panel.right());
PLOT_CHECK(row.value_box.x >= row.label_box.right());
}
// The two rows' swatches are their own traces' colours, so a row can be told apart from its
// neighbour -- and the panel is wide enough for the widest row's label, gutter and value.
PLOT_CHECK(crosshair.rows[1].swatch.w == crosshair.rows[0].swatch.w);
PLOT_CHECK(crosshair.rows[1].swatch.y > crosshair.rows[0].swatch.y);
PLOT_CHECK(crosshair.rows[1].label_box.x == crosshair.rows[0].label_box.x);
PLOT_CHECK(crosshair.rows[1].value_box.right() == crosshair.rows[0].value_box.right());
// Clearing the crosshair empties the whole geometry -- nothing of it is left for a renderer
// to stage -- and re-placing it at another x moves the panel with the row it lists.
scene.plot.clear_crosshair();
const plot::CrosshairLayout& cleared = scene.plot.layout().crosshair;
PLOT_CHECK(!cleared.active);
PLOT_CHECK(cleared.rows.empty());
PLOT_CHECK(cleared.panel.w == 0.0f && cleared.panel.h == 0.0f);
PLOT_CHECK(cleared.vertical.w == 0.0f && cleared.horizontal.h == 0.0f);
// A crosshair with the readout switched off still draws its lines -- and lists nothing.
scene.plot.set_crosshair(2.0, true);
plot::Crosshair no_readout = scene.plot.crosshair();
PLOT_CHECK(no_readout.active);
PLOT_CHECK(!no_readout.horizontal); // the one-coordinate form draws no row
PLOT_CHECK(no_readout.show_readout);
}
/// Spec §15, the panel's placement: near the pointer, flipped over it where it would otherwise
/// cross an edge, and clamped -- so it can never leave `axes`. A panel that did leave would be
/// drawn over a label strip, which is the defect Task 1 fixed, so all four corners are walked
/// and every piece of the panel's geometry is asserted against the rect it was measured for.
PLOT_TEST(crosshair_panel_stays_inside_the_axes_at_all_four_edges) {
CrosshairScene scene;
scene.plot.add_trace({&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay,
"a long signal name"});
scene.plot.add_trace({&scene.series, plot::Color::hex(0x00E070), 1.5f, scene.ax, scene.ay, ""});
scene.plot.add_trace({&scene.series, plot::Color::hex(0xFF00FF), 1.5f, scene.ax, scene.ay,
"third"});
const plot::Rect axes = scene.plot.layout().axes;
const plot::Transform tx = scene.plot.x_transform(scene.ax);
const plot::Transform ty = scene.plot.y_transform(scene.ay);
// A pointer one pixel inside each corner of the data area, as its *data* coordinates -- the
// form the crosshair takes -- so the four cases are the four edges a panel could fall off.
const struct {
const char* name;
f32 px;
f32 py;
} corners[4] = {
{"top left", axes.x + 1.0f, axes.y + 1.0f},
{"top right", axes.right() - 1.0f, axes.y + 1.0f},
{"bottom left", axes.x + 1.0f, axes.bottom() - 1.0f},
{"bottom right", axes.right() - 1.0f, axes.bottom() - 1.0f},
};
for (const auto& corner : corners) {
scene.plot.set_crosshair(tx.to_data_x(corner.px), ty.to_data_y(corner.py), true);
const plot::CrosshairLayout& crosshair = scene.plot.layout().crosshair;
std::printf("[crosshair] %s: panel %.1f,%.1f %.1fx%.1f in axes %.1f,%.1f %.1fx%.1f\n",
corner.name, static_cast<double>(crosshair.panel.x),
static_cast<double>(crosshair.panel.y), static_cast<double>(crosshair.panel.w),
static_cast<double>(crosshair.panel.h), static_cast<double>(axes.x),
static_cast<double>(axes.y), static_cast<double>(axes.w),
static_cast<double>(axes.h));
// There is a real panel to place, with all three rows listed: the corner is not a case the
// fit dropped out of.
PLOT_CHECK(crosshair.panel.w > 0.0f);
PLOT_CHECK(crosshair.panel.h > 0.0f);
u32 listed = 0;
for (const plot::CrosshairRow& row : crosshair.rows) {
if (row.listed) ++listed;
}
PLOT_CHECK(listed == 3u);
// Inside the axes, half-open on the far edges: the panel's own rect and every box in it.
PLOT_CHECK(crosshair.panel.x >= axes.x);
PLOT_CHECK(crosshair.panel.y >= axes.y);
PLOT_CHECK(crosshair.panel.right() <= axes.right());
PLOT_CHECK(crosshair.panel.bottom() <= axes.bottom());
for (const plot::CrosshairRow& row : crosshair.rows) {
PLOT_CHECK(row.swatch.x >= axes.x && row.swatch.right() <= axes.right());
PLOT_CHECK(row.swatch.y >= axes.y && row.swatch.bottom() <= axes.bottom());
PLOT_CHECK(row.label_box.x >= axes.x && row.label_box.right() <= axes.right());
PLOT_CHECK(row.value_box.x >= axes.x && row.value_box.right() <= axes.right());
}
// The two lines are inside it as well, stroke and all -- which is what leaves the clip
// nothing to trim but the sub-pixel a fractional edge rasterises outside the rect.
PLOT_CHECK(crosshair.vertical.x >= axes.x);
PLOT_CHECK(crosshair.vertical.right() <= axes.right());
PLOT_CHECK(crosshair.vertical.y >= axes.y);
PLOT_CHECK(crosshair.vertical.bottom() <= axes.bottom());
PLOT_CHECK(crosshair.horizontal.x >= axes.x);
PLOT_CHECK(crosshair.horizontal.right() <= axes.right());
PLOT_CHECK(crosshair.horizontal.y >= axes.y);
PLOT_CHECK(crosshair.horizontal.bottom() <= axes.bottom());
// Near the pointer, on the side the flip chose: down and to the right of it where there is
// room, and *over* it to the left or up where there is not -- so the panel is beside the
// crosshair rather than pinned to a corner whenever the crosshair is in that half.
const bool flip_x = corner.px > axes.x + 0.5f * axes.w;
const bool flip_y = corner.py > axes.y + 0.5f * axes.h;
if (flip_x) {
PLOT_CHECK(crosshair.panel.right() <= corner.px);
} else {
PLOT_CHECK(crosshair.panel.x >= corner.px);
}
if (flip_y) {
PLOT_CHECK(crosshair.panel.bottom() <= corner.py);
} else {
PLOT_CHECK(crosshair.panel.y >= corner.py);
}
}
}
/// Spec §15's cost requirement: the lookup runs on **every pointer move**, so it is index
/// arithmetic plus at most two sample reads and never a scan. The count is taken from the one
/// path a raw sample can be read through, over a series long enough that a walk would be
/// unmistakable -- and the values are asserted too, so a lookup that read nothing and invented
/// a number cannot pass by being cheap.
PLOT_TEST(crosshair_lookup_does_not_scan) {
constexpr u64 kSamples = 250'000u;
CountingSeries series(kSamples, 0.0, 1.0);
plot::Plot p;
p.add_x_axis({"t", {0.0, static_cast<f64>(kSamples - 1u)}, false, plot::AxisPlace::Bottom,
false});
p.add_y_axis({"v", {0.0, 99.0}, false, plot::AxisPlace::Left, false});
const u32 trace = p.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
// A query between two samples: 1234 % 100 = 34, the next is 35, halfway between them.
f64 y = 0.0;
const u64 before_reads = series.samples_read();
PLOT_CHECK(p.crosshair_value(trace, 1234.5, y));
PLOT_CHECK_NEAR(y, 34.5, 1e-9);
PLOT_CHECK(series.samples_read() - before_reads <= 2u);
PLOT_CHECK(series.gather_calls() <= 1u);
// The same bound at a query on a sample, at the far end of the series, and at both ends of
// the window: what varies is the index, never the amount of data touched. A lookup that
// scanned would read `kSamples` samples here, four orders of magnitude past the bound.
const f64 queries[6] = {0.0, 1.0, 99'999.5, static_cast<f64>(kSamples - 1u),
static_cast<f64>(kSamples) - 1.5, 987'654.321};
for (const f64 x : queries) {
const u64 reads = series.samples_read();
const u64 calls = series.gather_calls();
const bool found = p.crosshair_value(trace, x, y);
if (x <= static_cast<f64>(kSamples - 1u) + 1e-9) {
PLOT_CHECK(found);
} else {
PLOT_CHECK(!found); // past the newest sample: no value, and nothing read for one
}
PLOT_CHECK(series.samples_read() - reads <= 2u);
PLOT_CHECK(series.gather_calls() - calls <= 1u);
}
// A whole readout is a lookup per *visible trace*, not per sample: the process's readout for
// this one-trace plot stays inside the same bound, which is what keeps a frame's cost
// proportional to the number of signals rather than to their length.
p.set_crosshair(1234.5, true);
const u64 reads = series.samples_read();
const u64 calls = series.gather_calls();
const plot::CrosshairLayout& crosshair = p.layout().crosshair;
PLOT_CHECK(crosshair.rows.size() == 1u);
PLOT_CHECK(crosshair.rows[0].valid);
PLOT_CHECK_NEAR(crosshair.rows[0].y, 34.5, 1e-9);
PLOT_CHECK(series.samples_read() - reads <= 2u);
PLOT_CHECK(series.gather_calls() - calls <= 1u);
// And the readout is not vacuous: the series it read two samples of really does hold a
// quarter of a million of them.
PLOT_CHECK(series.size() == kSamples);
}