feat(input): live x follow with relative zoom and mouse panning

This commit is contained in:
Martino Ferrari committed 2026-09-28 13:01:48 +02:00
1 parent eaae07d8e0
commit 77e861f79e
10 files changed
+971 -28

No files matched your search

+222
View File
@@ -5,6 +5,7 @@
#include <plot/data/stream_series.hpp>
#include <plot/render/embedded_mono_font.hpp>
#include <cmath>
#include <limits>
#include <vector>
namespace {
@@ -1143,3 +1144,224 @@ PLOT_TEST(ui_scale_defaults_to_one) {
PLOT_CHECK(b.legend.panel.w == a.legend.panel.w);
PLOT_CHECK(b.legend.panel.h == a.legend.panel.h);
}
// --------------------------------------------- live x follow (spec §12)
//
// The streaming case: the visible window is not a pair of numbers the caller chose
// but a *view from the live edge*, recomputed from the series' newest x on every
// frame. `XFollow::Off` -- the default, and every case above -- is unchanged; these
// cases are about the mode where following is explicit, and about the two numbers
// that survive the per-frame recompute: the span, and the offset behind the edge.
namespace {
/// A streaming plot: one `StreamSeries` on one x axis, 800x400, with `push`
/// samples already produced and acquired. The axis starts 10 units wide and follows
/// nothing until a case asks it to, so "Off is the default" is what every case here
/// starts from.
struct LiveFixture {
plot::data::StreamSeries series;
plot::Plot graph;
u32 ax = 0;
u32 ay = 0;
explicit LiveFixture(u64 retain, u64 push) : series(retain, 0.0, 1.0) {
graph.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
ax = graph.add_x_axis({"t", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false});
ay = graph.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
graph.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, ax, ay});
feed(push);
graph.layout();
}
/// Produces `n` samples and publishes them, the way a frame does.
void feed(u64 n) {
for (u64 i = 0; i < n; ++i) series.push(0.0f);
series.acquire();
}
/// Arms following: the mode under test.
void live() { graph.set_x_follow(ax, plot::XFollow::Live); }
/// The newest x the series holds -- the live edge.
f64 newest() const { return series.x_bounds().max; }
plot::Range xr() const { return graph.x_range(ax); }
};
} // namespace
/// The window's right edge *is* the newest sample, every frame, for as long as the
/// stream runs -- and the span stays the axis' own, because following scrolls the
/// window rather than rescaling it. The second half is what stops a "tracks the
/// newest sample" implementation from passing by re-fitting the whole retained
/// window each frame.
PLOT_TEST(live_window_tracks_the_newest_sample) {
LiveFixture f(64, 4);
f.live();
const f64 span = f.xr().span();
PLOT_CHECK_NEAR(f.xr().max, f.newest(), 1e-9);
for (u64 i = 0; i < 40; ++i) {
f.feed(3);
f.graph.layout();
PLOT_CHECK_NEAR(f.xr().max, f.newest(), 1e-9);
PLOT_CHECK_NEAR(f.xr().span(), span, 1e-9);
PLOT_CHECK_NEAR(f.xr().min, f.xr().max - span, 1e-9);
}
// The ring has saturated (40 * 3 + 4 > 64) and the window still ends at the newest
// sample: the sliding retained window does not drag the view with it.
PLOT_CHECK(f.series.size() == 64u);
}
/// "A plot with no trace on that axis, or a non-streaming series, keeps its stored
/// range": an array's bounds never slide, so `Live` has no edge to follow and the
/// axis behaves exactly as an `Off` one -- stored range, `auto_scale` and all.
PLOT_TEST(live_without_a_streaming_series_keeps_its_stored_range) {
plot::data::ArraySeries array(ramp(51), 0.0, 1.0); // x = 0 .. 50, fixed
plot::Plot p;
p.set_viewport({0, 0, 800, 400});
const u32 ax = p.add_x_axis({"t", {3.0, 7.0}, false, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
const u32 empty = p.add_x_axis({"u", {0.0, 1.0}, false, plot::AxisPlace::Top, false});
p.add_trace({&array, plot::Color::hex(0x4FA3FF), 1.5f, ax, ay});
p.set_x_follow(ax, plot::XFollow::Live);
p.set_x_follow(empty, plot::XFollow::Live); // nothing bound to it at all
p.layout();
PLOT_CHECK_NEAR(p.x_range(ax).min, 3.0, 1e-9);
PLOT_CHECK_NEAR(p.x_range(ax).max, 7.0, 1e-9);
PLOT_CHECK_NEAR(p.x_range(empty).min, 0.0, 1e-9);
PLOT_CHECK_NEAR(p.x_range(empty).max, 1.0, 1e-9);
f64 newest = 0.0;
PLOT_CHECK(!p.x_live_edge(ax, newest)); // and it says so, rather than inventing one
// Auto scale still works on such an axis: the follow path is not involved, so
// this is an ordinary fit -- the behaviour a static plot has always had.
p.set_x_auto_scale(ax, true);
p.layout();
PLOT_CHECK(p.x_range(ax).min < 0.0);
PLOT_CHECK(p.x_range(ax).max > 50.0);
// ...and a caller-set range is stored verbatim, because there is no live edge to
// express it relative to.
p.set_x_range(ax, {-1.0, 2.0});
p.layout();
PLOT_CHECK_NEAR(p.x_range(ax).min, -1.0, 1e-9);
PLOT_CHECK_NEAR(p.x_range(ax).max, 2.0, 1e-9);
}
/// Under `Live` -- with a live edge -- `set_x_range` is a statement about the span
/// and the offset, not about absolute positions: the window follows from those at
/// whatever is newest now. This is the reinterpretation every gesture goes through,
/// so it is pinned directly here rather than only through the controller.
PLOT_TEST(set_x_range_under_live_is_a_span_and_an_offset) {
LiveFixture f(200, 20); // newest = 19
f.live();
f.graph.set_x_range(f.ax, {f.newest() - 20.0 - 5.0, f.newest() - 5.0});
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), 5.0, 1e-9);
PLOT_CHECK_NEAR(f.xr().span(), 20.0, 1e-9);
PLOT_CHECK_NEAR(f.xr().max, f.newest() - 5.0, 1e-9);
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live); // and still armed
// The absolute numbers are gone: the same offset and span hold after the stream
// has moved on, which is what a live window means.
f.feed(30);
f.graph.layout();
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), 5.0, 1e-9);
PLOT_CHECK_NEAR(f.xr().span(), 20.0, 1e-9);
PLOT_CHECK_NEAR(f.xr().max, f.newest() - 5.0, 1e-9);
PLOT_CHECK(f.xr().max > f.newest() - 5.0 - 1e-9); // moved with the data
}
/// The offset is clamped against what the traces still hold -- `x_bounds()` for a
/// stream, i.e. the retained ring -- so the window's right edge can never be put
/// behind the oldest sample the ring has. Asking for more than that is not an
/// error: it is the end of the window, and the empty space in front of it is the
/// honest view of data the ring dropped.
PLOT_TEST(set_x_offset_clamps_to_the_retained_window) {
LiveFixture f(64, 64); // saturated: retains x = 0 .. 63
f.live();
const plot::Range retained = f.series.x_bounds();
PLOT_CHECK_NEAR(retained.span(), 63.0, 1e-9);
f.graph.set_x_offset(f.ax, 1.0e6);
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), 63.0, 1e-9); // clamped, not stored
PLOT_CHECK_NEAR(f.xr().max, retained.min, 1e-9); // the right edge stops at the oldest
PLOT_CHECK_NEAR(f.xr().span(), 10.0, 1e-9); // ...and the window is still the axis' span
// The clamp stays honest as the ring slides: the offset is re-clamped against the
// window the traces hold *now*, not the one they held when it was set.
f.feed(20);
f.graph.layout();
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), f.series.x_bounds().span(), 1e-9);
PLOT_CHECK_NEAR(f.xr().max, f.series.x_bounds().min, 1e-9);
f.graph.set_x_offset(f.ax, -5.0);
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), 0.0, 1e-9);
PLOT_CHECK_NEAR(f.xr().max, f.newest(), 1e-9);
// A value that is not a number is refused: the window it would place is not a
// window, and there is no way back from one a caller could not see.
f.graph.set_x_offset(f.ax, std::numeric_limits<f64>::quiet_NaN());
PLOT_CHECK(std::isfinite(f.graph.x_offset(f.ax)));
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), 0.0, 1e-9);
}
/// `auto_fit` under `Live` is the way *back* to the live edge: the span becomes the
/// retained window's and the offset returns to zero, so the window ends at the
/// newest sample -- and following stays armed, because a fit is not how a plot
/// stops following.
PLOT_TEST(auto_fit_returns_to_the_live_edge) {
LiveFixture f(64, 40);
f.live();
f.graph.set_x_offset(f.ax, 12.0);
PLOT_CHECK_NEAR(f.xr().max, f.newest() - 12.0, 1e-9);
f.graph.auto_fit_x();
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), 0.0, 1e-9);
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
PLOT_CHECK_NEAR(f.xr().max, f.newest(), 1e-9);
// The fitted span is the retained window's, with the 2% margin auto-fit has always
// taken on both ends.
PLOT_CHECK_NEAR(f.xr().span(), 1.04 * f.series.x_bounds().span(), 1e-9);
// And the next frame keeps it at the live edge rather than at the fitted window's
// absolute position.
f.feed(25);
f.graph.layout();
PLOT_CHECK_NEAR(f.xr().max, f.newest(), 1e-9);
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), 0.0, 1e-9);
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
}
/// The mode is opt-in and `Off` is the default, so nothing about an existing caller
/// changes: the range is stored verbatim, a layout never moves it, and an offset has
/// no effect on an axis that is not following. The gesture half of "a static plot is
/// unaffected" is in the controller's cases, where the gestures live.
PLOT_TEST(a_static_plot_is_unaffected) {
LiveFixture f(64, 40);
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Off); // the default
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), 0.0, 1e-9);
f.graph.set_x_range(f.ax, {-3.0, 7.0});
PLOT_CHECK_NEAR(f.xr().min, -3.0, 1e-9);
PLOT_CHECK_NEAR(f.xr().max, 7.0, 1e-9); // absolute, exactly the numbers given
PLOT_CHECK(!f.graph.x_axis(f.ax).auto_scale);
// An offset on an axis that is not following changes nothing it draws: it is
// stored, and applies when following is armed.
f.graph.set_x_offset(f.ax, 9.0);
f.feed(30);
f.graph.layout();
PLOT_CHECK_NEAR(f.xr().min, -3.0, 1e-9);
PLOT_CHECK_NEAR(f.xr().max, 7.0, 1e-9);
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), 9.0, 1e-9);
f.graph.set_x_follow(f.ax, plot::XFollow::Live);
PLOT_CHECK_NEAR(f.xr().max, f.newest() - 9.0, 1e-9); // that far behind the live edge
PLOT_CHECK_NEAR(f.xr().span(), 10.0, 1e-9); // and the span it already had
// Turning following off leaves the axis exactly where it is: the last live window
// becomes an ordinary stored range and stays put as the stream moves on.
f.graph.set_x_follow(f.ax, plot::XFollow::Off);
const plot::Range frozen = f.xr();
f.feed(30);
f.graph.layout();
PLOT_CHECK_NEAR(f.xr().min, frozen.min, 1e-9);
PLOT_CHECK_NEAR(f.xr().max, frozen.max, 1e-9);
}