feat(input): live x follow with relative zoom and mouse panning
This commit is contained in:
1 parent
eaae07d8e0
commit
77e861f79e
10 files changed
+971
-28
No files matched your search
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user