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

+30 -5
View File
@@ -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;