From faef19ba60df5aa44be7cf52bd39deb90e7ecf00 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sat, 3 Oct 2026 15:58:52 +0200 Subject: [PATCH] fix(figure): a following group keeps its whole window on the data The offset of a following group was clamped to `retained` -- measured from the newest sample -- so a pan (or a zoom-in anchored near an edge) could put the window's *right* edge exactly on the oldest retained sample. That leaves the window's entire span to its left over data the scope never had: an empty margin as wide as the view. The bound is about the whole window, so it is `retained - span` -- but only while the window *can* sit on the data. When the members retain less than a window's worth (a ring still filling, or one just trimmed) nothing avoids a margin, and the older placement is the honest one and stays: right edge on the newest sample, the shortfall on the left. Test: a_following_group_keeps_its_whole_window_on_the_data -- asks to scroll ten spans past the oldest sample and asserts the window is still a window, with its left edge on the data. The existing `a_following_log_group_holds_decades` codified the old bound (`max == edge/10` after asking for a decade of pan with far less than that retained); its log-space subject is unchanged and the offset assertion is now the invariant above. --- README.md | 4 ++++ src/plot/figure.cpp | 17 +++++++++++++--- tests/test_controller.cpp | 41 +++++++++++++++++++++++++++++++++++++++ tests/test_figure.cpp | 15 +++++++++++--- 4 files changed, 71 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 065aaf3..f32c54d 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,10 @@ run touched a GPU, so read it as an upper bound and a record of the software pat ## Features +- a following group's window stays on its members' data: while the members retain at least a + window's worth, the scroll offset is bounded by `retained - span`, so a pan or a zoom-in near an + edge cannot leave the window's own span over data the scope never had (a window *wider* than the + data keeps the older placement -- right edge on the newest sample); - sample **markers** when a window is sparse enough that its samples are the shape (`Style::marker_below_samples`, 50 by default, `marker_px` across, in the trace's own colour); - grid with independent major/minor line classes (`Style::grid_major_visible`, diff --git a/src/plot/figure.cpp b/src/plot/figure.cpp index c702624..7ebed6c 100644 --- a/src/plot/figure.cpp +++ b/src/plot/figure.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -277,13 +278,23 @@ bool Figure::place_live_window(XLink& group) { // The offset can outlive the window it names: a ring that dropped samples moves // the oldest x forward under it, so it is clamped against what the members hold // *now* -- measured in the same space, or a log group could hold an offset larger - // than everything it retains. An offset past that would put the window's right - // edge behind the oldest sample left, i.e. over data that is gone. + // than everything it retains. + // + // The bound is about the whole *window*, not just its right edge -- but only when the window + // *can* sit on the data. `retained >= span` is that case, and there the offset may not exceed + // `retained - span`: with the loose bound a scroll could put the window's right edge exactly on + // the oldest sample, which leaves the entire span to its left over data the scope never had -- + // an empty margin as wide as the view, reached by a pan or by a zoom-in near that edge. When the + // window is *wider* than what the members retain (a ring still filling, or one just trimmed) + // nothing can avoid a margin, and the honest placement is the old one: the window's right edge + // on the newest sample, the shortfall on the left, which is what a scope that just started + // should look like. const bool log = !plots_.empty() && plots_.back().x_axis(0u).log; const f64 newest_space = to_link_space(live.max, log); const f64 retained = std::max(0.0, newest_space - to_link_space(live.min, log)); - group.offset = std::clamp(group.offset, 0.0, retained); + const f64 reach = retained >= group.span ? retained - group.span : retained; + group.offset = std::clamp(group.offset, 0.0, std::max(0.0, reach)); group.live_newest = live.max; group.has_live = true; const f64 right = newest_space - group.offset; diff --git a/tests/test_controller.cpp b/tests/test_controller.cpp index 6bea53d..804afd8 100644 --- a/tests/test_controller.cpp +++ b/tests/test_controller.cpp @@ -1788,6 +1788,47 @@ PLOT_TEST(a_middle_drag_on_a_figures_bottom_row_zooms_the_shared_x_axis) { // pointer) rather than on the live edge, and the window walked back off the newest sample // by a fifth of its width. These two cases pin the fix from the two ends: the gesture keeps // the edge, and the hint that makes it possible exists exactly while a group follows. +/// A following group's window stays **on the data**: the offset bound is `retained - span`, not +/// `retained`. With the loose bound, scrolling back put the window's *right* edge on the oldest +/// retained sample, which leaves the window's whole span to its left over data the scope never +/// had -- an empty margin as wide as the view, reached by a pan or by a zoom-in near that edge. +/// That is the "sometimes zooming in, the left or right margin becomes empty" report, and it is +/// why the bound has to be about the window and not about one edge of it. +PLOT_TEST(a_following_group_keeps_its_whole_window_on_the_data) { + plot::data::StreamSeries series(1u << 12, 0.0, 1.0 / 1000.0); + plot::f32 samples[512]; + for (u32 i = 0; i < 512u; ++i) samples[i] = static_cast(i % 7u); + series.push(samples, 512u); + series.acquire(); + + plot::Figure figure(1u); + figure.set_viewport({0.0f, 0.0f, 800.0f, 600.0f}); + figure.plot(0u).add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false}); + figure.plot(0u).add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); + figure.plot(0u).add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, 0u, 0u, "s"}); + const u32 group = figure.add_x_link(); + PLOT_CHECK(figure.link_x(0u, group)); + PLOT_CHECK(figure.set_x_link_range(group, {0.0, 0.25})); + PLOT_CHECK(figure.set_x_link_follow(group, plot::XFollow::Live)); + figure.layout(); + + const plot::Range data = series.x_bounds(); + const f64 span = figure.x_link_range(group).span(); + PLOT_CHECK(span > 0.0); + // Ask to scroll far past the oldest sample, the way a drag or a zoom-in near the left edge does. + PLOT_CHECK(figure.set_x_link_range(group, {data.min - 10.0 * span, data.min - 9.0 * span})); + figure.layout(); + + const plot::Range after = figure.x_link_range(group); + std::printf("[follow-bound] data=[%.4f..%.4f] span=%.4f after=[%.4f..%.4f]\n", data.min, + data.max, span, after.min, after.max); + // The window is still a window, and its *left* edge is on the data: that is the assertion the + // loose bound failed, where `after.min` came out a whole span below `data.min`. + PLOT_CHECK_NEAR(after.span(), span, 1e-9 * span); + PLOT_CHECK(after.min >= data.min - 1e-9); + PLOT_CHECK(after.max <= data.max + 1e-9); +} + PLOT_TEST(a_wheel_on_a_following_figures_axis_strip_keeps_the_live_edge) { constexpr u32 kFigureRows = 3u; constexpr f64 kWheelDx = 1.0 / 1000.0; diff --git a/tests/test_figure.cpp b/tests/test_figure.cpp index a23682e..c3b8e14 100644 --- a/tests/test_figure.cpp +++ b/tests/test_figure.cpp @@ -844,20 +844,29 @@ PLOT_TEST(a_following_log_group_holds_decades) { // A member panned while the group follows: the adopted scroll state is the // adopted range's span *in decades* -- two here (edge/1000 to edge/10 is a - // factor of 100) -- and the offset one decade behind the edge, both of which - // survive the next frames' advances. + // factor of 100) -- and the offset as far behind the edge as the retained data + // allows, both of which survive the next frames' advances. + // + // The pan *asks* for a full decade back, and the group may not take it: the offset is bounded by + // `retained - span`, so the window stops with its left edge on the oldest sample. Asserting + // `max == edge/10` here was asserting the *old* bound, which put a whole span of the window over + // data the members never held -- the empty-left-margin defect this bound exists for. The + // log-space assertions above are this test's subject and are unchanged. const plot::f64 edge = left.x_bounds().max; figure.plot(1u).set_x_range(0u, {edge / 1000.0, edge / 10.0}); figure.layout(); PLOT_CHECK_NEAR(decades_of(figure.plot(0u)), 2.0, 1e-9); PLOT_CHECK_NEAR(decades_of(figure.plot(1u)), 2.0, 1e-9); - PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).max, edge / 10.0, 1e-8); + const plot::f64 oldest = std::min(left.x_bounds().min, right.x_bounds().min); + PLOT_CHECK(figure.plot(0u).x_range(0u).min >= oldest - 1e-6); + PLOT_CHECK(figure.plot(0u).x_range(0u).max <= edge + 1e-6); for (int step = 0; step < 3; ++step) { left.push(0.5f); left.acquire(); figure.layout(); PLOT_CHECK_NEAR(decades_of(figure.plot(0u)), 2.0, 1e-9); PLOT_CHECK_NEAR(decades_of(figure.plot(1u)), 2.0, 1e-9); + PLOT_CHECK(figure.plot(0u).x_range(0u).min >= oldest - 1e-6); } PLOT_CHECK(figure.x_link_follow(group) == plot::XFollow::Live); }