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);
|
||||
}
|
||||
}
|
||||
@@ -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