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
+155
-8
@@ -15,7 +15,12 @@
|
||||
// (shaded band plus a `dx/dy/rms/mean` line) -- no drawing code here;
|
||||
// - the headless smoke mode the adapter implements once
|
||||
// (PLOT_DEMO_EXIT_AFTER_MS / PLOT_DEMO_SCREENSHOT), so CI can run this under
|
||||
// `xvfb-run` and check the frame it wrote.
|
||||
// `xvfb-run` and check the frame it wrote;
|
||||
// - `PLOT_DEMO_FOLLOW=1`: the same run with the time axis armed for live following
|
||||
// (`XFollow::Live`), driving three wheel notches and a pan through the host's own
|
||||
// controller and printing, at each step, the range's width, its distance behind
|
||||
// the newest sample and the follow state -- the interaction spec §12 is about,
|
||||
// measured on a real stream rather than in a unit test.
|
||||
//
|
||||
// The producer is joined before the stream it writes into is destroyed, and
|
||||
// before the host closes the GL context (main's destruction order does that).
|
||||
@@ -29,6 +34,7 @@
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <numbers>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
@@ -158,6 +164,133 @@ struct Meter {
|
||||
u64 frames = 0;
|
||||
};
|
||||
|
||||
/// The scripted live-follow sequence `PLOT_DEMO_FOLLOW=1` runs.
|
||||
///
|
||||
/// The follow arithmetic is pinned by the library's tests; "the plot keeps showing
|
||||
/// the newest sample while the user zooms and pans it" is an *interaction*, and this
|
||||
/// is the program that has one -- a real 1 MS/s producer, the real host, the real
|
||||
/// controller. So the sequence arms `XFollow::Live` once the stream holds data and
|
||||
/// then takes four measurements, one per frame:
|
||||
///
|
||||
/// (a) steady state, with the retained window framed;
|
||||
/// (b) after three wheel notches, rolled over the x label strip -- outside `axes`,
|
||||
/// where the wheel's anchor is the live edge, so the zoom changes the width and
|
||||
/// nothing else;
|
||||
/// (c) after a 100 px left-drag, the real pan gesture, which moves the window back
|
||||
/// from the live edge;
|
||||
/// (d) after a further 50 ms of data has arrived.
|
||||
///
|
||||
/// Each measurement prints the range's width, how far its right edge sits behind the
|
||||
/// newest sample, and whether following is still armed. Only the zoom may change the
|
||||
/// width, only the pan may change the distance behind, and following must be armed
|
||||
/// throughout; the last line of the run says whether that held, so the run is a check
|
||||
/// rather than a printout to be read.
|
||||
struct FollowScript {
|
||||
bool enabled = false;
|
||||
plot::u32 step = 0;
|
||||
plot::f64 width[4]{};
|
||||
plot::f64 behind[4]{};
|
||||
bool live[4]{};
|
||||
plot::f64 wait_from = 0.0;
|
||||
|
||||
static FollowScript from_env() {
|
||||
FollowScript s;
|
||||
const char* v = std::getenv("PLOT_DEMO_FOLLOW");
|
||||
s.enabled = v != nullptr && v[0] != '0' && v[0] != '\0';
|
||||
return s;
|
||||
}
|
||||
|
||||
/// One frame per step: the gesture, then the measurement of what it did. The last
|
||||
/// step waits for data to actually arrive, because the last measurement is about
|
||||
/// what more samples do to a window the user has moved.
|
||||
void advance(plot::glfw::Host& host, plot::data::StreamSeries& stream, plot::Plot& p,
|
||||
plot::u32 axis) {
|
||||
switch (step) {
|
||||
case 0:
|
||||
// Enough retained data for the numbers to be worth printing; the producer
|
||||
// starts before the window opens, so this usually fires on the first frame.
|
||||
if (stream.size() < 262144u) return;
|
||||
p.set_x_follow(axis, plot::XFollow::Live);
|
||||
p.auto_fit_x(); // the retained window, ending at the live edge
|
||||
record(0, p, stream, axis);
|
||||
break;
|
||||
case 1:
|
||||
notches(host, p);
|
||||
record(1, p, stream, axis);
|
||||
break;
|
||||
case 2:
|
||||
pan(host, p);
|
||||
record(2, p, stream, axis);
|
||||
wait_from = stream.x_bounds().max;
|
||||
break;
|
||||
case 3:
|
||||
// 50 ms at this demo's 1 MS/s, i.e. ~50000 samples of new data before the
|
||||
// fourth measurement.
|
||||
if (stream.x_bounds().max - wait_from < 0.05) return;
|
||||
record(3, p, stream, axis);
|
||||
report();
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
++step;
|
||||
}
|
||||
|
||||
private:
|
||||
/// Three notches of the wheel over the x label strip centre.
|
||||
static void notches(plot::glfw::Host& host, plot::Plot& p) {
|
||||
const plot::Rect strip = p.layout().x_label_area;
|
||||
const plot::input::WheelEvent w{strip.x + 0.5f * strip.w, strip.y + 0.5f * strip.h,
|
||||
0.0f, 1.0f, 0};
|
||||
for (int i = 0; i < 3; ++i) host.controller().on_wheel(w);
|
||||
}
|
||||
|
||||
/// A 100 px drag to the right across the axes, as press/move/release.
|
||||
static void pan(plot::glfw::Host& host, plot::Plot& p) {
|
||||
const plot::Rect axes = p.layout().axes;
|
||||
const f32 x = axes.x + 0.5f * axes.w;
|
||||
const f32 y = axes.y + 0.5f * axes.h;
|
||||
host.controller().on_pointer({x, y, 0.0f, 0.0f, plot::input::Button::Left, 0, true, true, false});
|
||||
host.controller().on_pointer(
|
||||
{x + 100.0f, y, 100.0f, 0.0f, plot::input::Button::Left, 0, true, false, false});
|
||||
host.controller().on_pointer(
|
||||
{x + 100.0f, y, 0.0f, 0.0f, plot::input::Button::Left, 0, false, false, true});
|
||||
}
|
||||
|
||||
void record(int i, plot::Plot& p, plot::data::StreamSeries& stream, plot::u32 axis) {
|
||||
p.layout();
|
||||
const plot::Range r = p.x_range(axis);
|
||||
width[i] = r.span();
|
||||
behind[i] = stream.x_bounds().max - r.max;
|
||||
live[i] = p.x_follow(axis) == plot::XFollow::Live;
|
||||
std::printf("[glfw_demo] follow %c: width %9.6f s behind %9.6f s follow %s newest %.6f s\n",
|
||||
"abcd"[i], width[i], behind[i], live[i] ? "armed" : "OFF",
|
||||
stream.x_bounds().max);
|
||||
std::fflush(stdout);
|
||||
}
|
||||
|
||||
void report() {
|
||||
// Tolerances rather than `==`: the window is recomputed from a newest x that has
|
||||
// moved between the frames the two numbers were taken on, so the last bits of
|
||||
// the width and the distance behind may differ.
|
||||
const auto same = [](plot::f64 a, plot::f64 b) {
|
||||
return std::fabs(a - b) <= 1e-9 * std::fabs(a) + 1e-12;
|
||||
};
|
||||
const auto at_edge = [](plot::f64 distance) { return std::fabs(distance) < 1e-6; };
|
||||
const bool zoom_only_width =
|
||||
width[0] > width[1] * 1.01 && same(width[1], width[2]) && same(width[2], width[3]);
|
||||
const bool pan_only_behind =
|
||||
at_edge(behind[0]) && at_edge(behind[1]) && behind[2] > 1e-6 && same(behind[2], behind[3]);
|
||||
const bool following = live[0] && live[1] && live[2] && live[3];
|
||||
std::printf(
|
||||
"[glfw_demo] follow checks: width changed only by the zoom: %s; distance behind only"
|
||||
" by the pan: %s; following armed throughout: %s\n",
|
||||
zoom_only_width ? "yes" : "NO", pan_only_behind ? "yes" : "NO",
|
||||
following ? "yes" : "NO");
|
||||
std::fflush(stdout);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
@@ -183,23 +316,37 @@ int main() {
|
||||
const u32 measure = p.add_measure(cursor_a, cursor_b, trace);
|
||||
|
||||
Meter meter;
|
||||
/// The scripted live-follow verification, off unless `PLOT_DEMO_FOLLOW` asks for
|
||||
/// it. It is the same shape as the headless smoke mode above: a driven run whose
|
||||
/// result is a few numbers on stdout.
|
||||
FollowScript script = FollowScript::from_env();
|
||||
// The cursors ride the live window until the user takes over: the first
|
||||
// ctrl+drag on a cursor, or a pan/zoom that clears the x axis' auto scale,
|
||||
// stops them where the user put them.
|
||||
// stops them where the user put them. Under `Live` the follow path owns the x
|
||||
// range whatever auto scale says, so a zoom is not the user taking the x view
|
||||
// over -- only a cursor drag, or following being switched off, is.
|
||||
bool cursors_follow = true;
|
||||
|
||||
const plot::glfw::FrameFn frame = [&](plot::Plot& graph, plot::f64 dt) {
|
||||
stream.acquire(); // once per frame, on the render thread
|
||||
|
||||
// Re-frame the live stream while the user has not overridden the ranges.
|
||||
// `auto_fit_*` re-arms auto scale, so it may only run while auto scale is
|
||||
// still on: calling it after a pan would undo the pan every frame.
|
||||
if (graph.x_axis(axis_x).auto_scale) graph.auto_fit_x();
|
||||
if (script.enabled) script.advance(host, stream, graph, axis_x);
|
||||
|
||||
// Re-frame the live stream while the user has not overridden the ranges, and
|
||||
// while the axis is not following. `auto_fit_*` re-arms auto scale, so it may
|
||||
// only run while auto scale is still on: calling it after a pan would undo the
|
||||
// pan every frame -- and under `Live` a fit means "back to the live edge", so a
|
||||
// per-frame one would undo the offset the user just panned to as well.
|
||||
if (graph.x_follow(axis_x) == plot::XFollow::Off && graph.x_axis(axis_x).auto_scale) {
|
||||
graph.auto_fit_x();
|
||||
}
|
||||
if (graph.y_axis(axis_y).auto_scale) graph.auto_fit_y(axis_y);
|
||||
|
||||
if (cursors_follow) {
|
||||
if (host.controller().drag_mode() == plot::input::DragMode::MoveCursor ||
|
||||
!graph.x_axis(axis_x).auto_scale) {
|
||||
const bool x_taken_over = graph.x_follow(axis_x) == plot::XFollow::Live
|
||||
? false
|
||||
: !graph.x_axis(axis_x).auto_scale;
|
||||
if (host.controller().drag_mode() == plot::input::DragMode::MoveCursor || x_taken_over) {
|
||||
cursors_follow = false;
|
||||
} else {
|
||||
const plot::Range xr = graph.x_range(axis_x);
|
||||
|
||||
@@ -30,6 +30,15 @@ class Series {
|
||||
virtual Range x_bounds() const = 0;
|
||||
virtual Range y_bounds() const = 0;
|
||||
virtual const MinMaxPyramid& pyramid() const = 0;
|
||||
/// True when the retained window slides as samples are produced, so that
|
||||
/// `x_bounds()` reports the window the series currently holds rather than an
|
||||
/// extent that only changes when the caller replaces the data. Only such a
|
||||
/// series has a *live edge* for `XFollow::Live` to follow -- the newest x it
|
||||
/// holds, which moves forward on its own -- so the follow path asks this rather
|
||||
/// than guessing from a type: `StreamSeries` says true, and every other series
|
||||
/// keeps the default. Virtual rather than a flag a caller could disagree with,
|
||||
/// so the series that owns the sliding window is the one that answers.
|
||||
virtual bool streaming() const noexcept { return false; }
|
||||
|
||||
/// Appends interleaved (x, y) pairs in data coordinates for level `level`.
|
||||
///
|
||||
|
||||
@@ -29,7 +29,8 @@ namespace plot::data {
|
||||
/// base, so that acquire re-reads the whole window.
|
||||
///
|
||||
/// x0() is therefore the x of the oldest retained sample, x_bounds().min == x0(),
|
||||
/// and y_bounds() covers exactly the published samples.
|
||||
/// and y_bounds() covers exactly the published samples. `streaming()` says true:
|
||||
/// this is the window that slides, the one `XFollow::Live` follows.
|
||||
///
|
||||
/// THREADING NOTE. The ring overwrites the oldest samples -- that is what keeps a
|
||||
/// live stream showing the newest data, and what the drop test pins down -- so a
|
||||
@@ -72,6 +73,9 @@ class StreamSeries final : public Series {
|
||||
Range y_bounds() const override;
|
||||
const MinMaxPyramid& pyramid() const override;
|
||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const override;
|
||||
/// Always true: this series' `x_bounds()` is the retained ring window, which
|
||||
/// slides with every `acquire()` -- the property `XFollow::Live` follows.
|
||||
bool streaming() const noexcept override { return true; }
|
||||
|
||||
protected:
|
||||
void copy_raw(u64 begin, u64 end, f32* out) const override;
|
||||
|
||||
@@ -45,12 +45,32 @@ enum class DragMode {
|
||||
/// Both axis-zoom drags read the vertical `dy` in pointer pixels with the
|
||||
/// wheel's sign convention (dragging down zooms in), anchored at the pointer's
|
||||
/// data coordinate on the axis they zoom. The drag has its own per-pixel
|
||||
/// sensitivity -- a pixel is not a wheel notch -- of one decade per 200 px, so
|
||||
/// a 200 px drag scales the span by 10.
|
||||
/// sensitivity -- a pixel is not a wheel notch -- of one decade per 200 **logical**
|
||||
/// px, so a 200 px drag scales the span by 10 whatever the display's scale.
|
||||
///
|
||||
/// A drag that changes no range is harmless: a box-zoom selection under 4 px on
|
||||
/// either side is a click, and leaves the ranges alone.
|
||||
///
|
||||
/// ## Zoom and pan on a live x axis (spec §12)
|
||||
///
|
||||
/// An x axis armed with `XFollow::Live` keeps following through every gesture
|
||||
/// below. Nothing here reads or writes the follow state, and the ranges these
|
||||
/// gestures set are read by `Plot::set_x_range` as a span and an offset behind the
|
||||
/// live edge rather than as absolute positions, so both halves of the requirement
|
||||
/// fall out of the paths that already existed:
|
||||
///
|
||||
/// - the wheel *multiplies* the span, so notches compound and the zoom is
|
||||
/// scale-free. Its anchor is the data coordinate under the pointer while the
|
||||
/// pointer is inside `axes`; outside it there is no such coordinate, and on a
|
||||
/// live axis the live edge is used instead (see `x_zoom_anchor()`), which leaves
|
||||
/// the window ending where it ended. Either way the offset is adjusted to keep
|
||||
/// the anchor still, and following stays armed;
|
||||
/// - a pan shifts the offset by the drag's data delta and leaves the span alone, so
|
||||
/// a user can walk back through the retained window and stops at the live edge
|
||||
/// rather than past it.
|
||||
///
|
||||
/// An `Off` axis -- every axis by default -- behaves exactly as it always has.
|
||||
///
|
||||
/// ## Tolerances are logical, the coordinates they are compared against are not
|
||||
///
|
||||
/// The plot geometry the controller hit-tests in is device pixels (see
|
||||
@@ -65,10 +85,13 @@ enum class DragMode {
|
||||
/// knows what its device pixel is worth, so the controller keeps no copy of the scale
|
||||
/// for a host to keep in step.
|
||||
///
|
||||
/// The middle-drag axis zoom's per-pixel gain is deliberately *not* in that group:
|
||||
/// it is a data-navigation sensitivity rather than a size (the wheel and `+`/`-`
|
||||
/// are scale-free already, being counted in notches), and spec §12 reworks the
|
||||
/// zoom and pan gestures that own it.
|
||||
/// The middle-drag axis zoom's per-pixel gain is logical for the same reason, by a
|
||||
/// different route: it is stated in *logical* pixels (200 of them is one decade),
|
||||
/// and the drag delta arrives in device pixels like every other coordinate here, so
|
||||
/// `axis_zoom()` divides it by `Plot::ui_scale()` before applying the gain. Without
|
||||
/// that the same physical drag would zoom twice as much at 2x. The wheel needs no
|
||||
/// such conversion: a notch is already device-independent, and the wheel path is
|
||||
/// left alone.
|
||||
class Controller {
|
||||
public:
|
||||
explicit Controller(Plot& plot) noexcept : plot_(&plot) {}
|
||||
@@ -115,8 +138,18 @@ class Controller {
|
||||
/// kInvalid when (px, py) is outside the plot axes.
|
||||
u64 cursor_at(f32 px, f32 py) const;
|
||||
|
||||
/// The data x a zoom of `axis` holds fixed, for a gesture at (px, py). The
|
||||
/// coordinate under the pointer while the pointer is inside `axes`, which is what
|
||||
/// makes a zoom feel anchored to the data being pointed at; outside `axes` there
|
||||
/// is no such coordinate, and a live axis (`XFollow::Live`) uses its live edge
|
||||
/// instead, so a zoom made from the label strip or off the plot zooms the window
|
||||
/// about the newest sample and leaves it ending where it ended.
|
||||
f64 x_zoom_anchor(u32 axis, f32 px, f32 py) const;
|
||||
|
||||
/// Zoom one axis about `anchor` (a data coordinate) by `factor`, recomputing
|
||||
/// the range so the anchor keeps its screen position. Leaves `auto_scale` false.
|
||||
/// the range so the anchor keeps its screen position. Leaves `auto_scale` false;
|
||||
/// under `XFollow::Live` the range is read by `Plot::set_x_range` as a span and an
|
||||
/// offset behind the live edge, which is what keeps following armed.
|
||||
void zoom_x_axis(u32 axis, f64 anchor, f64 factor);
|
||||
void zoom_y_axis(u32 axis, f64 anchor, f64 factor);
|
||||
|
||||
|
||||
@@ -10,6 +10,21 @@ namespace plot {
|
||||
/// Where an axis sits around the plot area.
|
||||
enum class AxisPlace { Bottom, Top, Left, Right };
|
||||
|
||||
/// How an x axis' visible window relates to the newest sample of the streaming
|
||||
/// series drawn on it.
|
||||
///
|
||||
/// `Off` is the default and means what the library has always meant: the axis'
|
||||
/// range is exactly what a caller set or what an auto-fit produced, and nothing
|
||||
/// recomputes it. `Live` makes the window a *view from the live edge*: every
|
||||
/// `layout()` places it at `[newest - offset - span, newest - offset]`, so new
|
||||
/// samples keep arriving at the right edge and no gesture can stop them (spec §12).
|
||||
///
|
||||
/// While an axis is `Live` the follow path owns its range: `auto_scale` is inert on
|
||||
/// that axis, and a range handed to `Plot::set_x_range` is read as a *span* and an
|
||||
/// *offset behind the live edge* rather than as absolute positions. See
|
||||
/// `Plot::set_x_follow()`.
|
||||
enum class XFollow { Off, Live };
|
||||
|
||||
/// Everything a caller can say about an axis at creation time. The six fields are
|
||||
/// the five of the design spec plus the label format; the trailing default keeps
|
||||
/// the spec's five-field aggregate initialisation working unchanged.
|
||||
@@ -23,8 +38,10 @@ struct AxisDesc {
|
||||
};
|
||||
|
||||
/// A live axis: its range and presentation flags, plus the tick list `refresh()`
|
||||
/// regenerates for the current pixel size. `auto_scale` and `log` are public
|
||||
/// because the input controller and the renderer both read and write them.
|
||||
/// regenerates for the current pixel size. `auto_scale`, `log` and `follow` are
|
||||
/// public because the input controller and the renderer both read and write them,
|
||||
/// and because a host that lays a plot out itself has to be able to state the
|
||||
/// follow mode it wants without a renderer to set it.
|
||||
struct Axis {
|
||||
std::string label;
|
||||
Range range{0.0, 1.0};
|
||||
@@ -32,6 +49,17 @@ struct Axis {
|
||||
AxisPlace place = AxisPlace::Bottom;
|
||||
bool log = false;
|
||||
TickFormat format = TickFormat::Si;
|
||||
/// Whether this axis follows the newest sample. Only meaningful on an x axis --
|
||||
/// a y axis has no live edge -- and `Off` on both, so nothing changes for a
|
||||
/// caller who never asks for it. See XFollow.
|
||||
XFollow follow = XFollow::Off;
|
||||
/// How far behind the live edge the visible window's right edge sits, in data
|
||||
/// units, under `XFollow::Live`. Stored rather than implied by the range,
|
||||
/// because the range is recomputed from the series' newest x on every frame:
|
||||
/// this is the part of the view the user chose, so it is the part that survives
|
||||
/// the recompute. `Plot::set_x_offset()` is the only way in, and clamps it to
|
||||
/// what the traces still retain.
|
||||
f64 offset = 0.0;
|
||||
/// Ticks for the last `refresh()`, ascending, majors and (when asked) minors.
|
||||
std::vector<Tick> ticks;
|
||||
|
||||
|
||||
@@ -155,6 +155,17 @@ class Plot {
|
||||
/// Sets an explicit range. Choosing a range is a user decision, so it also
|
||||
/// clears that axis' auto_scale: a later layout() must not snap the range back.
|
||||
/// auto_fit_x()/auto_fit_y() re-arm it.
|
||||
///
|
||||
/// **Under `XFollow::Live` on an x axis that has a live edge, the argument is not
|
||||
/// an absolute window.** It is read as the *span* the window should have and the
|
||||
/// *offset* it implies -- `offset = newest - r.max`, clamped to what the traces
|
||||
/// still retain -- and the visible window follows from those at the live edge of
|
||||
/// the moment. A caller who expects `r` to stay where it was put is in the wrong
|
||||
/// mode: `XFollow::Off` stores the range verbatim, which is what every existing
|
||||
/// caller gets. The point of the reinterpretation is that every gesture reaches
|
||||
/// the x range through this one setter, so pan, wheel, box and axis zoom all keep
|
||||
/// following for free instead of each needing its own special case -- and none of
|
||||
/// them clears the follow state.
|
||||
void set_x_range(u32 axis, Range r);
|
||||
void set_y_range(u32 axis, Range r);
|
||||
|
||||
@@ -162,6 +173,57 @@ class Plot {
|
||||
void set_x_auto_scale(u32 axis, bool on);
|
||||
void set_y_auto_scale(u32 axis, bool on);
|
||||
|
||||
// --- live follow ------------------------------------------------------
|
||||
|
||||
/// Arms or disarms live following on one x axis; see XFollow for what `Live`
|
||||
/// makes the visible window. **Only this disarms it**: no gesture -- pan, wheel,
|
||||
/// box zoom, axis zoom -- and no programmatic `set_x_range` reaches the follow
|
||||
/// state, because "an interaction silently switched off following for good" is
|
||||
/// the defect this state exists to remove (spec §12).
|
||||
///
|
||||
/// Arming an axis that has a live edge places its window at the live edge at
|
||||
/// once, so `x_range()` reports the window the next layout will produce; the span
|
||||
/// it starts from is the span the axis already had, so a caller that wants the
|
||||
/// retained window framed calls `auto_fit_x()`. Turning `Live` off leaves the
|
||||
/// axis exactly where it is: the last live window becomes an ordinary stored
|
||||
/// range, and `auto_scale` is untouched.
|
||||
///
|
||||
/// While an axis is `Live` *and* has a live edge, this path owns its range, and
|
||||
/// that has two consequences worth knowing. `auto_scale` is inert on the axis:
|
||||
/// `layout()` does not fit it, because the fit would overwrite the span the
|
||||
/// follow window is built from -- the user's zoom -- on every frame, and the
|
||||
/// follow path wins because following is the more specific statement about that
|
||||
/// axis (`auto_fit_x()` is how such an axis is framed, and it does fit). And the
|
||||
/// window is recomputed every frame, so its position is a *distance behind the
|
||||
/// newest sample* rather than a pair of numbers. With no live edge -- no trace on
|
||||
/// the axis, or none of them streaming -- nothing is recomputed and the axis
|
||||
/// keeps its stored range, `auto_scale` included, exactly as an `Off` axis does.
|
||||
void set_x_follow(u32 axis, XFollow f);
|
||||
/// The follow state of one x axis. `Off` for an axis that does not exist.
|
||||
XFollow x_follow(u32 axis) const;
|
||||
|
||||
/// How far behind the live edge the visible window's right edge sits, in data
|
||||
/// units. Clamped to `[0, retained span]`: the upper limit is what the traces
|
||||
/// still hold, so the window's right edge never goes behind the oldest sample the
|
||||
/// ring has, and the empty region in front of it -- data the ring dropped -- is
|
||||
/// what the view is left showing rather than a window invented over samples that
|
||||
/// are gone. The lower limit is why dragging toward the live edge stops instead
|
||||
/// of turning the window inside out. On an axis that is not `Live` the value is
|
||||
/// simply stored: it applies when following is armed, and changes nothing before.
|
||||
///
|
||||
/// A value that is not a number is refused outright -- the window it would place
|
||||
/// is not a window -- and leaves the last offset in place.
|
||||
void set_x_offset(u32 axis, f64 units_behind_live);
|
||||
/// The offset in force, as of the last `set_x_offset()`, `set_x_range()` or
|
||||
/// `layout()`. 0.0 for an axis that does not exist.
|
||||
f64 x_offset(u32 axis) const;
|
||||
|
||||
/// The newest x any visible streaming trace bound to `axis` holds: the coordinate
|
||||
/// a `Live` window is anchored to, which the controller uses as the anchor of a
|
||||
/// zoom made from outside `axes`. False -- and `newest` untouched -- when the axis
|
||||
/// has no such trace, i.e. when there is no live edge to follow.
|
||||
bool x_live_edge(u32 axis, f64& newest) const;
|
||||
|
||||
/// Data -> pixels for one axis, built from the axis' range, log flag and the
|
||||
/// current layout. Always invertible: a collapsed range or a collapsed axes
|
||||
/// rect is widened by math::Transform, so to_data_*(to_screen_*(v)) == v.
|
||||
@@ -178,6 +240,11 @@ class Plot {
|
||||
/// auto-fit tries again -- so a stream that starts at zero begins fitting on its
|
||||
/// own once it goes positive. A log axis is therefore never left with `min >=
|
||||
/// max` or a non-positive bound by an auto-fit.
|
||||
///
|
||||
/// Under `XFollow::Live` the fit is also the way back to the live edge: the span
|
||||
/// becomes the retained window's (with the same 2% margin) and the offset returns
|
||||
/// to 0, so the window ends at the newest sample again. It does **not** turn
|
||||
/// following off -- a fit is not how a plot stops following; `set_x_follow()` is.
|
||||
void auto_fit_x();
|
||||
/// Same for one y axis, over that axis' traces' y bounds.
|
||||
void auto_fit_y(u32 axis);
|
||||
@@ -277,6 +344,22 @@ class Plot {
|
||||
/// selects x or y). False when there is no such trace; `out` then retains
|
||||
/// whatever it held.
|
||||
bool trace_bounds_union(bool x_side, u32 axis, Range& out) const;
|
||||
/// The retained window of the visible **streaming** traces bound to one x axis:
|
||||
/// `min` the oldest x they still hold, `max` the newest -- the live edge. False
|
||||
/// when the axis has no such trace, i.e. when there is nothing to follow, and
|
||||
/// `out` then retains whatever it held.
|
||||
bool live_x_bounds(u32 axis, Range& out) const;
|
||||
/// True when the follow path owns `axis`: it is `Live` *and* has a live edge.
|
||||
/// What `autoscale_axes()` asks before fitting, and the only place "a live axis"
|
||||
/// is decided.
|
||||
bool follows_live(u32 axis) const;
|
||||
/// Places a `Live` x axis' window at its live edge -- `[newest - offset - span,
|
||||
/// newest - offset]`, with `span` the span the axis holds and `offset` clamped
|
||||
/// against the retained window first, so the window can never be moved over
|
||||
/// samples the ring has dropped. O(1) and allocation-free: `layout()` runs it on
|
||||
/// every frame. False, and a no-op, when the axis is not `Live`, has no live edge,
|
||||
/// or holds no usable span.
|
||||
bool place_live_x_window(u32 axis);
|
||||
/// Refit every axis that is still on auto scale. layout() runs this, so a
|
||||
/// growing stream stays framed until a caller gesture turns auto scale off.
|
||||
void autoscale_axes();
|
||||
|
||||
@@ -18,7 +18,10 @@ constexpr f64 kZoomPerNotch = 0.15;
|
||||
constexpr f64 kKeyZoomNotches = 1.0;
|
||||
/// Zoom per pixel of a middle-drag along an axis strip. A pointer drag reports
|
||||
/// pixels, a different unit from a wheel notch, so it must not reuse
|
||||
/// kZoomPerNotch: 200 px of drag is one decade, exp(-200 * ln10/200) == 0.1.
|
||||
/// kZoomPerNotch: 200 px of drag is one decade, exp(-200 * ln10/200) == 0.1. The
|
||||
/// pixels are **logical** ones -- a distance the hand moves, like the tolerances
|
||||
/// below -- while the deltas that arrive are device pixels, so `axis_zoom()`
|
||||
/// converts through the plot's scale before applying this.
|
||||
constexpr f64 kZoomPerPixel = std::numbers::ln10 / 200.0;
|
||||
/// How close the pointer must be to a cursor to grab it, in **logical** pixels: the
|
||||
/// listener's aim is a physical distance, so it is converted to the device-pixel
|
||||
@@ -135,7 +138,7 @@ void Controller::on_wheel(const WheelEvent& raw) {
|
||||
|
||||
if (zoom_x) {
|
||||
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
|
||||
zoom_x_axis(i, plot_->x_transform(i).to_data_x(static_cast<f64>(e.x)), factor);
|
||||
zoom_x_axis(i, x_zoom_anchor(i, e.x, e.y), factor);
|
||||
}
|
||||
}
|
||||
if (zoom_y) {
|
||||
@@ -314,12 +317,17 @@ void Controller::move_cursor(const PointerEvent& e) {
|
||||
|
||||
void Controller::axis_zoom(const PointerEvent& e) {
|
||||
// A pointer drag reports pixels, not wheel notches, so it uses its own
|
||||
// sensitivity: 200 px is one decade.
|
||||
const f64 factor = std::exp(-static_cast<f64>(e.dy) * kZoomPerPixel);
|
||||
// sensitivity: 200 px is one decade. The gain is a physical distance the hand
|
||||
// moves, so the device-pixel delta is converted to logical pixels first -- the
|
||||
// same drag must zoom the same amount on a 2x display. A scale no host should
|
||||
// ever set (0, or negative) makes the factor non-positive, and the guard below
|
||||
// turns that into no zoom at all rather than an axis re-centred on infinity.
|
||||
const f64 logical_dy = static_cast<f64>(e.dy) / static_cast<f64>(plot_->ui_scale());
|
||||
const f64 factor = std::exp(-logical_dy * kZoomPerPixel);
|
||||
if (!(factor > 0.0)) return;
|
||||
if (mode_ == DragMode::AxisZoomX) {
|
||||
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
|
||||
zoom_x_axis(i, plot_->x_transform(i).to_data_x(static_cast<f64>(e.x)), factor);
|
||||
zoom_x_axis(i, x_zoom_anchor(i, e.x, e.y), factor);
|
||||
}
|
||||
} else {
|
||||
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
|
||||
@@ -330,6 +338,23 @@ void Controller::axis_zoom(const PointerEvent& e) {
|
||||
|
||||
// --- helpers --------------------------------------------------------------
|
||||
|
||||
f64 Controller::x_zoom_anchor(u32 axis, f32 px, f32 py) const {
|
||||
// The pointer's own coordinate while it is over the data area -- the anchor a
|
||||
// zoom has always had, and the reason it feels attached to what is under the
|
||||
// cursor. Outside `axes` there is no data under the pointer at all, so the
|
||||
// coordinate it extrapolates to is a number the user cannot see; a live axis has
|
||||
// a better answer, its live edge, which makes a zoom from the label strip or from
|
||||
// off the plot keep the window ending at the newest sample instead of drifting
|
||||
// away from it. Any other axis keeps the extrapolated coordinate, and `Off` is
|
||||
// the default, so nothing changes for a plot that is not following.
|
||||
if (!std::as_const(*plot_).layout().axes.contains(px, py) &&
|
||||
plot_->x_follow(axis) == XFollow::Live) {
|
||||
f64 live = 0.0;
|
||||
if (plot_->x_live_edge(axis, live)) return live;
|
||||
}
|
||||
return plot_->x_transform(axis).to_data_x(static_cast<f64>(px));
|
||||
}
|
||||
|
||||
u64 Controller::cursor_at(f32 px, f32 py) const {
|
||||
const PlotLayout& lay = std::as_const(*plot_).layout();
|
||||
if (!lay.axes.contains(px, py)) return kInvalid;
|
||||
|
||||
+135
-5
@@ -165,8 +165,29 @@ Range Plot::y_range(u32 axis) const { return y_axis(axis).range; }
|
||||
|
||||
void Plot::set_x_range(u32 axis, Range r) {
|
||||
if (axis >= x_axes_.size()) return;
|
||||
x_axes_[axis].range = r;
|
||||
x_axes_[axis].auto_scale = false;
|
||||
Axis& a = x_axes_[axis];
|
||||
Range live{};
|
||||
if (a.follow == XFollow::Live && live_x_bounds(axis, live) && r.span() > 0.0 &&
|
||||
std::isfinite(r.min) && std::isfinite(r.max)) {
|
||||
// Under Live the caller is stating a *span* and where the window sits relative
|
||||
// to the live edge, not a pair of absolute positions: the next layout()
|
||||
// recomputes the window from the series' newest x, so storing `r` verbatim
|
||||
// would be a position that survives exactly until the next frame. The offset is
|
||||
// what carries the position -- derived here from the live edge as it is *now*,
|
||||
// which is what makes a zoom keep its anchor still and a pan shift by the
|
||||
// drag's data delta -- and place_live_x_window() clamps it and stores the
|
||||
// window that follows from it, so the state is consistent before the next
|
||||
// layout rather than only after it.
|
||||
a.range = r;
|
||||
a.offset = live.max - r.max;
|
||||
place_live_x_window(axis);
|
||||
a.auto_scale = false; // as ever: a range a caller chose is not an auto-fit
|
||||
return;
|
||||
}
|
||||
// Off, or Live with nothing streaming to follow: the range is stored verbatim,
|
||||
// which is what every caller of this setter got before follow existed.
|
||||
a.range = r;
|
||||
a.auto_scale = false;
|
||||
}
|
||||
|
||||
void Plot::set_y_range(u32 axis, Range r) {
|
||||
@@ -183,6 +204,95 @@ void Plot::set_y_auto_scale(u32 axis, bool on) {
|
||||
if (axis < y_axes_.size()) y_axes_[axis].auto_scale = on;
|
||||
}
|
||||
|
||||
void Plot::set_x_follow(u32 axis, XFollow f) {
|
||||
if (axis >= x_axes_.size()) return;
|
||||
x_axes_[axis].follow = f;
|
||||
// Arming a live axis places its window at once, so x_range() reports the window
|
||||
// the next layout produces rather than the one the axis held while it was Off.
|
||||
// Nothing else follows from it: the follow state is only ever changed here, and
|
||||
// the range the axis already held is the span `Live` starts from (see the header).
|
||||
place_live_x_window(axis);
|
||||
}
|
||||
|
||||
XFollow Plot::x_follow(u32 axis) const {
|
||||
return axis < x_axes_.size() ? x_axes_[axis].follow : XFollow::Off;
|
||||
}
|
||||
|
||||
void Plot::set_x_offset(u32 axis, f64 units_behind_live) {
|
||||
if (axis >= x_axes_.size()) return;
|
||||
// No such offset: a NaN would place a window that is not a window, and the last
|
||||
// usable one is a better answer than that for a value no gesture can undo.
|
||||
if (!std::isfinite(units_behind_live)) return;
|
||||
x_axes_[axis].offset = std::max(0.0, units_behind_live);
|
||||
// The upper end of the clamp is what the traces still retain, which only they
|
||||
// know: place_live_x_window() applies it, and with it brings the visible window
|
||||
// along so x_range() and x_offset() agree before the next layout. A no-op on an
|
||||
// axis that is not Live -- the value waits there for when following is armed.
|
||||
place_live_x_window(axis);
|
||||
}
|
||||
|
||||
f64 Plot::x_offset(u32 axis) const {
|
||||
return axis < x_axes_.size() ? x_axes_[axis].offset : 0.0;
|
||||
}
|
||||
|
||||
bool Plot::x_live_edge(u32 axis, f64& newest) const {
|
||||
Range live{};
|
||||
if (axis >= x_axes_.size() || !live_x_bounds(axis, live)) return false;
|
||||
newest = live.max;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Plot::live_x_bounds(u32 axis, Range& out) const {
|
||||
bool any = false;
|
||||
for (const Trace& t : traces_) {
|
||||
// Only a streaming series has a live edge. An array's bounds cover everything
|
||||
// it holds and change only when the caller replaces the data, so `Live` on such
|
||||
// a trace would be an anchor that never moves -- the axis is left to its stored
|
||||
// range instead (see place_live_x_window).
|
||||
if (t.axis_x != axis || !t.visible() || !t.series->streaming()) continue;
|
||||
const Range b = t.series->x_bounds();
|
||||
if (!std::isfinite(b.min) || !std::isfinite(b.max)) continue;
|
||||
if (!any) {
|
||||
out = b;
|
||||
any = true;
|
||||
} else {
|
||||
// The union of what the traces retain: the oldest x any of them still holds
|
||||
// and the newest any of them holds, so the window is clamped against the ring
|
||||
// that reaches furthest back and anchored to the sample that is newest.
|
||||
out.min = std::min(out.min, b.min);
|
||||
out.max = std::max(out.max, b.max);
|
||||
}
|
||||
}
|
||||
return any;
|
||||
}
|
||||
|
||||
bool Plot::follows_live(u32 axis) const {
|
||||
if (x_axes_[axis].follow != XFollow::Live) return false;
|
||||
Range live{};
|
||||
return live_x_bounds(axis, live);
|
||||
}
|
||||
|
||||
bool Plot::place_live_x_window(u32 axis) {
|
||||
Axis& a = x_axes_[axis];
|
||||
if (a.follow != XFollow::Live) return false;
|
||||
Range live{};
|
||||
if (!live_x_bounds(axis, live)) return false;
|
||||
// The span is the axis' own -- what the user zoomed to, or what the last fit
|
||||
// produced -- and the follow window only moves it: the span is what makes this a
|
||||
// scroll rather than a rescale. A span that is not a span is nothing to place a
|
||||
// window from, so the range is left exactly as it is.
|
||||
const f64 span = a.range.span();
|
||||
if (!std::isfinite(span) || !(span > 0.0)) return false;
|
||||
// The offset can outlive the window it names: a ring that dropped samples moves
|
||||
// the oldest x forward under it, so it is clamped against what the traces hold
|
||||
// *now* -- an offset past that would put the window's right edge behind the
|
||||
// oldest sample left, i.e. over data that is gone.
|
||||
const f64 retained = std::max(0.0, live.max - live.min);
|
||||
a.offset = std::clamp(a.offset, 0.0, retained);
|
||||
a.range = Range{live.max - a.offset - span, live.max - a.offset};
|
||||
return true;
|
||||
}
|
||||
|
||||
Transform Plot::x_transform(u32 axis) const {
|
||||
const Axis& a = x_axis(axis);
|
||||
const Rect r = layout_.axes;
|
||||
@@ -235,7 +345,14 @@ void Plot::auto_fit_x() {
|
||||
for (u32 i = 0; i < x_axes_.size(); ++i) {
|
||||
// Fitting re-arms auto scaling, so the next layout keeps the axis framed. An
|
||||
// axis with nothing bound to it has nothing to fit and nothing to re-arm.
|
||||
if (fit_x_axis(i)) x_axes_[i].auto_scale = true;
|
||||
if (!fit_x_axis(i)) continue;
|
||||
x_axes_[i].auto_scale = true;
|
||||
// Under Live a fit is also the way *back* to the live edge: the span becomes the
|
||||
// one that was just fitted, and following means the window ends at the newest
|
||||
// sample, so the offset returns to zero. Following stays armed -- a fit is not
|
||||
// how a plot stops following, set_x_follow() is.
|
||||
if (follows_live(i)) x_axes_[i].offset = 0.0;
|
||||
place_live_x_window(i);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,7 +363,11 @@ void Plot::auto_fit_y(u32 axis) {
|
||||
|
||||
void Plot::autoscale_axes() {
|
||||
for (u32 i = 0; i < x_axes_.size(); ++i) {
|
||||
if (x_axes_[i].auto_scale) fit_x_axis(i);
|
||||
// A Live axis with a live edge is owned by the follow path, and auto scale is
|
||||
// inert on it: a fit here would overwrite the span the follow window is built
|
||||
// from -- the user's zoom -- on every frame, which is exactly the fight spec §12
|
||||
// removes. auto_fit_x() is how a live axis is framed.
|
||||
if (x_axes_[i].auto_scale && !follows_live(i)) fit_x_axis(i);
|
||||
}
|
||||
for (u32 i = 0; i < y_axes_.size(); ++i) {
|
||||
if (y_axes_[i].auto_scale) fit_y_axis(i);
|
||||
@@ -317,9 +438,18 @@ MeasureSpan Plot::measure_span(u32 id) const noexcept {
|
||||
}
|
||||
|
||||
const PlotLayout& Plot::layout() {
|
||||
// Live x windows are placed first, so everything below that derives from a range
|
||||
// -- the ticks, the grid, the transforms the controller hit-tests through, and the
|
||||
// range a caller reads back -- describes the live edge of *this* frame rather than
|
||||
// the position the window had when the axis was last touched, and the renderer,
|
||||
// the controller and a caller all see one window per frame. O(1) per axis and
|
||||
// allocation-free, so a frame pays nothing for it.
|
||||
for (u32 i = 0; i < x_axes_.size(); ++i) place_live_x_window(i);
|
||||
|
||||
// Axes still on auto scale refit here, once per layout, so a growing stream
|
||||
// stays framed. A caller gesture clears auto_scale (set_x_range/set_y_range),
|
||||
// which is precisely what stops this from fighting the range the user chose.
|
||||
// which is precisely what stops this from fighting the range the user chose --
|
||||
// and a live axis is not fitted at all, because the follow path above owns it.
|
||||
autoscale_axes();
|
||||
|
||||
// The label padding is a logical size, so it is converted once, here: every strip
|
||||
|
||||
@@ -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