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

+24 -12
View File
@@ -170,7 +170,7 @@ class Renderer {
///
/// **The clip** is the one piece of state the frame moves to keep data inside the plot
/// area (spec §13): the passes that draw *inside* `axes` -- grid, traces, regions,
/// cursors, the measurement band, annotations and the legend -- run with the scissor
/// cursors, the measurement band, annotations, the legend and the crosshair -- run with the scissor
/// narrowed to `layout().axes`, intersected with the frame's viewport, while the axis
/// lines (chrome drawn on the rect's own edge, which overhangs it by half a stroke) and
/// the tick labels and titles (outside the rect by design) run with the frame's viewport
@@ -487,18 +487,30 @@ class Renderer {
/// them again, so the panel a test asserts and the panel this draws are the same
/// numbers.
void draw_legend(const Plot& plot, const PlotLayout& layout);
/// The crosshair: the two lines, a marker per trace, and the readout panel with a swatch and
/// a value per listed row. Every rect -- the two stroke boxes, the panel, the markers'
/// positions and the rows' boxes -- comes from `PlotLayout::crosshair`, which placed it
/// inside `axes`; this stages exactly that geometry, so the crosshair a test measures and the
/// crosshair a frame paints are one set of numbers. Clipped to `axes` with the rest of the
/// interior passes, which is what keeps a marker for an off-scale sample inside the rect.
/// The crosshair's **lines and markers**: the vertical and horizontal strokes and a small
/// cross at each trace's intersection.
///
/// The panel is a backplate like the legend's, so it is drawn after the lines and the
/// markers and before any of the frame's text. The rows' values were formatted by the plot
/// (in the trace's own y axis' format) and are staged here as they stand -- this layer
/// formats nothing.
void draw_crosshair(const Plot& plot, const PlotLayout& layout);
/// Every rect -- the two stroke boxes and the markers' positions -- comes from
/// `PlotLayout::crosshair`, which placed them inside `axes`; this stages exactly that
/// geometry, so the crosshair a test measures and the crosshair a frame paints are one set of
/// numbers. Clipped to `axes` with the rest of the interior passes, which is what keeps a
/// marker for a sample far outside the visible y range inside the rect instead of over a
/// label strip.
///
/// Drawn over the data and *under* the handles -- regions, cursors -- because the crosshair
/// sits wherever the pointer is, including on top of the handle a gesture is manipulating, and
/// a hover or grab highlight must not be hidden by the instrument that happens to share its
/// column. Its readout panel is the other half (`draw_crosshair_readout()`), drawn last.
void draw_crosshair_lines(const Plot& plot, const PlotLayout& layout);
/// The crosshair's **readout panel**: the backplate, its border and one swatch, label and
/// value per listed row, all read from `PlotLayout::crosshair` -- the plot formatted the
/// values (in each trace's own y axis' format) and placed every box, so this layer formats
/// nothing and derives no rect. A row that is not listed is skipped; a row whose trace has no
/// sample at the crosshair's x stages no value at all rather than a fabricated one.
///
/// Drawn after every other interior pass -- the legend included -- because it is what the
/// pointer is on: a readout the legend covered would be the feature failing at its one job.
void draw_crosshair_readout(const Plot& plot, const PlotLayout& layout);
/// The built-in backend the renderer owns, and the face the frames draw with:
/// `set_text_backend()` writes `font_`, which starts here and comes back here
+16 -5
View File
@@ -487,6 +487,12 @@ void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) {
draw_axis_lines(plot);
set_clip(layout.axes);
draw_traces(plot, layout);
// The crosshair's lines and markers over the data, but *under* the handles a pointer can
// grab: a hovered or grabbed cursor marker or region bound is feedback for a gesture in
// progress, and the crosshair -- which is drawn wherever the pointer is, including on top of
// the handle being grabbed -- must not hide it. Its readout panel, which is a backplate for
// text rather than a stroke, is drawn last of all (see below).
draw_crosshair_lines(plot, layout);
// Regions over the traces and under the cursors: an area of interest the data is
// still legible through, with the markers that bound it on top of it.
draw_regions(plot, layout);
@@ -494,10 +500,10 @@ void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) {
draw_measures(plot, layout);
draw_annotations(plot);
draw_legend(plot, layout);
// The crosshair last of the interior passes: it is what the pointer is on, so its panel is
// drawn over the legend and every other overlay rather than under them, and its text joins
// the frame's single flush with everything else.
draw_crosshair(plot, layout);
// The crosshair's readout last of the interior passes: it is what the pointer is on, so its
// panel is drawn over the legend and every other overlay rather than under them, and its text
// joins the frame's single flush with everything else.
draw_crosshair_readout(plot, layout);
clear_clip();
draw_tick_labels(plot, layout);
draw_titles(plot, layout);
@@ -1174,7 +1180,7 @@ void Renderer::draw_legend(const Plot& plot, const PlotLayout& layout) {
}
}
void Renderer::draw_crosshair(const Plot& plot, const PlotLayout& layout) {
void Renderer::draw_crosshair_lines(const Plot& plot, const PlotLayout& layout) {
const CrosshairLayout& crosshair = layout.crosshair;
if (!crosshair.active) return;
@@ -1213,6 +1219,11 @@ void Renderer::draw_crosshair(const Plot& plot, const PlotLayout& layout) {
lines_.add_segment({row.marker.x, row.marker.y - arm}, {row.marker.x, row.marker.y + arm});
flush_lines(pixel_mapping(), color, style_.device(1.0f));
}
}
void Renderer::draw_crosshair_readout(const Plot& plot, const PlotLayout& layout) {
const CrosshairLayout& crosshair = layout.crosshair;
if (!crosshair.active) return;
// The readout: the same backplate a legend has -- the frame's background at a partial
// alpha, so the data stays legible through it -- with its border in the crosshair's own
+85
View File
@@ -1640,3 +1640,88 @@ PLOT_TEST(a_figures_live_edge_hint_is_published_only_while_the_group_follows) {
PLOT_CHECK(solo.x_follow(0u) == plot::XFollow::Live);
PLOT_CHECK(!solo.x_live_owner(0u, edge));
}
// ------------------------------------------------- the crosshair (spec §15)
/// Spec §15, the wiring: a pointer over the data area places the crosshair at the data
/// coordinates under it, and a pointer that leaves the axes clears it. A press neither clears it
/// nor moves it away from the pointer -- it follows the pointer, and a pointer still over the
/// axes is still pointing at what it grabbed -- while a wheel, which moves the data without
/// moving the pointer, re-anchors it to the same pointer position rather than leaving it on the
/// value it was placed at.
PLOT_TEST(crosshair_tracks_motion_and_clears_on_leave) {
Fixture fx;
fx.plot.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
fx.plot.layout();
const plot::Rect axes = fx.plot.layout().axes;
const f32 cx = axes.x + 0.37f * axes.w;
const f32 cy = axes.y + 0.61f * axes.h;
// No pointer has been seen, so there is no crosshair: it is a hover, and a hover needs a
// pointer.
PLOT_CHECK(!fx.plot.crosshair().active);
// A motion inside the data area places it at the coordinates under the pointer, through the
// same transforms every gesture uses -- so the line, the value and the pointer cannot drift.
fx.controller.on_pointer({cx, cy, 0.0f, 0.0f, plot::input::Button::Left, 0u, false,
/*down=*/false, false});
PLOT_CHECK(fx.plot.crosshair().active);
PLOT_CHECK_NEAR(fx.plot.crosshair().x, fx.plot.x_transform(0u).to_data_x(cx), 1e-9);
PLOT_CHECK_NEAR(fx.plot.crosshair().y, fx.plot.y_transform(0u).to_data_y(cy), 1e-9);
// Both lines: a pointer over the axes knows its x and its y.
PLOT_CHECK(fx.plot.crosshair().vertical);
PLOT_CHECK(fx.plot.crosshair().horizontal);
// Moving re-anchors it, and a press keeps it: the pointer did not leave the axes.
const f32 moved_x = axes.x + 0.52f * axes.w;
fx.controller.on_pointer({moved_x, cy, moved_x - cx, 0.0f, plot::input::Button::Left, 0u, true,
true, false});
PLOT_CHECK(fx.plot.crosshair().active);
PLOT_CHECK_NEAR(fx.plot.crosshair().x, fx.plot.x_transform(0u).to_data_x(moved_x), 1e-9);
fx.controller.on_pointer({moved_x, cy, 0.0f, 0.0f, plot::input::Button::Left, 0u, false, false,
true});
PLOT_CHECK(fx.plot.crosshair().active);
// A pointer that leaves the data area clears it -- into the y label strip, where no data is
// under the pointer at all.
fx.controller.on_pointer({axes.x - 4.0f, cy, -4.0f, 0.0f, plot::input::Button::Left, 0u, false,
false, false});
PLOT_CHECK(fx.plot.layout().y_label_area.contains(axes.x - 4.0f, cy)); // the premise
PLOT_CHECK(!fx.plot.crosshair().active);
// ... and back inside re-places it, with the coordinate of the new position.
fx.controller.on_pointer({cx, cy, 0.0f, 0.0f, plot::input::Button::Left, 0u, false, false,
false});
PLOT_CHECK(fx.plot.crosshair().active);
PLOT_CHECK_NEAR(fx.plot.crosshair().x, fx.plot.x_transform(0u).to_data_x(cx), 1e-9);
// A wheel moved the data under a stationary pointer, so the crosshair is re-anchored: the
// value it reports is the one under the pointer *now*, not the one it was placed at.
const f64 before = fx.plot.crosshair().x;
fx.controller.on_wheel({cx, cy, 0.0f, 1.0f, 0u});
PLOT_CHECK(fx.plot.crosshair().active);
PLOT_CHECK_NEAR(fx.plot.crosshair().x, fx.plot.x_transform(0u).to_data_x(cx), 1e-9);
PLOT_CHECK(fx.plot.crosshair().x != before);
// The same for an axis zoom from the keyboard: the ranges move, the pointer does not.
const f64 after_wheel = fx.plot.crosshair().x;
fx.controller.on_key({plot::input::kKeyZoomOut, 0u, true});
PLOT_CHECK(fx.plot.crosshair().active);
PLOT_CHECK_NEAR(fx.plot.crosshair().x, fx.plot.x_transform(0u).to_data_x(cx), 1e-9);
PLOT_CHECK(fx.plot.crosshair().x != after_wheel);
// A resize moves the axes under a stationary pointer, so the crosshair is re-placed against
// the new rect -- and a resize that leaves the pointer outside the new axes clears it, the
// same rule as a pointer that left.
const f32 far_x = axes.right() - 2.0f;
fx.controller.on_pointer({far_x, cy, far_x - cx, 0.0f, plot::input::Button::Left, 0u, false,
false, false});
PLOT_CHECK(fx.plot.crosshair().active);
fx.controller.set_viewport({0.0f, 0.0f, 800.0f, 120.0f}); // the pointer is now below it
PLOT_CHECK(!fx.plot.layout().axes.contains(far_x, cy)); // the premise: the pointer is out
PLOT_CHECK(!fx.plot.crosshair().active);
// Restoring the rect puts the pointer back over the data, and the crosshair with it.
fx.controller.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
PLOT_CHECK(fx.plot.layout().axes.contains(far_x, cy));
PLOT_CHECK(fx.plot.crosshair().active);
PLOT_CHECK_NEAR(fx.plot.crosshair().x, fx.plot.x_transform(0u).to_data_x(far_x), 1e-9);
}
+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);
}
}
+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);
}
+174
View File
@@ -4570,3 +4570,177 @@ PLOT_TEST(horizontal_ruler_stays_inside_the_axes_at_the_edges) {
ctx.destroy();
}
// ------------------------------------------------------ the crosshair (spec §15)
namespace {
/// The crosshair's own colour (0xB8E8FF) at real coverage: a pale cyan, green clearly above red
/// and blue above green, which no other element of the frames this case draws produces. The two
/// traces here are magenta and green -- deliberately *not* blue, which is the one other colour
/// that would satisfy "blue above green and green above red" (0x40C0FF does). The axis text
/// (0xC8D0DC) is grey enough to fail the green-over-red term, the chrome and the grid are darker
/// still, and the same hue at lower coverage still passes, so a partially covered stroke counts
/// as ink rather than the test needing a fully covered pixel to exist.
bool crosshair_pixel(const plot::u8 px[4]) {
return px[1] > px[0] + 25 && px[2] > px[1] + 12 && px[2] > 120;
}
} // namespace
namespace {
/// A third colour predicate for the lateral case below: the orange 0xFFB020 of a trace of its
/// own, which no other element of this frame draws (there is no cursor, no region and no
/// measurement band in it, and the crosshair's own colour is a pale cyan).
bool orange_pixel(const plot::u8 px[4]) {
return px[0] > 200 && px[1] > 150 && px[1] < px[0] && px[2] < 80;
}
} // namespace
/// Spec §15's clip half, and with it a regression guard for Task 1: **everything the crosshair
/// draws stays inside `axes`** -- both lines, every marker and the readout panel -- and so does
/// the data it points at.
///
/// Three traces make the case discriminating, one per way a marker can leave the rect:
///
/// * an **in-range** trace, whose line and marker are inside it -- the complement that says the
/// clip is the axes rect and not something smaller;
/// * a trace **below the axis**: y = -0.05 on a [0, 1] axis maps some thirteen pixels under the
/// axes' bottom edge, i.e. *into the x label strip*. Nothing in the drawing path tests a
/// sample's y against the visible range, and a crosshair marker is deliberately staged where
/// its trace is drawn rather than skipped, so only Task 1's scissor keeps it off the strip;
/// * a trace **beside the axes**: one bound to a *second* x axis whose range starts past the
/// crosshair's x, so the data x under the pointer maps to a column *left* of the axes -- in
/// the y label strip. The marker is placed through the trace's own axis pair (which is what
/// puts it there, and what the row's geometry asserts as the premise), and again only the clip
/// keeps it out.
///
/// A build without the scissor puts marker ink in the x and the y label strip respectively, and
/// this case fails on both; the panel's own placement is asserted too, because a panel that
/// overhung `axes` would be the same defect by a different route.
PLOT_TEST(crosshair_ink_stays_inside_the_axes) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
// A viewport inside the framebuffer with the host's grey painted behind it, so the frame may
// paint inside that rect and nowhere else -- and a marker that escaped `axes` would land either
// in a strip or on the host's own surface, both of which are asserted empty below.
const plot::Rect vp{30.0f, 40.0f, 580.0f, 290.0f};
// The in-range signal: a wave inside the [0, 1] axis, so its trace and its marker are both
// inside the rect.
std::vector<plot::f32> in_range(640u);
for (plot::u32 i = 0; i < in_range.size(); ++i) {
in_range[i] = 0.5f + 0.4f * std::sin(0.03f * static_cast<plot::f32>(i));
}
plot::data::ArraySeries good(in_range, 0.0, 1.0);
// Five percent below the bottom of the axis: some thirteen pixels, which is the x label strip.
plot::data::ArraySeries below(std::vector<plot::f32>(640u, -0.05f), 0.0, 1.0);
// In range on y, but drawn through an x axis the crosshair's x is outside of: its marker lands
// in the y label strip, beside the axes.
plot::data::ArraySeries aside(std::vector<plot::f32>(2000u, 0.25f), 0.0, 1.0);
plot::Plot p;
p.set_viewport(vp);
const plot::u32 ax = p.add_x_axis({"t", {0.0, 639.0}, false, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {0.0, 1.0}, false, plot::AxisPlace::Left, false});
// The second x axis: a window that starts *above* the crosshair's x, so the same data x is a
// column to the left of the axes when the trace reads it through this one.
const plot::u32 ax2 = p.add_x_axis({"t2", {700.0, 1400.0}, false, plot::AxisPlace::Bottom, false});
p.add_trace({&good, kRowMagenta, 1.5f, ax, ay, "good"});
p.add_trace({&below, plot::Color::hex(0x00E070), 1.5f, ax, ay, "below"});
p.add_trace({&aside, plot::Color::hex(0xFFB020), 1.5f, ax2, ay, "aside"});
plot::gl::glClearColor(0.5f, 0.5f, 0.5f, 1.0f);
plot::gl::glClear(plot::gl::GL_COLOR_BUFFER_BIT);
// A warm-up frame first: the renderer installs its text backend on the first `draw()`, and the
// strips and the panel are measured with it -- so the frame under test is placed and asserted
// against the rects a *drawn* frame has, not an empty plot's defaults.
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
// The crosshair three pixels inside the bottom-right corner of the data area, so its readout
// panel would leave the rect on two sides if it were not flipped and clamped.
const plot::Rect axes = p.layout().axes;
const plot::Transform tx = p.x_transform(ax);
const plot::Transform ty = p.y_transform(ay);
p.set_crosshair(tx.to_data_x(axes.right() - 3.0f), ty.to_data_y(axes.bottom() - 3.0f), true);
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
dump_if_requested(r, "crosshair_axes.ppm");
const std::vector<plot::u8> frame = read_frame(ctx);
const plot::PlotLayout layout = p.layout();
const plot::CrosshairLayout& crosshair = layout.crosshair;
PLOT_CHECK(crosshair.active);
PLOT_CHECK(crosshair.rows.size() == 3u);
// The panel exists, and is inside the axes on every side -- the placement rule, asserted
// against the very rect the renderer clipped to.
PLOT_CHECK(crosshair.panel.w > 0.0f);
PLOT_CHECK(crosshair.panel.h > 0.0f);
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());
// The premises: the two off-scale markers are staged where nothing but the clip is between them
// and a strip. Row 1's is below the axes' bottom edge, within the x label strip; row 2's is left
// of the axes' left edge, within the y label strip.
PLOT_CHECK(crosshair.rows[0].valid);
PLOT_CHECK(crosshair.rows[1].valid);
PLOT_CHECK(crosshair.rows[2].valid);
PLOT_CHECK(crosshair.rows[1].marker.y > axes.bottom());
PLOT_CHECK(crosshair.rows[1].marker.y < layout.x_label_area.bottom());
PLOT_CHECK(crosshair.rows[2].marker.x < axes.x);
PLOT_CHECK(crosshair.rows[2].marker.x >= layout.y_label_area.x);
// Every crosshair-coloured pixel of the frame is inside `axes`, and there are some: not one in
// a label or title strip, not one over the host's surface outside the viewport.
const plot::u32 inside = predicate_pixels(frame, kW, axes, crosshair_pixel);
PLOT_CHECK(inside > 0u);
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, crosshair_pixel) == inside);
PLOT_CHECK(predicate_pixels(frame, kW, layout.y_label_area, crosshair_pixel) == 0u);
PLOT_CHECK(predicate_pixels(frame, kW, layout.y_title_area, crosshair_pixel) == 0u);
PLOT_CHECK(predicate_pixels(frame, kW, layout.x_label_area, crosshair_pixel) == 0u);
PLOT_CHECK(predicate_pixels(frame, kW, layout.x_title_area, crosshair_pixel) == 0u);
plot::u8 host[4] = {};
PLOT_CHECK(ctx.read_pixel(4u, kH - 4u, host));
PLOT_CHECK(host[0] > 100 && host[0] < 160 && host[1] > 100 && host[2] > 100);
// The panel's own border is inside the panel, so the readout is drawn where the layout said and
// not merely clipped somewhere else.
PLOT_CHECK(predicate_pixels(frame, kW, crosshair.panel, crosshair_pixel) > 0u);
// The trace below the axis: none of its colour anywhere outside `axes` -- the x label strip and
// the host's surface included -- while its own swatch in the readout proves the colour is one
// this frame really draws (so the absence is the clip's doing and not a colour nothing draws).
PLOT_CHECK(predicate_pixels(frame, kW, layout.x_label_area, green_pixel) == 0u);
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, green_pixel) ==
predicate_pixels(frame, kW, axes, green_pixel));
PLOT_CHECK(predicate_pixels(frame, kW, crosshair.rows[1].swatch, green_pixel) > 0u);
// The trace beside the axes: the same statement for the y label strip, where its marker would
// have been drawn.
PLOT_CHECK(predicate_pixels(frame, kW, layout.y_label_area, orange_pixel) == 0u);
PLOT_CHECK(predicate_pixels(frame, kW, layout.y_title_area, orange_pixel) == 0u);
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, orange_pixel) ==
predicate_pixels(frame, kW, axes, orange_pixel));
// The complement: the in-range trace's ink -- its polyline and the marker the crosshair put on
// it -- is inside `axes`, and all of it is, which a clip smaller than the rect could not do.
PLOT_CHECK(predicate_pixels(frame, kW, axes, magenta_pixel) > 0u);
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, magenta_pixel) ==
predicate_pixels(frame, kW, axes, magenta_pixel));
ctx.destroy();
}