diff --git a/tests/test_controller.cpp b/tests/test_controller.cpp index f1b92f8..e55bc71 100644 --- a/tests/test_controller.cpp +++ b/tests/test_controller.cpp @@ -721,3 +721,375 @@ PLOT_TEST(live_gestures_survive_a_degenerate_range) { PLOT_CHECK_NEAR(f.xr().max, f.newest() - f.graph.x_offset(f.ax), 1e-9); PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live); } + +// --- direct manipulation: grabbable cursors, regions and band edges (spec §5) --- + +namespace { + +/// The x column a region's bound sits at, as `Plot::layout()` measured it -- the same +/// number the controller hit-tests and the renderer draws the bound in. Negative when +/// the layout holds no such region, which no caller here should see. +f32 bound_x(plot::Plot& p, u32 region, bool left) { + for (const plot::RegionLayout& r : p.layout().regions) { + if (r.region != region) continue; + return plot::handle_column(left ? r.left_handle : r.right_handle); + } + return -1.0f; +} + +/// A region whose bounds are stored exactly as written: `Free` snap, so a drag's +/// endpoints are the numbers the gesture put there rather than the sample grid +/// `add_region()` pulls a snapping region onto. +plot::Region free_region(f64 x0, f64 x1) { + plot::Region r; + r.x0 = x0; + r.x1 = x1; + r.snap = plot::RegionSnap::Free; + return r; +} + +/// The data x under one device-pixel column. +f64 data_at(const plot::Plot& p, u32 axis, f32 px) { + return p.x_transform(axis).to_data_x(static_cast(px)); +} + +/// The device column a data x is drawn at. +f32 column_of(const plot::Plot& p, u32 axis, f64 data) { + return static_cast(p.x_transform(axis).to_screen_x(data)); +} + +void hover(plot::input::Controller& c, f32 px, f32 py = 200.0f, u32 mods = 0) { + c.on_pointer({px, py, 0.0f, 0.0f, plot::input::Button::Left, mods, false, false, false}); +} +void press(plot::input::Controller& c, f32 px, f32 py = 200.0f, u32 mods = 0) { + c.on_pointer({px, py, 0.0f, 0.0f, plot::input::Button::Left, mods, true, true, false}); +} +void move_to(plot::input::Controller& c, f32 px, f32 py = 200.0f, u32 mods = 0) { + c.on_pointer({px, py, 0.0f, 0.0f, plot::input::Button::Left, mods, true, false, false}); +} +void release(plot::input::Controller& c, f32 px, f32 py = 200.0f, u32 mods = 0) { + c.on_pointer({px, py, 0.0f, 0.0f, plot::input::Button::Left, mods, false, false, true}); +} + +/// Root-mean-square of the y values of the `y(i) == i` samples in [begin, end]: what +/// the band case's measurement has to match exactly. +f64 ramp_rms(u32 begin, u32 end) { + f64 sq = 0.0; + for (u32 i = begin; i <= end; ++i) sq += static_cast(i) * static_cast(i); + return std::sqrt(sq / static_cast(end - begin + 1u)); +} + +} // namespace + +/// A marker is a handle like any other and needs no modifier to be moved: the hit +/// test runs before the pan is ever considered, so a press on a cursor is that +/// cursor's drag while a press anywhere else still pans. `Ctrl` is not a second +/// gesture -- it must not turn a press on a marker into anything else, which is the +/// regression to watch for. +PLOT_TEST(cursor_is_grabbed_without_a_modifier) { + Fixture f; + const u32 cid = f.plot.add_cursor(50.0, plot::Color::hex(0x00FF00)); + const f32 sx = screen_x_of(f.plot, f.ax, 50.0); + const f64 range_min = f.plot.x_range(f.ax).min; + + press(f.controller, sx); + PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor); + PLOT_CHECK(f.controller.grabbed_cursor() == cid); + // The plot publishes the same answers, for the renderer and for a host that wants + // a resize cursor or a status line of its own. + PLOT_CHECK(f.plot.hot_handles().cursor == cid); + PLOT_CHECK(f.plot.hot_handles().grabbed_cursor == cid); + move_to(f.controller, sx + 100.0f); + PLOT_CHECK_NEAR(f.plot.cursor(cid).x, data_at(f.plot, f.ax, sx + 100.0f), 1e-9); + PLOT_CHECK(f.plot.cursor(cid).x > 50.0); + // The view did not pan: the gesture belonged to the marker, not to the ranges. + PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, range_min, 1e-9); + PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), 100.0, 1e-9); + release(f.controller, sx + 100.0f); + PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::None); + PLOT_CHECK(f.controller.grabbed_cursor() == plot::kInvalid); + PLOT_CHECK(f.plot.hot_handles().grabbed_cursor == plot::kInvalid); + + // The same press with ctrl is the same gesture, not a different mode. + const f32 moved_to = column_of(f.plot, f.ax, f.plot.cursor(cid).x); + const f64 min_before = f.plot.x_range(f.ax).min; + press(f.controller, moved_to, 200.0f, kCtrl); + PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor); + PLOT_CHECK(f.controller.grabbed_cursor() == cid); + move_to(f.controller, moved_to - 40.0f, 200.0f, kCtrl); + PLOT_CHECK_NEAR(f.plot.cursor(cid).x, data_at(f.plot, f.ax, moved_to - 40.0f), 1e-9); + PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, min_before, 1e-9); // still no pan + release(f.controller, moved_to - 40.0f, 200.0f, kCtrl); +} + +/// The other half of "a superset of the old gesture": a press that is on no handle +/// reaches the pan exactly as it did before, with and without ctrl, and neither the +/// cursor nor the region under the view is disturbed. +PLOT_TEST(pressing_away_from_a_handle_still_pans) { + Fixture f; + const u32 cid = f.plot.add_cursor(20.0, plot::Color::hex(0x00FF00)); + const u32 rid = f.plot.add_region(free_region(40.0, 60.0)); + + for (const u32 mods : {0u, kCtrl}) { + const f32 px = screen_x_of(f.plot, f.ax, 85.0); + const f64 grabbed = data_at(f.plot, f.ax, px); + const f64 min_before = f.plot.x_range(f.ax).min; + press(f.controller, px, 200.0f, mods); + PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::Pan); + PLOT_CHECK(f.controller.grabbed_cursor() == plot::kInvalid); + PLOT_CHECK(f.controller.grabbed_region() == plot::kInvalid); + PLOT_CHECK(f.plot.hot_handles().cursor == plot::kInvalid); + PLOT_CHECK(f.plot.hot_handles().region == plot::kInvalid); + // The pan is driven by the event's own delta, so this motion has to report one: + // a host that sends an absolute position with dx = 0 has not moved the drag. + f.controller.on_pointer( + {px + 70.0f, 200.0f, 70.0f, 0.0f, plot::input::Button::Left, mods, true, false, false}); + release(f.controller, px + 70.0f, 200.0f, mods); + // Grab-and-drag, the invariant the pan has always held: the data under the press + // is under the pointer afterwards. + PLOT_CHECK_NEAR(data_at(f.plot, f.ax, px + 70.0f), grabbed, 1e-9); + PLOT_CHECK(f.plot.x_range(f.ax).min < min_before); + PLOT_CHECK_NEAR(f.plot.cursor(cid).x, 20.0, 1e-9); + PLOT_CHECK_NEAR(f.plot.region(rid).x0, 40.0, 1e-9); + PLOT_CHECK_NEAR(f.plot.region(rid).x1, 60.0, 1e-9); + } +} + +/// Nearest-first resolution: a bound within the grab radius beats the body it sits +/// inside, and the body answers only away from both bounds. The bounds are *inside* +/// the fill, so a body-first hit test would report `Body` here and a region could +/// never be resized. +PLOT_TEST(region_edge_wins_over_region_body) { + Fixture f; + const u32 rid = f.plot.add_region(free_region(30.0, 70.0)); + const f32 left = bound_x(f.plot, rid, true); + const f32 right = bound_x(f.plot, rid, false); + PLOT_CHECK(left >= 0.0f && right > left); + const f32 mid = 0.5f * (left + right); + + // No pointer seen yet: nothing is on anything. + PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::None); + + hover(f.controller, mid); + PLOT_CHECK(f.controller.hovered_region() == rid); + PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Body); + PLOT_CHECK(f.plot.hot_handles().region == rid); + PLOT_CHECK(f.plot.hot_handles().zone == plot::RegionGrab::Body); + + hover(f.controller, left); + PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Left); + PLOT_CHECK(f.plot.hot_handles().zone == plot::RegionGrab::Left); + hover(f.controller, right); + PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Right); + PLOT_CHECK(f.plot.hot_handles().zone == plot::RegionGrab::Right); + // A couple of pixels inside the bound is still the bound, not the body. + hover(f.controller, left + 2.0f); + PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Left); + // Just outside the fill nothing is hot, so the `Body` answer above is not an + // accident of treating a wide neighbourhood as the region. + hover(f.controller, left - 12.0f); + PLOT_CHECK(f.controller.hovered_region() == plot::kInvalid); + PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::None); + + // Each bound opens the drag that moves that bound; the body opens the one that + // moves the interval. + press(f.controller, left); + PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::ResizeRegionLeft); + PLOT_CHECK(f.controller.grabbed_region() == rid); + release(f.controller, left); + press(f.controller, right); + PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::ResizeRegionRight); + release(f.controller, right); + press(f.controller, mid); + PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveRegion); + release(f.controller, mid); + PLOT_CHECK(f.controller.grabbed_region() == plot::kInvalid); + + // A cursor marker is hit-tested before any region, so a press on it is still the + // cursor's drag. + const u32 cid = f.plot.add_cursor(50.0, plot::Color::hex(0x00FF00)); + const f32 marker = screen_x_of(f.plot, f.ax, 50.0); + hover(f.controller, marker); + // The region is under the pointer too -- the marker sits inside its interval, so the + // region's answer is `Body` -- and the cursor still wins the drag: the two hit tests + // are separate, and the cursor's is the one that decides the gesture. + PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Body); + PLOT_CHECK(f.controller.hovered_region() == rid); + press(f.controller, marker); + PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor); + PLOT_CHECK(f.controller.grabbed_cursor() == cid); + PLOT_CHECK(f.controller.grabbed_region() == plot::kInvalid); + release(f.controller, marker); +} + +/// A body drag translates both bounds by the *same* data delta -- the width is +/// preserved exactly -- and each motion is a function of the pointer rather than a +/// step from the last one, so a pointer that returns to where it started returns the +/// region there too even when the host's reported deltas say otherwise. +PLOT_TEST(region_body_move_preserves_width) { + Fixture f; + const u32 rid = f.plot.add_region(free_region(30.0, 70.0)); + const f64 width = f.plot.region(rid).x1 - f.plot.region(rid).x0; + const f32 mid = 0.5f * (bound_x(f.plot, rid, true) + bound_x(f.plot, rid, false)); + + press(f.controller, mid); + PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveRegion); + PLOT_CHECK(f.controller.grabbed_region() == rid); + move_to(f.controller, mid + 120.0f); + const f64 delta = data_at(f.plot, f.ax, mid + 120.0f) - data_at(f.plot, f.ax, mid); + PLOT_CHECK_NEAR(f.plot.region(rid).x0, 30.0 + delta, 1e-9); + PLOT_CHECK_NEAR(f.plot.region(rid).x1, 70.0 + delta, 1e-9); + PLOT_CHECK_NEAR(f.plot.region(rid).x1 - f.plot.region(rid).x0, width, 1e-9); + PLOT_CHECK(f.plot.region(rid).x0 > 30.0); + + // The deltas here are a lie -- 0, 0 while the pointer moved back -- and the bounds + // are rebuilt from the pointer's own coordinate, so nothing accumulates. + f.controller.on_pointer({mid, 200.0f, 0.0f, 0.0f, plot::input::Button::Left, 0u, true, false, false}); + PLOT_CHECK_NEAR(f.plot.region(rid).x0, 30.0, 1e-9); + PLOT_CHECK_NEAR(f.plot.region(rid).x1, 70.0, 1e-9); + release(f.controller, mid); +} + +/// A bound dragged past its partner *swaps* the interval rather than inverting or +/// clamping it: the store keeps (min, max), so the region always has a positive +/// width, and the pointer keeps hold of the bound it grabbed -- which, having crossed +/// over, is now the other one. +PLOT_TEST(region_bounds_stay_ordered_when_dragged_past_each_other) { + Fixture f; + const u32 rid = f.plot.add_region(free_region(30.0, 70.0)); + const f32 right = bound_x(f.plot, rid, false); + + // Drag the right bound well past the left one. + const f32 to = column_of(f.plot, f.ax, 12.0); + const f64 want = data_at(f.plot, f.ax, to); + PLOT_CHECK(want < 30.0); // the drag really does cross the other bound + press(f.controller, right); + PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::ResizeRegionRight); + move_to(f.controller, to); + PLOT_CHECK_NEAR(f.plot.region(rid).x0, want, 1e-9); // the dragged bound, now the lower one + PLOT_CHECK_NEAR(f.plot.region(rid).x1, 30.0, 1e-9); // the one it was dragged past + PLOT_CHECK(f.plot.region(rid).x0 < f.plot.region(rid).x1); + + // The grabbed bound is still the one being moved -- it is `x0` now, and the next + // motion moves it without the gesture having to be restarted. + const f32 back = column_of(f.plot, f.ax, 20.0); + move_to(f.controller, back); + PLOT_CHECK_NEAR(f.plot.region(rid).x0, data_at(f.plot, f.ax, back), 1e-9); + PLOT_CHECK_NEAR(f.plot.region(rid).x1, 30.0, 1e-9); + release(f.controller, back); + + // And the mirror image: the left bound dragged past the right one. + f.plot.set_region(rid, 30.0, 70.0); + const f32 left = bound_x(f.plot, rid, true); + const f32 far = column_of(f.plot, f.ax, 90.0); + const f64 want_far = data_at(f.plot, f.ax, far); + press(f.controller, left); + PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::ResizeRegionLeft); + move_to(f.controller, far); + PLOT_CHECK_NEAR(f.plot.region(rid).x0, 70.0, 1e-9); + PLOT_CHECK_NEAR(f.plot.region(rid).x1, want_far, 1e-9); + PLOT_CHECK(f.plot.region(rid).x0 < f.plot.region(rid).x1); + release(f.controller, far); +} + +/// A region whose two bounds share a column cannot be grabbed by an edge -- an edge +/// would win the hit test every time and the region could never be moved again once +/// collapsed -- so it is grabbed by its body, which keeps it movable. The threshold is +/// the narrowest region whose bounds a pointer can tell apart: below it the body +/// answers, above it the bound does. +PLOT_TEST(collapsed_region_cannot_be_grabbed_by_an_edge) { + Fixture f; + const u32 rid = f.plot.add_region(free_region(50.0, 50.0)); + const f32 col = bound_x(f.plot, rid, true); + PLOT_CHECK_NEAR(static_cast(col), static_cast(bound_x(f.plot, rid, false)), 1e-6); + hover(f.controller, col); + PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Body); + press(f.controller, col); + PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveRegion); + move_to(f.controller, col + 60.0f); + const f64 delta = data_at(f.plot, f.ax, col + 60.0f) - data_at(f.plot, f.ax, col); + PLOT_CHECK_NEAR(f.plot.region(rid).x0, 50.0 + delta, 1e-9); + PLOT_CHECK_NEAR(f.plot.region(rid).x1, 50.0 + delta, 1e-9); // moved, still collapsed + release(f.controller, col + 60.0f); + + // Two pixels wide is still narrower than the minimum (3 logical px at scale 1): the + // same answer at either bound. + const u32 narrow = f.plot.add_region(free_region(data_at(f.plot, f.ax, 400.0f), + data_at(f.plot, f.ax, 402.0f))); + hover(f.controller, 400.0f); + PLOT_CHECK(f.controller.region_grab(narrow) == plot::RegionGrab::Body); + hover(f.controller, 401.0f); + PLOT_CHECK(f.controller.region_grab(narrow) == plot::RegionGrab::Body); + press(f.controller, 401.0f); + PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveRegion); + release(f.controller, 401.0f); + + // Six pixels is wide enough to tell the bounds apart, and there the bound wins. + const u32 wide = f.plot.add_region(free_region(data_at(f.plot, f.ax, 400.0f), + data_at(f.plot, f.ax, 406.0f))); + hover(f.controller, 400.0f); + PLOT_CHECK(f.controller.region_grab(wide) == plot::RegionGrab::Left); + hover(f.controller, 406.0f); + PLOT_CHECK(f.controller.region_grab(wide) == plot::RegionGrab::Right); +} + +/// The shaded measurement band's two edges *are* the cursors that bound it, so +/// dragging one is the cursor drag with a different hit target: the grab maps to the +/// cursor owning that edge, the other edge stays where it was, and `Plot::measurement` +/// -- which the demo and the renderer both read -- follows as it already does. +PLOT_TEST(band_edge_drag_moves_the_owning_cursor_and_updates_the_measurement) { + constexpr u32 kSamples = 100u; + std::vector samples(kSamples); + for (u32 i = 0; i < kSamples; ++i) samples[i] = static_cast(i); + plot::data::ArraySeries s(std::move(samples), 0.0, 1.0); + + Fixture f; + f.ay = f.plot.add_y_axis({"v", {0.0, 99.0}, false, plot::AxisPlace::Left, false}); + const u32 tr = f.plot.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, f.ax, f.ay}); + const u32 a = f.plot.add_cursor(10.0, plot::Color::hex(0x00FF00)); + const u32 b = f.plot.add_cursor(60.0, plot::Color::hex(0xFF00FF)); + const u32 m = f.plot.add_measure(a, b, tr); + PLOT_CHECK(f.plot.measurement(m).valid); + PLOT_CHECK_NEAR(f.plot.measurement(m).mean, 35.0, 1e-9); // samples 10..60 + PLOT_CHECK_NEAR(f.plot.measurement(m).dx, 50.0, 1e-9); + PLOT_CHECK_NEAR(f.plot.measurement(m).ymax, 60.0, 1e-9); + + // The band's left edge is cursor `a` at its own column, and the drag moves that + // cursor -- not the band, not the view. The pointer lands *between* two samples and + // the readout is over the nearest sample to each cursor, which is the measurement's + // documented rule: a cursor at 20.3 measures from the sample at x == 20. + const f32 edge_a = screen_x_of(f.plot, f.ax, 10.0); + const f32 to = column_of(f.plot, f.ax, 20.3); + press(f.controller, edge_a); + PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor); + PLOT_CHECK(f.controller.grabbed_cursor() == a); + move_to(f.controller, to); + PLOT_CHECK_NEAR(f.plot.cursor(a).x, 20.3, 1e-3); // the bound followed the pointer + PLOT_CHECK_NEAR(f.plot.cursor(b).x, 60.0, 1e-9); // the other edge did not move + const plot::Measurement moved = f.plot.measurement(m); + PLOT_CHECK(moved.valid); + PLOT_CHECK_NEAR(moved.dx, 40.0, 1e-9); // samples 20..60: 41 of them, dx == 1 + PLOT_CHECK_NEAR(moved.mean, 40.0, 1e-9); + PLOT_CHECK_NEAR(moved.ymin, 20.0, 1e-9); + PLOT_CHECK_NEAR(moved.ymax, 60.0, 1e-9); + PLOT_CHECK_NEAR(moved.rms, ramp_rms(20u, 60u), 1e-9); + PLOT_CHECK_NEAR(moved.integral, static_cast((20 + 60) * 41 / 2), 1e-9); + release(f.controller, to); + + // The right edge is cursor `b`, and grabbing it moves `b` alone. + const f32 edge_b = screen_x_of(f.plot, f.ax, 60.0); + const f32 to_b = column_of(f.plot, f.ax, 50.3); + press(f.controller, edge_b); + PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor); + PLOT_CHECK(f.controller.grabbed_cursor() == b); + move_to(f.controller, to_b); + PLOT_CHECK_NEAR(f.plot.cursor(b).x, 50.3, 1e-3); + PLOT_CHECK_NEAR(f.plot.cursor(a).x, 20.3, 1e-3); + const plot::Measurement narrowed = f.plot.measurement(m); + PLOT_CHECK(narrowed.valid); + PLOT_CHECK_NEAR(narrowed.dx, 30.0, 1e-9); // samples 20..50: 31 of them + PLOT_CHECK_NEAR(narrowed.mean, 35.0, 1e-9); + PLOT_CHECK_NEAR(narrowed.ymin, 20.0, 1e-9); + PLOT_CHECK_NEAR(narrowed.ymax, 50.0, 1e-9); + release(f.controller, to_b); +} diff --git a/tests/test_plot.cpp b/tests/test_plot.cpp index b44e33e..01bd236 100644 --- a/tests/test_plot.cpp +++ b/tests/test_plot.cpp @@ -1411,3 +1411,182 @@ PLOT_TEST(a_degenerate_range_cannot_freeze_a_live_axis) { PLOT_CHECK_NEAR(f.xr().max, f.newest() - 5.0, 1e-9); PLOT_CHECK_NEAR(f.xr().span(), 10.0, 1e-9); } + +// --- regions: exact interval statistics (spec §5) ------------------------------ + +/// A region is an interval a user drags, so it lands *between* samples: the stats +/// must be over exactly the samples inside the bounds, which is what +/// `Series::reduce/sum/sumsq` answer for and what the pyramid's `*_buckets` forms, +/// counting whole buckets, cannot. The grid here is deliberately nothing a caller +/// could divide by -- `x0` is not 0 and `dx` is not 1 -- and the interval is not +/// bucket-aligned, so a bucket-grained or off-by-one answer changes the count. +/// +/// The brute-force scan below applies the rule `RegionStats` documents -- the samples +/// whose x is at or after `x0` and at or before `x1`, both ends included -- so the two +/// are comparable at the last bit. Two spikes *outside* the interval are what make the +/// extremes an assertion about the interval rather than about the series. +PLOT_TEST(region_stats_match_bruteforce_over_an_unaligned_interval) { + constexpr f64 kDx = 0.7; + constexpr f64 kX0 = 3.0; + constexpr f64 kLo = 10.4; + constexpr f64 kHi = 47.6; + constexpr u64 kCount = 53u; // x = 3.0 + 0.7*i, i = 11..63, both ends inside + + std::vector ys = ramp(120); + ys[20] = -100.0f; // inside the interval: the minimum + ys[40] = 200.0f; // inside the interval: the maximum + ys[5] = 500.0f; // before it, on a sample + ys[70] = -300.0f; // after it, on a sample + const std::vector samples = ys; // the brute-force copy + plot::data::ArraySeries s(ys, kX0, kDx); // takes the vector by value + + plot::Plot p; + p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); + const u32 ax = p.add_x_axis({"t", s.x_bounds(), false, plot::AxisPlace::Bottom, false}); + const u32 ay = p.add_y_axis({"v", {-200.0, 200.0}, false, plot::AxisPlace::Left, false}); + const u32 tr = p.add_trace({&s, plot::Color::hex(0xFFFFFF), 1.0f, ax, ay}); + + plot::Region reg; + reg.x0 = kLo; + reg.x1 = kHi; + reg.snap = plot::RegionSnap::Free; // stored verbatim: this is the interval under test + const u32 rid = p.add_region(reg); + + const plot::RegionStats st = p.region_stats(rid, tr); + PLOT_CHECK(st.valid); + PLOT_CHECK(st.count == kCount); + PLOT_CHECK_NEAR(st.x0, kLo, 1e-12); + PLOT_CHECK_NEAR(st.x1, kHi, 1e-12); + PLOT_CHECK_NEAR(st.width, kHi - kLo, 1e-9); + PLOT_CHECK_NEAR(st.min, -100.0, 1e-9); // the spike inside, never the one outside + PLOT_CHECK_NEAR(st.max, 200.0, 1e-9); + + u64 count = 0; + f64 sum = 0.0, sq = 0.0, lo = 0.0, hi = 0.0; + for (u64 i = 0; i < samples.size(); ++i) { + const f64 x = kX0 + kDx * static_cast(i); + if (x < kLo || x > kHi) continue; + const f64 v = static_cast(samples[static_cast(i)]); + if (count == 0u) { + lo = v; + hi = v; + } + lo = std::min(lo, v); + hi = std::max(hi, v); + sum += v; + sq += v * v; + ++count; + } + PLOT_CHECK(count == kCount); // the scan and the query agree on the interval itself + PLOT_CHECK_NEAR(st.mean, sum / static_cast(count), 1e-6); + PLOT_CHECK_NEAR(st.rms, std::sqrt(sq / static_cast(count)), 1e-6); + PLOT_CHECK_NEAR(st.min, lo, 1e-9); + PLOT_CHECK_NEAR(st.max, hi, 1e-9); + PLOT_CHECK_NEAR(st.integral, sum * kDx, 1e-6); // left-Riemann, the measurement's rule + + // An interval whose bounds *are* sample x's takes both of those samples: the rule is + // inclusive at each end, so this is one sample wider at the top than a half-open one. + plot::Region exact; + exact.x0 = kX0 + kDx * 11.0; // 10.7 + exact.x1 = kX0 + kDx * 63.0; // 47.1 + exact.snap = plot::RegionSnap::Free; + const u32 rid_exact = p.add_region(exact); + PLOT_CHECK(p.region_stats(rid_exact, tr).count == kCount); + + // Between two adjacent samples there is nothing to average, so the answer is + // invalid and every field zero rather than a mean of no samples. + plot::Region gap; + gap.x0 = 10.71; + gap.x1 = 10.79; + gap.snap = plot::RegionSnap::Free; + const u32 rid_gap = p.add_region(gap); + const plot::RegionStats none = p.region_stats(rid_gap, tr); + PLOT_CHECK(!none.valid); + PLOT_CHECK(none.count == 0u && none.x0 == 0.0 && none.x1 == 0.0 && none.mean == 0.0); + + // A trace with no series and an id that names no region are invalid too. + const u32 tr_null = p.add_trace({nullptr, plot::Color::hex(0xFFFFFF), 1.0f, ax, ay}); + PLOT_CHECK(!p.region_stats(rid, tr_null).valid); + PLOT_CHECK(!p.region_stats(99u, tr).valid); + + // Moving a region moves the interval the statistics are over. + p.set_region(rid, 32.0, 47.6); + PLOT_CHECK(p.region_stats(rid, tr).count == 22u); // x = 32.4 .. 47.1, i = 42..63 + PLOT_CHECK_NEAR(p.region_stats(rid, tr).min, 42.0, 1e-9); // both spikes are outside it now + PLOT_CHECK_NEAR(p.region_stats(rid, tr).max, 63.0, 1e-9); +} + +/// A saturated ring pins `size()` at capacity while `x0()` advances every acquire, so +/// a region whose own bounds never move still covers a different set of samples each +/// frame. The cache key includes `x0()` for exactly this: with (series, sample count) +/// alone the readout would freeze on the first saturated frame, which is the bug the +/// measurement cache's third key exists to prevent and this statistic must not +/// reintroduce. +/// +/// The signal is a ramp in absolute x, so a sample's value names its x: what the +/// statistics report once the window has slid past a cursor is the current window's +/// oldest sample, which a frozen cache cannot produce. +PLOT_TEST(region_stats_track_a_sliding_streaming_window) { + constexpr u64 kCapacity = 64; + plot::data::StreamSeries s(kCapacity, 0.0, 1.0); + for (u64 i = 0; i < kCapacity; ++i) s.push(static_cast(i)); // y == absolute x + s.acquire(); + PLOT_CHECK(s.size() == kCapacity); // the published window is full from here on + + plot::Plot p; + p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); + const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false}); + const u32 ay = p.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false}); + const u32 tr = p.add_trace({&s, plot::Color::hex(0xFFFFFF), 1.0f, ax, ay}); + + plot::Region reg; + reg.x0 = 10.0; + reg.x1 = 60.0; + reg.snap = plot::RegionSnap::Free; // the region never moves; the window does + const u32 rid = p.add_region(reg); + plot::Region behind; + behind.x0 = 0.0; + behind.x1 = 5.0; + behind.snap = plot::RegionSnap::Free; + const u32 rid_behind = p.add_region(behind); + + // The returned reference aliases the cached slot the next read updates, so every + // comparison here keeps a copy of the frame it is about. + const plot::RegionStats first = p.region_stats(rid, tr); + PLOT_CHECK(first.valid); + PLOT_CHECK(first.count == 51u); // absolute x 10..60 + PLOT_CHECK_NEAR(first.min, 10.0, 1e-9); + PLOT_CHECK_NEAR(first.max, 60.0, 1e-9); + PLOT_CHECK_NEAR(first.mean, 35.0, 1e-9); + PLOT_CHECK_NEAR(first.integral, 35.0 * 51.0, 1e-9); + PLOT_CHECK(p.region_stats(rid_behind, tr).valid); // it is inside the window for now + + // Slide the window past the region's lower bound: 40 more samples and an acquire, + // with the sample count unchanged and the region untouched. + for (u64 i = kCapacity; i < kCapacity + 40u; ++i) s.push(static_cast(i)); + s.acquire(); + PLOT_CHECK(s.size() == kCapacity); // size() cannot be the cache key + PLOT_CHECK_NEAR(s.x0(), 40.0, 1e-9); // x0() is what moved under it + + const plot::RegionStats slid = p.region_stats(rid, tr); + PLOT_CHECK(slid.valid); + PLOT_CHECK(slid.count == 21u); // absolute x 40..60, the part the ring still holds + PLOT_CHECK(slid.count != first.count); + PLOT_CHECK_NEAR(slid.min, 40.0, 1e-9); + PLOT_CHECK_NEAR(slid.max, 60.0, 1e-9); + PLOT_CHECK_NEAR(slid.mean, 50.0, 1e-9); + PLOT_CHECK_NEAR(slid.integral, 50.0 * 21.0, 1e-9); + // The window has walked out from under the other region: no sample is left in it. + PLOT_CHECK(!p.region_stats(rid_behind, tr).valid); + + // And it keeps tracking, one acquire at a time, with nothing but x0() moving. + for (u32 i = 0; i < 5u; ++i) { + s.push(static_cast(kCapacity + 40u + i)); + s.acquire(); + const plot::RegionStats step = p.region_stats(rid, tr); + PLOT_CHECK(step.valid); + PLOT_CHECK(step.count == 20u - i); // absolute x (41 + i)..60 + PLOT_CHECK_NEAR(step.min, 41.0 + static_cast(i), 1e-9); + PLOT_CHECK_NEAR(step.mean, 0.5 * (step.min + 60.0), 1e-9); + } +} diff --git a/tests/test_render_smoke.cpp b/tests/test_render_smoke.cpp index 08c7f4d..48d2dfc 100644 --- a/tests/test_render_smoke.cpp +++ b/tests/test_render_smoke.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -2207,9 +2208,40 @@ plot::u32 exact_pixels(const std::vector& frame, plot::u32 fb_w, const return count; } +/// True when any row of the column at `x` inside `axes` holds a pixel the predicate +/// accepts. The overlay strokes are blended over whatever is under them, so for them a +/// presence test is the honest one, and it is measured in the axes' own rows rather +/// than over the whole framebuffer: a bound is the axes' height by construction. +bool column_has(plottest::HeadlessContext& ctx, plot::u32 x, const plot::Rect& axes, + bool (*pred)(const plot::u8*)) { + for (plot::u32 row = 0; row < static_cast(axes.h); ++row) { + plot::u8 px[4] = {}; + PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - static_cast(axes.y) - row, px)); + if (pred(px)) return true; + } + return false; +} + /// The whole framebuffer as a Rect, for the counts that ask "and nowhere else". const plot::Rect kFrameRect{0.0f, 0.0f, static_cast(kW), static_cast(kH)}; +/// The pixels of `area` a predicate accepts. `exact_pixels` above answers "the +/// colour only this element can produce"; this one answers the same question for an +/// element that is *blended* -- a translucent region fill, an anti-aliased stroke -- +/// where the test has to name the pattern rather than the colour. +plot::u32 predicate_pixels(const std::vector& frame, plot::u32 fb_w, + const plot::Rect& area, bool (*pred)(const plot::u8*)) { + plot::u32 count = 0; + for (plot::u32 y = static_cast(area.y); + y < static_cast(area.bottom()); ++y) { + for (plot::u32 x = static_cast(area.x); + x < static_cast(area.right()); ++x) { + if (pred(&frame[(plot::usize(y) * fb_w + x) * 4u])) ++count; + } + } + return count; +} + /// The legend's entries are drawn where `Plot::layout()` measured them: a filled /// swatch per trace in that trace's own colour, and the name's ink inside the panel /// and nowhere else in the data area -- and the panel's presence changes nothing @@ -3162,3 +3194,239 @@ PLOT_TEST(grid_widths_are_per_class_and_scale_with_dpi) { PLOT_CHECK_NEAR(r.style().device(r.style().grid_minor_width), 2.0f, 1e-6f); ctx.destroy(); } + +// --- regions: the fill, the bounds and the emphasized handle (spec §5) --------- + +namespace { + +/// Green dominance: what the region's translucent `0x33FF00` fill leaves over the dark +/// background, over a grey grid line and over the axis line, and what an opaque +/// `0x00FF00` marker leaves at any coverage. Nothing else in these frames is +/// green-dominant -- the trace is blue (`g - b < 0`), the chrome and the text are +/// grey/chalk (`r == g == b`), the bounds are yellow (`r == g > b`) -- so a count of +/// these pixels is a statement about the fill (or the marker) and about nothing else. +/// Conservative on purpose: the fill's own half-pixel fringe at the top and bottom rows +/// is not counted. +bool green_dominant(const plot::u8 px[4]) { + return px[1] > px[0] + 60 && px[1] > px[2] + 60; +} + +} // namespace + +/// A region's fill and its two bounds are drawn from the geometry `Plot::layout()` +/// measured -- the same rects the controller hit-tests -- so the rect a test asserts, the +/// rect a frame paints and the column a pointer is grabbed at are one set of numbers. +/// The fill shades the interval between the bounds and nothing outside it; each bound is +/// one vertical stroke of the axes' height in the region's own edge colour, in its own +/// column and nowhere else in the frame. +PLOT_TEST(region_fill_and_edges_are_drawn_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)); + + plot::data::ArraySeries s(sine_samples(), 0.0, 1.0); + plot::Plot p; + p.set_viewport({0, 0, static_cast(kW), static_cast(kH)}); + const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false}); + const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); + p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay}); + p.set_x_range(ax, {0.0, 640.0}); // one sample per pixel: a flat, cheap frame + + plot::Region reg; + reg.x0 = 100.0; + reg.x1 = 300.0; + reg.snap = plot::RegionSnap::Free; + // Colours no other element of the frame produces, so each count below can only be + // satisfied by the element it names. + reg.fill = plot::Color::hex(0x33FF00, 0.35f); + // Magenta, which `magenta_pixel` already names and neither the fill, the trace, the + // chrome nor the accent (0xFFB020) can produce. + reg.edge = plot::Color::hex(0xFF00FF); + const plot::u32 rid = p.add_region(reg); + + r.begin_frame(kW, kH, {0, 0, static_cast(kW), static_cast(kH)}); + r.draw(p); + r.end_frame(); + PLOT_CHECK(!r.last_frame_had_gl_error()); + dump_if_requested(r, "regions.ppm"); + + const std::vector frame = read_frame(ctx); + const plot::PlotLayout layout = p.layout(); + PLOT_CHECK(layout.regions.size() == 1u); + const plot::RegionLayout& rl = layout.regions[0]; + PLOT_CHECK(rl.region == rid); + const plot::Rect axes = layout.axes; + + // The measured geometry is the drawn geometry: the fill spans the axes' full height + // and is clipped to their columns, so it can never reach over a label strip. + PLOT_CHECK(rl.fill.w > 0.0f); + PLOT_CHECK(rl.fill.x >= axes.x && rl.fill.right() <= axes.right()); + PLOT_CHECK_NEAR(static_cast(rl.fill.h), static_cast(axes.h), 1e-6); + // ... and it is the interval the two bounds mark: its edges are the accepted columns + // of the bound strokes, so the rect that is painted is the interval that is grabbed. + PLOT_CHECK_NEAR(static_cast(rl.fill.x), + static_cast(plot::handle_column(rl.left_handle)), 1e-4); + PLOT_CHECK_NEAR(static_cast(rl.fill.right()), + static_cast(plot::handle_column(rl.right_handle)), 1e-4); + + const plot::u32 in_fill = predicate_pixels(frame, kW, rl.fill, green_dominant); + const plot::u32 anywhere = predicate_pixels(frame, kW, kFrameRect, green_dominant); + const plot::u32 area = static_cast(rl.fill.w) * static_cast(rl.fill.h); + PLOT_CHECK(in_fill > 0u); + // The fill shades essentially the whole of its rect -- the trace and the grid it is + // drawn over take a few percent -- and tints nothing anywhere else in the frame. + PLOT_CHECK(in_fill >= area - area / 8u); + PLOT_CHECK(anywhere == in_fill); + + // The bounds: one stroke per bound, drawn in the bound's own column, of the axes' + // height, and nowhere else in the frame. + const plot::u32 left_col = + static_cast(plot::handle_column(rl.left_handle)); + const plot::u32 right_col = + static_cast(plot::handle_column(rl.right_handle)); + PLOT_CHECK(column_has(ctx, left_col, axes, magenta_pixel)); + PLOT_CHECK(column_has(ctx, right_col, axes, magenta_pixel)); + PLOT_CHECK(!column_has(ctx, left_col + 25u, axes, magenta_pixel)); // the fill is not an edge + PLOT_CHECK(!column_has(ctx, 0u, axes, magenta_pixel)); + // Each band is the bound's own column grown by a pixel either side -- and one row + // taller than the axes, because a stroke drawn to `tab.bottom()` covers the + // half-pixel row a rect's truncating cast ends at. + const plot::Rect left_band{static_cast(left_col) - 1.0f, axes.y, 3.0f, + axes.h + 1.0f}; + const plot::Rect right_band{static_cast(right_col) - 1.0f, axes.y, 3.0f, + axes.h + 1.0f}; + const plot::u32 left_edge = predicate_pixels(frame, kW, left_band, magenta_pixel); + const plot::u32 right_edge = predicate_pixels(frame, kW, right_band, magenta_pixel); + const plot::u32 all_edges = predicate_pixels(frame, kW, kFrameRect, magenta_pixel); + PLOT_CHECK(left_edge >= static_cast(axes.h) / 2u); + PLOT_CHECK(right_edge >= static_cast(axes.h) / 2u); + PLOT_CHECK(all_edges == left_edge + right_edge); + + ctx.destroy(); +} + +/// The handles a pointer is on are emphasized, and the emphasis is the plot's to draw: +/// `Plot::hot_handles()` carries what the input layer's one hit test found, and the +/// renderer draws that handle in the style's cursor colour and at the handle width +/// `PlotLayout` sized it with. A grabbed handle keeps its emphasis for the whole +/// gesture, so what is emphasized follows the *grab* rather than the pointer -- which is +/// what the last frame here pins, with the pointer off the plot entirely. +PLOT_TEST(grabbed_handle_is_highlighted) { + 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)); + + plot::data::ArraySeries s(sine_samples(), 0.0, 1.0); + plot::Plot p; + p.set_viewport({0, 0, static_cast(kW), static_cast(kH)}); + const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false}); + const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); + p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay}); + p.set_x_range(ax, {0.0, 640.0}); + + plot::Region reg; + reg.x0 = 100.0; + reg.x1 = 300.0; + reg.snap = plot::RegionSnap::Free; + reg.edge = plot::Color::hex(0xFF00FF); + const plot::u32 rid = p.add_region(reg); + // A marker in a colour of its own, so the accent count below can only come from a + // highlighted handle and never from the marker as it is drawn when it is idle. + const plot::u32 cid = p.add_cursor(500.0, plot::Color::hex(0x00FF00)); + + plot::input::Controller c(p); + c.set_viewport({0, 0, static_cast(kW), static_cast(kH)}); + const plot::Color accent = r.style().cursor; + + auto draw_frame = [&] { + r.begin_frame(kW, kH, {0, 0, static_cast(kW), static_cast(kH)}); + r.draw(p); + r.end_frame(); + PLOT_CHECK(!r.last_frame_had_gl_error()); + return read_frame(ctx); + }; + + // Frame 1: the controller has seen no pointer, so nothing is hot and no handle is + // emphasized -- while the bounds themselves are still drawn, in their own colour. + const std::vector bare = draw_frame(); + PLOT_CHECK(exact_pixels(bare, kW, kFrameRect, accent) == 0u); + PLOT_CHECK(predicate_pixels(bare, kW, kFrameRect, magenta_pixel) > 0u); + + const plot::PlotLayout layout = p.layout(); + PLOT_CHECK(layout.regions.size() == 1u); + const plot::Rect tab = layout.regions[0].left_handle; + // The tab grown by a pixel on each side -- and by that row plus one, because a + // stroke drawn to `tab.bottom()` covers the half-pixel row the truncating cast of a + // rect ends at. The window is the handle's, and the handle's alone. + const plot::Rect band{tab.x - 1.0f, tab.y, tab.w + 2.0f, tab.h + 1.0f}; + const plot::f32 col = plot::handle_column(tab); + const plot::f32 cy = layout.axes.y + 0.5f * layout.axes.h; + + // Frame 2: the pointer is on the bound, so the bound is the handle being emphasized. + c.on_pointer({col, cy, 0.0f, 0.0f, plot::input::Button::Left, 0u, false, false, false}); + PLOT_CHECK(c.region_grab(rid) == plot::RegionGrab::Left); + PLOT_CHECK(p.hot_handles().zone == plot::RegionGrab::Left); + const std::vector hovered = draw_frame(); + const plot::u32 hot = exact_pixels(hovered, kW, kFrameRect, accent); + PLOT_CHECK(hot > 0u); + // At the handle and nowhere else in the frame. + PLOT_CHECK(exact_pixels(hovered, kW, band, accent) == hot); + // The handle is a handle, not a hairline: the accent covers the tab's own width, + // where the idle bound was drawn 1.5 logical px wide in the region's colour. + PLOT_CHECK(hot >= static_cast(tab.h) * (static_cast(tab.w) - 2u)); + PLOT_CHECK(hot <= static_cast(tab.h) * (static_cast(tab.w) + 2u)); + PLOT_CHECK(predicate_pixels(hovered, kW, band, magenta_pixel) == 0u); + + // Frame 3: the bound is grabbed and dragged, then the pointer leaves the plot. The + // hover is over -- `region_grab()` says so -- but the gesture is not, so the bound the + // drag holds stays emphasized, at the column the drag has moved it to. + c.on_pointer({col, cy, 0.0f, 0.0f, plot::input::Button::Left, 0u, true, true, false}); + PLOT_CHECK(c.drag_mode() == plot::input::DragMode::ResizeRegionLeft); + PLOT_CHECK(c.grabbed_region() == rid); + const plot::f32 moved = col + 60.0f; + c.on_pointer({moved, cy, 60.0f, 0.0f, plot::input::Button::Left, 0u, true, false, false}); + c.on_pointer({moved, static_cast(kH) + 20.0f, 0.0f, 0.0f, plot::input::Button::Left, + 0u, true, false, false}); + PLOT_CHECK(c.region_grab(rid) == plot::RegionGrab::None); // the pointer is off the data area + PLOT_CHECK(p.hot_handles().region == rid); + PLOT_CHECK(p.hot_handles().zone == plot::RegionGrab::Left); + const std::vector dragged = draw_frame(); + const plot::PlotLayout moved_layout = p.layout(); + const plot::Rect moved_tab = moved_layout.regions[0].left_handle; + PLOT_CHECK_NEAR(static_cast(plot::handle_column(moved_tab)), + static_cast(moved), 0.5); + const plot::Rect moved_band{moved_tab.x - 1.0f, moved_tab.y, moved_tab.w + 2.0f, + moved_tab.h + 1.0f}; + const plot::u32 held = exact_pixels(dragged, kW, kFrameRect, accent); + PLOT_CHECK(held > 0u); + PLOT_CHECK(exact_pixels(dragged, kW, moved_band, accent) == held); + + // Releasing off the plot ends both the gesture and the emphasis. + c.on_pointer({moved, static_cast(kH) + 20.0f, 0.0f, 0.0f, plot::input::Button::Left, + 0u, false, false, true}); + PLOT_CHECK(c.grabbed_region() == plot::kInvalid); + PLOT_CHECK(p.hot_handles().region == plot::kInvalid); + PLOT_CHECK(exact_pixels(draw_frame(), kW, kFrameRect, accent) == 0u); + + // The same rule for a cursor marker: hovered, it is drawn as a handle in the accent, + // in its own column and nowhere else, and its own colour is gone from that column. + const plot::f32 marker_col = static_cast(p.x_transform(ax).to_screen_x(p.cursor(cid).x)); + c.on_pointer({marker_col, cy, 0.0f, 0.0f, plot::input::Button::Left, 0u, false, false, false}); + PLOT_CHECK(c.hovered_cursor() == cid); + PLOT_CHECK(p.hot_handles().cursor == cid); + const std::vector over_marker = draw_frame(); + const plot::u32 marker_hot = exact_pixels(over_marker, kW, kFrameRect, accent); + const plot::Rect marker_band{marker_col - 4.0f, layout.axes.y, 9.0f, + layout.axes.h + 1.0f}; + PLOT_CHECK(marker_hot > 0u); + PLOT_CHECK(exact_pixels(over_marker, kW, marker_band, accent) == marker_hot); + PLOT_CHECK(predicate_pixels(over_marker, kW, marker_band, green_dominant) == 0u); + + ctx.destroy(); +}