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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user