diff --git a/include/plot/render/renderer.hpp b/include/plot/render/renderer.hpp index b1a10b0..fc8557a 100644 --- a/include/plot/render/renderer.hpp +++ b/include/plot/render/renderer.hpp @@ -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 diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index d84ae06..4bb0fb4 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -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 diff --git a/tests/test_controller.cpp b/tests/test_controller.cpp index bbb92ee..95cbe63 100644 --- a/tests/test_controller.cpp +++ b/tests/test_controller.cpp @@ -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); +} diff --git a/tests/test_figure.cpp b/tests/test_figure.cpp index 71a2e90..56d9e78 100644 --- a/tests/test_figure.cpp +++ b/tests/test_figure.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -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{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 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); + } +} diff --git a/tests/test_plot.cpp b/tests/test_plot.cpp index c3e4543..70cac48 100644 --- a/tests/test_plot.cpp +++ b/tests/test_plot.cpp @@ -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 tens(u64 n) { + std::vector y(n); + for (u64 i = 0; i < n; ++i) y[i] = f32(10.0 * static_cast(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(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& 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 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{}, 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{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{}, 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(crosshair.panel.x), + static_cast(crosshair.panel.y), static_cast(crosshair.panel.w), + static_cast(crosshair.panel.h), static_cast(axes.x), + static_cast(axes.y), static_cast(axes.w), + static_cast(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(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(kSamples - 1u), + static_cast(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(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); +} diff --git a/tests/test_render_smoke.cpp b/tests/test_render_smoke.cpp index cbe09ae..2f8c90a 100644 --- a/tests/test_render_smoke.cpp +++ b/tests/test_render_smoke.cpp @@ -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 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(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(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(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 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(); +}