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

+262
View File
@@ -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);
}
}
+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);
}