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
@@ -3,6 +3,7 @@
|
||||
#include <plot/input/controller.hpp>
|
||||
#include <plot/plot/plot.hpp>
|
||||
#include <plot/data/array_series.hpp>
|
||||
#include <plot/data/stream_series.hpp>
|
||||
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
@@ -430,3 +431,264 @@ PLOT_TEST(box_zoom_click_threshold_scales_with_dpi) {
|
||||
PLOT_CHECK(p.x_range(ax).max < before.max);
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------- live x follow (spec §12)
|
||||
//
|
||||
// The gestures on an axis that is following live data. Every path below already
|
||||
// existed; what is new is that none of them disarms following, and that the ranges
|
||||
// they set are read by `Plot::set_x_range` as a span and an offset behind the live
|
||||
// edge rather than as absolute positions.
|
||||
|
||||
namespace {
|
||||
|
||||
/// A streaming plot under the controller: one `StreamSeries` on one x axis, 800x400,
|
||||
/// with `push` samples already produced and acquired. The axis starts 10 units wide;
|
||||
/// a case arms following when it needs it, so "Off is the default" is the state every
|
||||
/// case here starts from.
|
||||
struct LiveFixture {
|
||||
plot::data::StreamSeries series;
|
||||
plot::Plot graph;
|
||||
plot::input::Controller controller{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);
|
||||
controller.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
||||
}
|
||||
|
||||
void feed(u64 n) {
|
||||
for (u64 i = 0; i < n; ++i) series.push(0.0f);
|
||||
series.acquire();
|
||||
}
|
||||
void live() { graph.set_x_follow(ax, plot::XFollow::Live); }
|
||||
f64 newest() const { return series.x_bounds().max; }
|
||||
plot::Range xr() const { return graph.x_range(ax); }
|
||||
};
|
||||
|
||||
/// The centre of the x label strip: outside `axes`, which is where a zoom's anchor
|
||||
/// falls back to the live edge, and where the middle-drag x zoom is aimed anyway.
|
||||
plot::input::WheelEvent strip_wheel(const plot::Plot& p, f32 notches) {
|
||||
const plot::Rect strip = std::as_const(p).layout().x_label_area;
|
||||
return {strip.x + 0.5f * strip.w, strip.y + 0.5f * strip.h, 0.0f, notches, 0};
|
||||
}
|
||||
|
||||
/// One left drag of `dx` device pixels across the axes, through the controller.
|
||||
void drag(plot::input::Controller& c, const plot::Rect& axes, f32 dx) {
|
||||
const f32 x = axes.x + 0.5f * axes.w;
|
||||
const f32 y = axes.y + 0.5f * axes.h;
|
||||
c.on_pointer({x, y, 0.0f, 0.0f, plot::input::Button::Left, 0, true, true, false});
|
||||
c.on_pointer({x + dx, y, dx, 0.0f, plot::input::Button::Left, 0, true, false, false});
|
||||
c.on_pointer({x + dx, y, 0.0f, 0.0f, plot::input::Button::Left, 0, false, false, true});
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
/// The regression §12 exists for. Today a wheel zoom clears the x axis' auto scale,
|
||||
/// and a live plot then stops following the newest sample for good: the window stays
|
||||
/// where the zoom left it while the stream walks out of it. Under `Live` the span
|
||||
/// shrinks, following stays armed, and the window keeps ending where it ended --
|
||||
/// which is asserted by *both* halves below, because the edge is the newest sample
|
||||
/// only for a zoom anchored on it.
|
||||
PLOT_TEST(wheel_zoom_keeps_following) {
|
||||
LiveFixture f(200, 40);
|
||||
f.graph.set_x_auto_scale(f.ax, true); // the only way to follow before this feature
|
||||
f.graph.layout();
|
||||
f.live();
|
||||
const f64 span_before = f.xr().span();
|
||||
const plot::Rect axes = std::as_const(f.graph).layout().axes;
|
||||
|
||||
// A notch from outside `axes` anchors on the live edge, so the window still ends
|
||||
// at the newest sample afterwards -- and goes on doing so as data arrives.
|
||||
f.controller.on_wheel(strip_wheel(f.graph, 1.0f));
|
||||
PLOT_CHECK(f.xr().span() < span_before);
|
||||
PLOT_CHECK_NEAR(f.xr().max, f.newest(), 1e-9);
|
||||
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
|
||||
f.feed(20);
|
||||
f.graph.layout();
|
||||
PLOT_CHECK_NEAR(f.xr().max, f.newest(), 1e-9);
|
||||
const f64 zoomed = f.xr().span();
|
||||
|
||||
// A notch from inside `axes` anchors on the data under the pointer, which pushes
|
||||
// the window back from the edge by what the zoom chose -- and it still follows:
|
||||
// that distance is constant as the stream advances, so the newest sample is the
|
||||
// same distance off the right edge on every frame instead of drifting away.
|
||||
f.controller.on_wheel({axes.x + 0.5f * axes.w, axes.y + 0.5f * axes.h, 0.0f, 1.0f, 0});
|
||||
PLOT_CHECK_NEAR(f.xr().span(), zoomed * std::exp(-0.15), 1e-9);
|
||||
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
|
||||
const f64 behind = f.newest() - f.xr().max;
|
||||
PLOT_CHECK(behind > 0.0);
|
||||
f.feed(25);
|
||||
f.graph.layout();
|
||||
PLOT_CHECK_NEAR(f.newest() - f.xr().max, behind, 1e-9);
|
||||
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
|
||||
}
|
||||
|
||||
/// Two half-notches are one full notch, because the wheel *multiplies* the span: an
|
||||
/// additive step would give `2 * (span - d/2) != span - d`. The two fixtures have
|
||||
/// identical state, so the comparison is exact -- and the pinned value says which
|
||||
/// arithmetic produced it.
|
||||
PLOT_TEST(wheel_zoom_is_relative) {
|
||||
LiveFixture a(200, 40);
|
||||
LiveFixture b(200, 40);
|
||||
a.live();
|
||||
b.live();
|
||||
PLOT_CHECK_NEAR(a.xr().span(), b.xr().span(), 1e-12); // same state to start from
|
||||
|
||||
a.controller.on_wheel(strip_wheel(a.graph, 0.5f));
|
||||
a.controller.on_wheel(strip_wheel(a.graph, 0.5f));
|
||||
b.controller.on_wheel(strip_wheel(b.graph, 1.0f));
|
||||
PLOT_CHECK_NEAR(a.xr().span(), b.xr().span(), 1e-9);
|
||||
PLOT_CHECK_NEAR(b.xr().span(), 10.0 * std::exp(-0.15), 1e-9);
|
||||
PLOT_CHECK(a.xr().span() < 10.0);
|
||||
// Each window still ends at its own live edge, so the relative zoom kept following.
|
||||
PLOT_CHECK_NEAR(a.xr().max, a.newest(), 1e-9);
|
||||
PLOT_CHECK_NEAR(b.xr().max, b.newest(), 1e-9);
|
||||
PLOT_CHECK(a.graph.x_follow(a.ax) == plot::XFollow::Live);
|
||||
PLOT_CHECK(b.graph.x_follow(b.ax) == plot::XFollow::Live);
|
||||
}
|
||||
|
||||
/// Panning a live plot moves the window, not the scale: the offset grows by the
|
||||
/// drag's data delta, the span is untouched, and following is still armed. Dragging
|
||||
/// right reveals earlier data, which for a live window means moving back from the
|
||||
/// edge -- and the window then keeps that distance as the stream advances.
|
||||
PLOT_TEST(mouse_pan_shifts_the_offset_and_preserves_the_span) {
|
||||
LiveFixture f(200, 40);
|
||||
f.live();
|
||||
const f64 span = f.xr().span();
|
||||
const plot::Rect axes = std::as_const(f.graph).layout().axes;
|
||||
const f32 dx = 60.0f;
|
||||
drag(f.controller, axes, dx);
|
||||
|
||||
const f64 want = static_cast<f64>(dx) / static_cast<f64>(axes.w) * span;
|
||||
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), want, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.xr().span(), span, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.xr().max, f.newest() - want, 1e-9);
|
||||
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
|
||||
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::None);
|
||||
|
||||
f.feed(20);
|
||||
f.graph.layout();
|
||||
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), want, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.xr().span(), span, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.xr().max, f.newest() - want, 1e-9);
|
||||
}
|
||||
|
||||
/// Dragging toward the live edge stops at it: the offset clamps at 0, so the window
|
||||
/// ends at the newest sample instead of turning inside out -- the failure an
|
||||
/// unclamped offset would produce, and one no gesture could undo.
|
||||
PLOT_TEST(pan_cannot_pass_the_live_edge) {
|
||||
LiveFixture f(200, 40);
|
||||
f.live();
|
||||
const plot::Rect axes = std::as_const(f.graph).layout().axes;
|
||||
drag(f.controller, axes, 100.0f); // back from the edge
|
||||
PLOT_CHECK(f.graph.x_offset(f.ax) > 0.0);
|
||||
const f64 span = f.xr().span();
|
||||
|
||||
drag(f.controller, axes, -300.0f); // hard toward it, past it
|
||||
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), 0.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.xr().max, f.newest(), 1e-9);
|
||||
PLOT_CHECK_NEAR(f.xr().span(), span, 1e-9);
|
||||
PLOT_CHECK(f.xr().min < f.xr().max); // a real window, not an inverted one
|
||||
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
|
||||
}
|
||||
|
||||
/// The point of an offset: a user who scrolled back keeps seeing what they scrolled
|
||||
/// to. The window advances by exactly what the stream advanced, so the sample at its
|
||||
/// right edge is the same one frame after frame, and the span is unchanged.
|
||||
PLOT_TEST(offset_survives_new_samples) {
|
||||
LiveFixture f(200, 48);
|
||||
f.live();
|
||||
f.graph.set_x_offset(f.ax, 10.0);
|
||||
const f64 span = f.xr().span();
|
||||
const f64 newest0 = f.newest();
|
||||
const f64 edge_sample = f.xr().max;
|
||||
PLOT_CHECK_NEAR(edge_sample, newest0 - 10.0, 1e-9);
|
||||
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
f.feed(5);
|
||||
f.graph.layout();
|
||||
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), 10.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.xr().span(), span, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.xr().max - edge_sample, f.newest() - newest0, 1e-9);
|
||||
// ...and it lands on a sample, so the same sample is at the right edge rather
|
||||
// than the window having crept between two of them.
|
||||
PLOT_CHECK_NEAR((f.xr().max - newest0) / f.series.dx(),
|
||||
std::round((f.xr().max - newest0) / f.series.dx()), 1e-9);
|
||||
}
|
||||
PLOT_CHECK_NEAR(f.xr().max, f.newest() - 10.0, 1e-9);
|
||||
}
|
||||
|
||||
/// The pre-existing behaviour, pinned so the mode is provably opt-in: on an `Off`
|
||||
/// axis a gesture clears auto scale and the window then stays exactly where the
|
||||
/// gesture left it, however much data arrives. That is what following replaces, and
|
||||
/// it is still what an untouched plot does -- the six other controller cases above
|
||||
/// are about this path.
|
||||
PLOT_TEST(off_plot_zoom_and_pan_are_unchanged) {
|
||||
LiveFixture f(200, 40);
|
||||
f.graph.set_x_auto_scale(f.ax, true);
|
||||
f.graph.layout(); // today's implicit "follow": fit the retained window
|
||||
const plot::Range fitted = f.xr();
|
||||
const plot::Rect axes = std::as_const(f.graph).layout().axes;
|
||||
|
||||
const f32 px = axes.x + 0.3f * axes.w;
|
||||
const f64 under = f.graph.x_transform(f.ax).to_data_x(px);
|
||||
f.controller.on_wheel({px, axes.y + 0.5f * axes.h, 0.0f, 1.0f, 0});
|
||||
PLOT_CHECK_NEAR(f.graph.x_transform(f.ax).to_data_x(px), under, 1e-9); // anchor still
|
||||
PLOT_CHECK(f.xr().span() < fitted.span());
|
||||
PLOT_CHECK(!f.graph.x_axis(f.ax).auto_scale); // a gesture is still a user choice
|
||||
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Off); // and arms nothing
|
||||
|
||||
const f64 span = f.xr().span();
|
||||
const f64 grabbed = f.graph.x_transform(f.ax).to_data_x(axes.x + 0.25f * axes.w);
|
||||
drag(f.controller, axes, 60.0f);
|
||||
PLOT_CHECK_NEAR(f.xr().span(), span, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.graph.x_transform(f.ax).to_data_x(axes.x + 0.25f * axes.w + 60.0f), grabbed,
|
||||
1e-9);
|
||||
|
||||
// Frozen: the stream walks on and the window does not move. This is the pre-fix
|
||||
// behaviour, and the reason `Live` exists.
|
||||
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);
|
||||
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Off);
|
||||
}
|
||||
|
||||
/// The deferred DPI item from the UI-scale task: the middle-drag axis zoom's gain was
|
||||
/// per *device* pixel, so the same physical drag zoomed twice as much at 2x. The gain
|
||||
/// is now logical -- 200 logical px is one decade -- and the drag delta is converted
|
||||
/// through the plot's scale, so the same physical drag zooms the same amount at either
|
||||
/// scale. The wheel is in notches, a unit that is already device-independent, so it
|
||||
/// must *not* be converted: one notch scales the span by the same factor at both.
|
||||
PLOT_TEST(drag_zoom_is_logical_the_wheel_is_not_scaled) {
|
||||
for (const f32 scale : {1.0f, 2.0f}) {
|
||||
plot::Plot p;
|
||||
p.set_ui_scale(scale);
|
||||
p.set_viewport({0.0f, 0.0f, 800.0f * scale, 400.0f * scale});
|
||||
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
|
||||
plot::input::Controller c(p);
|
||||
c.set_viewport({0.0f, 0.0f, 800.0f * scale, 400.0f * scale});
|
||||
|
||||
const plot::PlotLayout& l = p.layout();
|
||||
const f32 strip_x = l.axes.x + 0.5f * l.axes.w;
|
||||
const f32 strip_y = l.x_label_area.y + 0.5f * l.x_label_area.h;
|
||||
const f32 drag_px = 20.0f * scale; // 20 *logical* px at both scales
|
||||
c.on_pointer({strip_x, strip_y, 0, 0, plot::input::Button::Middle, 0, true, true, false});
|
||||
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::AxisZoomX);
|
||||
c.on_pointer({strip_x, strip_y + drag_px, 0, drag_px, plot::input::Button::Middle, 0, true, false,
|
||||
false});
|
||||
c.on_pointer({strip_x, strip_y + drag_px, 0, 0, plot::input::Button::Middle, 0, false, false,
|
||||
true});
|
||||
PLOT_CHECK_NEAR(p.x_range(ax).span(), 100.0 * std::pow(10.0, -20.0 / 200.0), 1e-6);
|
||||
|
||||
const f64 span_before = p.x_range(ax).span();
|
||||
c.on_wheel({400.0f * scale, 200.0f * scale, 0.0f, 1.0f, 0});
|
||||
PLOT_CHECK_NEAR(p.x_range(ax).span(), span_before * std::exp(-0.15), 1e-9);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user