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

+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
// 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<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) {
constexpr u32 kFigureRows = 3u;
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
// 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);
}