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

+9
View File
@@ -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`.
///
+5 -1
View File
@@ -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;
+40 -7
View File
@@ -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);
+30 -2
View File
@@ -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;
+83
View File
@@ -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();