fix(figure): a following group keeps its whole window on the data
CI / debug (tests + all adapters) (push) Canceled after 0s
CI / coverage (floor + measured table) (push) Canceled after 0s
CI / asan (AddressSanitizer) (push) Canceled after 0s

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.
This commit is contained in:
Martino Ferrari committed 2026-10-03 15:58:52 +02:00
1 parent 7dbe4e7183
commit faef19ba60
4 files changed
+71 -6

No files matched your search

+4
View File
@@ -29,6 +29,10 @@ run touched a GPU, so read it as an upper bound and a record of the software pat
## Features ## 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 - 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); (`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`, - grid with independent major/minor line classes (`Style::grid_major_visible`,
+14 -3
View File
@@ -4,6 +4,7 @@
#include <plot/plot/figure.hpp> #include <plot/plot/figure.hpp>
#include <algorithm> #include <algorithm>
#include <cstdio>
#include <cmath> #include <cmath>
#include <cstddef> #include <cstddef>
@@ -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 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 // 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 // *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 // than everything it retains.
// edge behind the oldest sample left, i.e. over data that is gone. //
// 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 bool log = !plots_.empty() && plots_.back().x_axis(0u).log;
const f64 newest_space = to_link_space(live.max, log); const f64 newest_space = to_link_space(live.max, log);
const f64 retained = const f64 retained =
std::max(0.0, newest_space - to_link_space(live.min, log)); 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.live_newest = live.max;
group.has_live = true; group.has_live = true;
const f64 right = newest_space - group.offset; const f64 right = newest_space - group.offset;
+41
View File
@@ -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 // 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 // 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. // 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<plot::f32>(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) { PLOT_TEST(a_wheel_on_a_following_figures_axis_strip_keeps_the_live_edge) {
constexpr u32 kFigureRows = 3u; constexpr u32 kFigureRows = 3u;
constexpr f64 kWheelDx = 1.0 / 1000.0; constexpr f64 kWheelDx = 1.0 / 1000.0;
+12 -3
View File
@@ -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 // 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 // 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 // factor of 100) -- and the offset as far behind the edge as the retained data
// survive the next frames' advances. // 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; const plot::f64 edge = left.x_bounds().max;
figure.plot(1u).set_x_range(0u, {edge / 1000.0, edge / 10.0}); figure.plot(1u).set_x_range(0u, {edge / 1000.0, edge / 10.0});
figure.layout(); figure.layout();
PLOT_CHECK_NEAR(decades_of(figure.plot(0u)), 2.0, 1e-9); 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(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) { for (int step = 0; step < 3; ++step) {
left.push(0.5f); left.push(0.5f);
left.acquire(); left.acquire();
figure.layout(); figure.layout();
PLOT_CHECK_NEAR(decades_of(figure.plot(0u)), 2.0, 1e-9); 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(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); PLOT_CHECK(figure.x_link_follow(group) == plot::XFollow::Live);
} }