feat(input): host-agnostic events and pan/zoom/cursor controller
This commit is contained in:
1 parent
606e40a9f1
commit
5ab854104a
6 files changed
+718
No files matched your search
@@ -0,0 +1,313 @@
|
||||
#include <plot/input/controller.hpp>
|
||||
|
||||
#include <plot/math/transform.hpp>
|
||||
#include <plot/plot/plot.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <utility>
|
||||
|
||||
namespace plot::input {
|
||||
namespace {
|
||||
|
||||
/// Zoom step for one wheel notch (dy == 1). A notch changes the span by
|
||||
/// `exp(-0.15)` (~14%), a step that feels like one detent on a normal mouse.
|
||||
constexpr f64 kZoomPerNotch = 0.15;
|
||||
/// A keyboard zoom press is one wheel notch.
|
||||
constexpr f64 kKeyZoomNotches = 1.0;
|
||||
/// How close (in pixels) the pointer must be to a cursor to grab it.
|
||||
constexpr f32 kCursorHitRadiusPx = 5.0f;
|
||||
/// A box-zoom selection narrower than this on either side is a click.
|
||||
constexpr f32 kMinBoxPixels = 4.0f;
|
||||
|
||||
/// An axis endpoint in transform space: log10 when the axis is logarithmic, the
|
||||
/// value itself otherwise. Pan and zoom arithmetic runs in this space so a log
|
||||
/// axis translates and scales multiplicatively, exactly as its transform maps.
|
||||
f64 to_space(f64 v, bool log) noexcept {
|
||||
return log ? Transform::log10_positive(v) : v;
|
||||
}
|
||||
f64 from_space(f64 v, bool log) noexcept {
|
||||
return log ? std::pow(10.0, v) : v;
|
||||
}
|
||||
|
||||
/// Zoom factor for a number of wheel notches: positive notches (scroll up, `+`
|
||||
/// key) zoom in, so the span shrinks by `exp(-notches * kZoomPerNotch)`.
|
||||
f64 zoom_factor(f64 notches) noexcept { return std::exp(-notches * kZoomPerNotch); }
|
||||
|
||||
} // namespace
|
||||
|
||||
void Controller::set_viewport(Rect px) {
|
||||
// The controller owns no viewport: it forwards the caller's rect to the plot
|
||||
// (so the two can never disagree) and refreshes the layout the hit tests and
|
||||
// transforms read.
|
||||
plot_->set_viewport(px);
|
||||
plot_->layout();
|
||||
}
|
||||
|
||||
void Controller::on_pointer(const PointerEvent& e) {
|
||||
has_pointer_ = true;
|
||||
pointer_x_ = e.x;
|
||||
pointer_y_ = e.y;
|
||||
plot_->layout(); // keep the chrome geometry the gestures map through current
|
||||
|
||||
if (e.pressed && e.down) {
|
||||
begin_drag(e);
|
||||
return;
|
||||
}
|
||||
if (e.released) {
|
||||
end_drag();
|
||||
return;
|
||||
}
|
||||
if (e.down && mode_ != DragMode::None) update_drag(e);
|
||||
}
|
||||
|
||||
void Controller::on_wheel(const WheelEvent& e) {
|
||||
plot_->layout();
|
||||
const f64 factor = zoom_factor(static_cast<f64>(e.dy));
|
||||
if (!(factor > 0.0)) return;
|
||||
|
||||
const bool shift = has_mod(e.mods, Mod::Shift);
|
||||
const bool ctrl = has_mod(e.mods, Mod::Ctrl);
|
||||
// Shift alone restricts the zoom to x, ctrl alone to y; neither (or both)
|
||||
// zooms both axes. The data coordinate under the pointer stays put.
|
||||
const bool zoom_x = !(ctrl && !shift);
|
||||
const bool zoom_y = !(shift && !ctrl);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
if (zoom_y) {
|
||||
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
|
||||
zoom_y_axis(i, plot_->y_transform(i).to_data_y(static_cast<f64>(e.y)), factor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Controller::on_key(const KeyEvent& e) {
|
||||
if (!e.down) return; // every action below is press-only
|
||||
|
||||
if (e.key == kKeyZoomIn || e.key == kKeyZoomOut) {
|
||||
const f64 notches = e.key == kKeyZoomIn ? kKeyZoomNotches : -kKeyZoomNotches;
|
||||
const f64 factor = zoom_factor(notches);
|
||||
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
|
||||
const Axis& a = plot_->x_axis(i);
|
||||
const f64 lo = to_space(a.range.min, a.log);
|
||||
const f64 hi = to_space(a.range.max, a.log);
|
||||
zoom_x_axis(i, from_space(0.5 * (lo + hi), a.log), factor);
|
||||
}
|
||||
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
|
||||
const Axis& a = plot_->y_axis(i);
|
||||
const f64 lo = to_space(a.range.min, a.log);
|
||||
const f64 hi = to_space(a.range.max, a.log);
|
||||
zoom_y_axis(i, from_space(0.5 * (lo + hi), a.log), factor);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key == kKeyFit || e.key == kKeyFitUpper) {
|
||||
plot_->auto_fit_x();
|
||||
for (u32 i = 0; i < plot_->y_axis_count(); ++i) plot_->auto_fit_y(i);
|
||||
}
|
||||
}
|
||||
|
||||
u64 Controller::hovered_cursor() const {
|
||||
if (!has_pointer_) return kInvalid;
|
||||
return cursor_at(pointer_x_, pointer_y_);
|
||||
}
|
||||
|
||||
// --- drag dispatch --------------------------------------------------------
|
||||
|
||||
void Controller::begin_drag(const PointerEvent& e) {
|
||||
origin_x_ = e.x;
|
||||
origin_y_ = e.y;
|
||||
drag_cursor_ = kInvalid;
|
||||
|
||||
if (e.button == Button::Left && has_mod(e.mods, Mod::Shift)) {
|
||||
mode_ = DragMode::BoxZoom;
|
||||
selection_ = Rect{e.x, e.y, 0.0f, 0.0f};
|
||||
return;
|
||||
}
|
||||
if (e.button == Button::Left && has_mod(e.mods, Mod::Ctrl)) {
|
||||
const u64 hit = cursor_at(e.x, e.y);
|
||||
if (hit != kInvalid) {
|
||||
mode_ = DragMode::MoveCursor;
|
||||
drag_cursor_ = hit;
|
||||
return;
|
||||
}
|
||||
// Ctrl far from any cursor falls through to pan, like an unmodified drag.
|
||||
}
|
||||
if (e.button == Button::Middle) {
|
||||
const PlotLayout& lay = std::as_const(*plot_).layout();
|
||||
if (lay.x_label_area.contains(e.x, e.y)) {
|
||||
mode_ = DragMode::AxisZoomX;
|
||||
} else if (lay.y_label_area.contains(e.x, e.y)) {
|
||||
mode_ = DragMode::AxisZoomY;
|
||||
} else {
|
||||
mode_ = DragMode::None; // a middle drag off the label strips does nothing
|
||||
}
|
||||
return;
|
||||
}
|
||||
mode_ = DragMode::Pan; // left and right drag both pan
|
||||
}
|
||||
|
||||
void Controller::update_drag(const PointerEvent& e) {
|
||||
switch (mode_) {
|
||||
case DragMode::Pan: pan(e); break;
|
||||
case DragMode::BoxZoom: box_zoom_update(e); break;
|
||||
case DragMode::MoveCursor: move_cursor(e); break;
|
||||
case DragMode::AxisZoomX:
|
||||
case DragMode::AxisZoomY: axis_zoom(e); break;
|
||||
case DragMode::None: break;
|
||||
}
|
||||
}
|
||||
|
||||
void Controller::end_drag() {
|
||||
if (mode_ == DragMode::BoxZoom) apply_box_zoom();
|
||||
mode_ = DragMode::None;
|
||||
selection_ = Rect{};
|
||||
drag_cursor_ = kInvalid;
|
||||
}
|
||||
|
||||
// --- gestures -------------------------------------------------------------
|
||||
|
||||
void Controller::pan(const PointerEvent& e) {
|
||||
const Rect vp = plot_->viewport();
|
||||
if (!(vp.w > 0.0f) || !(vp.h > 0.0f)) return;
|
||||
const f64 fx = static_cast<f64>(e.dx) / static_cast<f64>(vp.w);
|
||||
const f64 fy = static_cast<f64>(e.dy) / static_cast<f64>(vp.h);
|
||||
|
||||
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
|
||||
const Axis& a = plot_->x_axis(i);
|
||||
const f64 lo = to_space(a.range.min, a.log);
|
||||
const f64 hi = to_space(a.range.max, a.log);
|
||||
const f64 d = -fx * (hi - lo); // drag right reveals earlier (smaller) data
|
||||
plot_->set_x_range(i, Range{from_space(lo + d, a.log), from_space(hi + d, a.log)});
|
||||
}
|
||||
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
|
||||
const Axis& a = plot_->y_axis(i);
|
||||
const f64 lo = to_space(a.range.min, a.log);
|
||||
const f64 hi = to_space(a.range.max, a.log);
|
||||
const f64 d = fy * (hi - lo); // screen y is inverted: dragging down raises y
|
||||
plot_->set_y_range(i, Range{from_space(lo + d, a.log), from_space(hi + d, a.log)});
|
||||
}
|
||||
}
|
||||
|
||||
void Controller::box_zoom_update(const PointerEvent& e) {
|
||||
const f32 x = std::min(origin_x_, e.x);
|
||||
const f32 y = std::min(origin_y_, e.y);
|
||||
selection_ = Rect{x, y, std::fabs(e.x - origin_x_), std::fabs(e.y - origin_y_)};
|
||||
}
|
||||
|
||||
void Controller::apply_box_zoom() {
|
||||
const Rect sel = selection_;
|
||||
// A selection under a few pixels on either side is a click: no zoom.
|
||||
if (sel.w < kMinBoxPixels || sel.h < kMinBoxPixels) return;
|
||||
|
||||
const Rect vp = plot_->viewport();
|
||||
if (!(vp.w > 0.0f) || !(vp.h > 0.0f)) return;
|
||||
const f64 vx = static_cast<f64>(vp.x);
|
||||
const f64 vy = static_cast<f64>(vp.y);
|
||||
const f64 vw = static_cast<f64>(vp.w);
|
||||
const f64 vh = static_cast<f64>(vp.h);
|
||||
const f64 vright = static_cast<f64>(vp.right());
|
||||
const f64 vbottom = static_cast<f64>(vp.bottom());
|
||||
|
||||
// Clamped to the viewport so a drag that left it cannot ask for a range
|
||||
// outside anything the caller can see.
|
||||
const f64 l = std::clamp(static_cast<f64>(sel.x), vx, vright);
|
||||
const f64 r = std::clamp(static_cast<f64>(sel.right()), vx, vright);
|
||||
const f64 t = std::clamp(static_cast<f64>(sel.y), vy, vbottom);
|
||||
const f64 b = std::clamp(static_cast<f64>(sel.bottom()), vy, vbottom);
|
||||
if (!(r > l) || !(b > t)) return;
|
||||
|
||||
const f64 fx0 = (l - vx) / vw;
|
||||
const f64 fx1 = (r - vx) / vw;
|
||||
const f64 fy0 = (vbottom - b) / vh; // the selection's bottom edge is range min
|
||||
const f64 fy1 = (vbottom - t) / vh;
|
||||
|
||||
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
|
||||
const Axis& a = plot_->x_axis(i);
|
||||
const f64 lo = to_space(a.range.min, a.log);
|
||||
const f64 hi = to_space(a.range.max, a.log);
|
||||
plot_->set_x_range(i, Range{from_space(lo + fx0 * (hi - lo), a.log),
|
||||
from_space(lo + fx1 * (hi - lo), a.log)});
|
||||
}
|
||||
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
|
||||
const Axis& a = plot_->y_axis(i);
|
||||
const f64 lo = to_space(a.range.min, a.log);
|
||||
const f64 hi = to_space(a.range.max, a.log);
|
||||
plot_->set_y_range(i, Range{from_space(lo + fy0 * (hi - lo), a.log),
|
||||
from_space(lo + fy1 * (hi - lo), a.log)});
|
||||
}
|
||||
}
|
||||
|
||||
void Controller::move_cursor(const PointerEvent& e) {
|
||||
if (drag_cursor_ == kInvalid) return;
|
||||
const u32 id = static_cast<u32>(drag_cursor_);
|
||||
const Cursor& cursor = plot_->cursor(id);
|
||||
plot_->set_cursor_x(id, plot_->x_transform(cursor.axis_x).to_data_x(static_cast<f64>(e.x)));
|
||||
}
|
||||
|
||||
void Controller::axis_zoom(const PointerEvent& e) {
|
||||
const f64 factor = zoom_factor(static_cast<f64>(e.dy));
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
|
||||
zoom_y_axis(i, plot_->y_transform(i).to_data_y(static_cast<f64>(e.y)), factor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers --------------------------------------------------------------
|
||||
|
||||
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;
|
||||
|
||||
u64 best = kInvalid;
|
||||
f32 best_d = 0.0f;
|
||||
for (u32 i = 0; i < plot_->cursor_count(); ++i) {
|
||||
const Cursor& cursor = plot_->cursor(i);
|
||||
if (!cursor.visible) continue;
|
||||
const f32 sx = static_cast<f32>(plot_->x_transform(cursor.axis_x).to_screen_x(cursor.x));
|
||||
const f32 d = std::fabs(sx - px);
|
||||
if (d > kCursorHitRadiusPx) continue;
|
||||
if (best == kInvalid || d < best_d) {
|
||||
best = i;
|
||||
best_d = d;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
void Controller::zoom_x_axis(u32 axis, f64 anchor, f64 factor) {
|
||||
const Axis& a = plot_->x_axis(axis);
|
||||
const Range r = a.range;
|
||||
if (!(r.span() > 0.0) || !(factor > 0.0)) return;
|
||||
const f64 lo = to_space(r.min, a.log);
|
||||
const f64 hi = to_space(r.max, a.log);
|
||||
const f64 aa = to_space(anchor, a.log);
|
||||
// Recomputing the range with the anchor held fixed keeps the anchor's screen
|
||||
// position invariant, for linear and log axes alike.
|
||||
plot_->set_x_range(axis, Range{from_space(aa + (lo - aa) * factor, a.log),
|
||||
from_space(aa + (hi - aa) * factor, a.log)});
|
||||
}
|
||||
|
||||
void Controller::zoom_y_axis(u32 axis, f64 anchor, f64 factor) {
|
||||
const Axis& a = plot_->y_axis(axis);
|
||||
const Range r = a.range;
|
||||
if (!(r.span() > 0.0) || !(factor > 0.0)) return;
|
||||
const f64 lo = to_space(r.min, a.log);
|
||||
const f64 hi = to_space(r.max, a.log);
|
||||
const f64 aa = to_space(anchor, a.log);
|
||||
plot_->set_y_range(axis, Range{from_space(aa + (lo - aa) * factor, a.log),
|
||||
from_space(aa + (hi - aa) * factor, a.log)});
|
||||
}
|
||||
|
||||
} // namespace plot::input
|
||||
Reference in new issue
Block a user