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
@@ -24,6 +24,7 @@ add_library(plot_core
|
|||||||
src/plot/grid.cpp
|
src/plot/grid.cpp
|
||||||
src/plot/measure.cpp
|
src/plot/measure.cpp
|
||||||
src/plot/plot.cpp
|
src/plot/plot.cpp
|
||||||
|
src/input/controller.cpp
|
||||||
)
|
)
|
||||||
add_library(plot::core ALIAS plot_core)
|
add_library(plot::core ALIAS plot_core)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <plot/core/rect.hpp>
|
||||||
|
#include <plot/core/types.hpp>
|
||||||
|
#include <plot/input/events.hpp>
|
||||||
|
|
||||||
|
namespace plot {
|
||||||
|
class Plot;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace plot::input {
|
||||||
|
|
||||||
|
/// What a drag in progress is doing.
|
||||||
|
enum class DragMode {
|
||||||
|
None,
|
||||||
|
Pan, ///< translate every range (left or right drag)
|
||||||
|
BoxZoom, ///< a shift+left selection is open; the ranges change on release
|
||||||
|
MoveCursor, ///< ctrl+left drag is moving `hovered_cursor()`
|
||||||
|
AxisZoomX, ///< middle drag inside the x label strip; zooms x only
|
||||||
|
AxisZoomY, ///< middle drag inside the y label strip; zooms y only
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Turns host events into plot gestures, knowing nothing about Qt, GTK, GLFW or
|
||||||
|
/// ImGui -- Tasks 8-11 translate their native events into the value types above.
|
||||||
|
///
|
||||||
|
/// It is pure CPU and needs no GL context and no font: everything it reads comes
|
||||||
|
/// from `Plot`'s layout, ranges and transforms. The controller owns no viewport
|
||||||
|
/// of its own: set_viewport() forwards to `Plot::set_viewport` (and refreshes the
|
||||||
|
/// layout) so the two can never disagree, and every gesture runs through
|
||||||
|
/// `Plot`'s range setters, which clear `auto_scale` in one place.
|
||||||
|
///
|
||||||
|
/// Gesture table (grab-and-drag: the data follows the cursor):
|
||||||
|
/// | gesture | mode | effect |
|
||||||
|
/// |---|---|---|
|
||||||
|
/// | left drag | `Pan` | translate both ranges |
|
||||||
|
/// | right drag | `Pan` | same, for hosts that reserve left |
|
||||||
|
/// | shift + left drag | `BoxZoom` | both ranges from the selection rect |
|
||||||
|
/// | ctrl + left drag near a cursor | `MoveCursor` | move that cursor |
|
||||||
|
/// | middle drag on the x label strip | `AxisZoomX` | x-only zoom |
|
||||||
|
/// | middle drag on the y label strip | `AxisZoomY` | y-only zoom |
|
||||||
|
/// | wheel (shift: x only, ctrl: y only) | -- | zoom about the pointer |
|
||||||
|
/// | `+` / `-` | -- | zoom about the plot centre |
|
||||||
|
/// | `f` | -- | auto-fit every axis |
|
||||||
|
///
|
||||||
|
/// Both axis-zoom drags read the vertical `dy` with the wheel's sign convention
|
||||||
|
/// (dragging down zooms in), anchored at the pointer's data coordinate on the
|
||||||
|
/// axis they zoom.
|
||||||
|
///
|
||||||
|
/// 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.
|
||||||
|
class Controller {
|
||||||
|
public:
|
||||||
|
explicit Controller(Plot& plot) noexcept : plot_(&plot) {}
|
||||||
|
|
||||||
|
/// Sets the plot's viewport (the controller keeps none of its own) and
|
||||||
|
/// refreshes the layout, so gesture geometry and the host agree.
|
||||||
|
void set_viewport(Rect px);
|
||||||
|
|
||||||
|
void on_pointer(const PointerEvent& e);
|
||||||
|
void on_wheel(const WheelEvent& e);
|
||||||
|
void on_key(const KeyEvent& e);
|
||||||
|
|
||||||
|
DragMode drag_mode() const noexcept { return mode_; }
|
||||||
|
/// The current box-zoom selection in pixels; meaningful while BoxZoom.
|
||||||
|
Rect selection_rect() const noexcept { return selection_; }
|
||||||
|
/// The cursor under the pointer, or `kInvalid` when the pointer is outside the
|
||||||
|
/// plot area or no cursor is within a few pixels.
|
||||||
|
u64 hovered_cursor() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void begin_drag(const PointerEvent& e);
|
||||||
|
void update_drag(const PointerEvent& e);
|
||||||
|
void end_drag();
|
||||||
|
|
||||||
|
void pan(const PointerEvent& e);
|
||||||
|
void box_zoom_update(const PointerEvent& e);
|
||||||
|
void apply_box_zoom();
|
||||||
|
void move_cursor(const PointerEvent& e);
|
||||||
|
void axis_zoom(const PointerEvent& e);
|
||||||
|
|
||||||
|
/// The nearest visible cursor within the hit radius of (px, py), or kInvalid.
|
||||||
|
/// kInvalid when (px, py) is outside the plot axes.
|
||||||
|
u64 cursor_at(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.
|
||||||
|
void zoom_x_axis(u32 axis, f64 anchor, f64 factor);
|
||||||
|
void zoom_y_axis(u32 axis, f64 anchor, f64 factor);
|
||||||
|
|
||||||
|
Plot* plot_ = nullptr;
|
||||||
|
DragMode mode_ = DragMode::None;
|
||||||
|
Rect selection_{};
|
||||||
|
f32 origin_x_ = 0.0f;
|
||||||
|
f32 origin_y_ = 0.0f;
|
||||||
|
u64 drag_cursor_ = kInvalid;
|
||||||
|
bool has_pointer_ = false;
|
||||||
|
f32 pointer_x_ = 0.0f;
|
||||||
|
f32 pointer_y_ = 0.0f;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace plot::input
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <plot/core/types.hpp>
|
||||||
|
|
||||||
|
namespace plot::input {
|
||||||
|
|
||||||
|
/// Mouse buttons a host can report. The controller never assumes a fixed
|
||||||
|
/// mapping: right-drag pans as well as left, for hosts that reserve left-drag.
|
||||||
|
enum class Button { Left, Middle, Right };
|
||||||
|
|
||||||
|
/// Keyboard modifier bits for `PointerEvent::mods` / `WheelEvent::mods`.
|
||||||
|
enum class Mod { None = 0, Shift = 1, Ctrl = 2, Alt = 4, Super = 8 };
|
||||||
|
|
||||||
|
/// True when `mods` holds `m`. The single place modifier bits are tested, so a
|
||||||
|
/// call site never hand-rolls a mask.
|
||||||
|
constexpr bool has_mod(u32 mods, Mod m) noexcept {
|
||||||
|
return (mods & static_cast<u32>(m)) != 0u;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pointer event in viewport pixels (origin top-left, the same space as
|
||||||
|
/// `Rect`). `dx`/`dy` are the movement since the previous event; the controller
|
||||||
|
/// reads them for panning and the absolute `x`/`y` for anchoring and hit tests.
|
||||||
|
/// `pressed` and `released` are the transitions; `down` is the state after the
|
||||||
|
/// event. A plain move has all three false/true as appropriate (down only).
|
||||||
|
struct PointerEvent {
|
||||||
|
f32 x = 0.0f;
|
||||||
|
f32 y = 0.0f;
|
||||||
|
f32 dx = 0.0f;
|
||||||
|
f32 dy = 0.0f;
|
||||||
|
Button button = Button::Left;
|
||||||
|
u32 mods = 0;
|
||||||
|
bool down = false; ///< the button is held after this event
|
||||||
|
bool pressed = false; ///< a press transition happened on this event
|
||||||
|
bool released = false; ///< a release transition happened on this event
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A wheel/scroll event at a pixel position. A positive `dy` (scroll up) zooms
|
||||||
|
/// in; a positive `dx` scrolls right. Only `dy` drives the controller's zoom.
|
||||||
|
struct WheelEvent {
|
||||||
|
f32 x = 0.0f;
|
||||||
|
f32 y = 0.0f;
|
||||||
|
f32 dx = 0.0f;
|
||||||
|
f32 dy = 0.0f;
|
||||||
|
u32 mods = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A key event. `key` is an ASCII codepoint for printable keys; hosts that only
|
||||||
|
/// have scancodes translate them before calling. `down` distinguishes the press
|
||||||
|
/// from the release -- the controller's actions are press-only.
|
||||||
|
struct KeyEvent {
|
||||||
|
int key = 0;
|
||||||
|
u32 mods = 0;
|
||||||
|
bool down = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The printable keys the controller reacts to, named so no call site spells a
|
||||||
|
/// bare character literal. `kKeyZoomIn`/`kKeyZoomOut` zoom about the plot centre,
|
||||||
|
/// `kKeyFit` re-fits every axis to its traces.
|
||||||
|
inline constexpr int kKeyZoomIn = '+';
|
||||||
|
inline constexpr int kKeyZoomOut = '-';
|
||||||
|
inline constexpr int kKeyFit = 'f';
|
||||||
|
/// Hosts that report shifted keys uppercase can pass this unchanged.
|
||||||
|
inline constexpr int kKeyFitUpper = 'F';
|
||||||
|
|
||||||
|
} // namespace plot::input
|
||||||
@@ -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
|
||||||
@@ -35,6 +35,7 @@ add_executable(plot_tests
|
|||||||
test_ticks.cpp
|
test_ticks.cpp
|
||||||
test_measure.cpp
|
test_measure.cpp
|
||||||
test_plot.cpp
|
test_plot.cpp
|
||||||
|
test_controller.cpp
|
||||||
egl_headless.cpp
|
egl_headless.cpp
|
||||||
)
|
)
|
||||||
target_link_libraries(plot_tests PRIVATE plot::core ${plot_egl_library} Threads::Threads)
|
target_link_libraries(plot_tests PRIVATE plot::core ${plot_egl_library} Threads::Threads)
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
#include "harness.hpp"
|
||||||
|
|
||||||
|
#include <plot/input/controller.hpp>
|
||||||
|
#include <plot/plot/plot.hpp>
|
||||||
|
#include <plot/data/array_series.hpp>
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using plot::f32;
|
||||||
|
using plot::f64;
|
||||||
|
using plot::u32;
|
||||||
|
|
||||||
|
constexpr u32 kShift = static_cast<u32>(plot::input::Mod::Shift);
|
||||||
|
constexpr u32 kCtrl = static_cast<u32>(plot::input::Mod::Ctrl);
|
||||||
|
constexpr u32 kNoAxis = ~u32{0};
|
||||||
|
|
||||||
|
/// A 800x400 plot with one x axis spanning [0, 100] and the controller attached.
|
||||||
|
struct Fixture {
|
||||||
|
plot::Plot plot;
|
||||||
|
u32 ax = kNoAxis;
|
||||||
|
u32 ay = kNoAxis;
|
||||||
|
plot::input::Controller controller{plot};
|
||||||
|
|
||||||
|
Fixture() {
|
||||||
|
plot.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
||||||
|
ax = plot.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
|
||||||
|
controller.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
f32 screen_x_of(const plot::Plot& p, u32 axis, f64 data) {
|
||||||
|
return static_cast<f32>(p.x_transform(axis).to_screen_x(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
PLOT_TEST(wheel_zoom_keeps_data_under_cursor_fixed) {
|
||||||
|
plot::Plot p;
|
||||||
|
p.set_viewport({0, 0, 800, 400});
|
||||||
|
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, true, plot::AxisPlace::Bottom, false});
|
||||||
|
plot::input::Controller c(p);
|
||||||
|
c.set_viewport({0, 0, 800, 400});
|
||||||
|
const f32 cursor_x = 200.0f;
|
||||||
|
const f64 before = p.x_transform(ax).to_data_x(cursor_x);
|
||||||
|
plot::input::WheelEvent w{cursor_x, 100.0f, 0.0f, 1.0f, 0};
|
||||||
|
c.on_wheel(w);
|
||||||
|
const f64 after = p.x_transform(ax).to_data_x(cursor_x);
|
||||||
|
PLOT_CHECK_NEAR(before, after, 1e-6);
|
||||||
|
PLOT_CHECK(plot::Range{p.x_range(ax)}.span() < 100.0); // zoomed in
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(left_drag_pans_without_changing_span) {
|
||||||
|
plot::Plot p;
|
||||||
|
p.set_viewport({0, 0, 800, 400});
|
||||||
|
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, true, plot::AxisPlace::Bottom, false});
|
||||||
|
const u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
|
||||||
|
plot::input::Controller c(p);
|
||||||
|
c.set_viewport({0, 0, 800, 400});
|
||||||
|
const double span_before = p.x_range(ax).span();
|
||||||
|
c.on_pointer({100, 100, 0, 0, plot::input::Button::Left, 0, true, true, false});
|
||||||
|
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::Pan);
|
||||||
|
c.on_pointer({180, 130, 80, 30, plot::input::Button::Left, 0, true, false, false});
|
||||||
|
c.on_pointer({180, 130, 0, 0, plot::input::Button::Left, 0, false, false, true});
|
||||||
|
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None);
|
||||||
|
PLOT_CHECK_NEAR(p.x_range(ax).span(), span_before, 1e-9); // span unchanged
|
||||||
|
// 80 px right-drag on an 800 px plot showing [0, 100] reveals 10 units of
|
||||||
|
// earlier data: the data follows the cursor. (The brief printed +10.0 here.)
|
||||||
|
PLOT_CHECK_NEAR(p.x_range(ax).min, -10.0, 1e-6);
|
||||||
|
PLOT_CHECK(p.y_range(ay).max > 1.0); // moving down raises y range
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(shift_drag_box_zooms_to_selection) {
|
||||||
|
plot::Plot p;
|
||||||
|
p.set_viewport({0, 0, 800, 400});
|
||||||
|
const u32 ax = p.add_x_axis({"t", {0.0, 800.0}, true, plot::AxisPlace::Bottom, false});
|
||||||
|
plot::input::Controller c(p);
|
||||||
|
c.set_viewport({0, 0, 800, 400});
|
||||||
|
const u32 e_shift = 1u << 0; // Mod::Shift
|
||||||
|
c.on_pointer({100, 50, 0, 0, plot::input::Button::Left, e_shift, true, true, false});
|
||||||
|
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::BoxZoom);
|
||||||
|
c.on_pointer({300, 150, 200, 100, plot::input::Button::Left, e_shift, true, false, false});
|
||||||
|
PLOT_CHECK_NEAR(c.selection_rect().x, 100.0, 1e-6);
|
||||||
|
PLOT_CHECK_NEAR(c.selection_rect().w, 200.0, 1e-6);
|
||||||
|
c.on_pointer({300, 150, 0, 0, plot::input::Button::Left, e_shift, false, false, true});
|
||||||
|
PLOT_CHECK_NEAR(p.x_range(ax).min, 100.0, 1e-6);
|
||||||
|
PLOT_CHECK_NEAR(p.x_range(ax).max, 300.0, 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(auto_scale_disabled_by_user_interaction) {
|
||||||
|
// A wheel zoom or pan MUST clear auto_scale on the affected axis so the range
|
||||||
|
// the user chose is not snapped back by the next autoscale.
|
||||||
|
plot::Plot p;
|
||||||
|
p.set_viewport({0, 0, 800, 400});
|
||||||
|
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, true, plot::AxisPlace::Bottom, false});
|
||||||
|
plot::input::Controller c(p);
|
||||||
|
c.set_viewport({0, 0, 800, 400});
|
||||||
|
c.on_wheel({400.0f, 200.0f, 0.0f, 1.0f, 0});
|
||||||
|
PLOT_CHECK(!p.x_axis(ax).auto_scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- resolutions of the brief's ambiguity, pinned ------------------------
|
||||||
|
|
||||||
|
PLOT_TEST(box_zoom_below_threshold_is_a_click) {
|
||||||
|
plot::Plot p;
|
||||||
|
p.set_viewport({0, 0, 800, 400});
|
||||||
|
const u32 ax = p.add_x_axis({"t", {0.0, 800.0}, false, plot::AxisPlace::Bottom, false});
|
||||||
|
plot::input::Controller c(p);
|
||||||
|
c.set_viewport({0, 0, 800, 400});
|
||||||
|
c.on_pointer({100, 50, 0, 0, plot::input::Button::Left, kShift, true, true, false});
|
||||||
|
c.on_pointer({102, 52, 2, 2, plot::input::Button::Left, kShift, true, false, false});
|
||||||
|
c.on_pointer({102, 52, 0, 0, plot::input::Button::Left, kShift, false, false, true});
|
||||||
|
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None);
|
||||||
|
PLOT_CHECK_NEAR(p.x_range(ax).min, 0.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(p.x_range(ax).max, 800.0, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(shift_wheel_zooms_x_only) {
|
||||||
|
Fixture f;
|
||||||
|
f.ay = f.plot.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||||
|
f.controller.on_wheel({400.0f, 200.0f, 0.0f, 1.0f, kShift});
|
||||||
|
PLOT_CHECK(f.plot.x_range(f.ax).span() < 100.0);
|
||||||
|
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).span(), 2.0, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(ctrl_wheel_zooms_y_only) {
|
||||||
|
Fixture f;
|
||||||
|
f.ay = f.plot.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||||
|
f.controller.on_wheel({400.0f, 200.0f, 0.0f, 1.0f, kCtrl});
|
||||||
|
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), 100.0, 1e-9);
|
||||||
|
PLOT_CHECK(f.plot.y_range(f.ay).span() < 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(hovered_cursor_only_inside_plot_area) {
|
||||||
|
plot::Plot p;
|
||||||
|
p.set_viewport({0, 0, 800, 400});
|
||||||
|
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
|
||||||
|
const u32 cid = p.add_cursor(50.0, plot::Color::hex(0xFF0000));
|
||||||
|
plot::input::Controller c(p);
|
||||||
|
c.set_viewport({0, 0, 800, 400});
|
||||||
|
const f32 sx = screen_x_of(p, ax, 50.0);
|
||||||
|
c.on_pointer({sx, 200, 0, 0, plot::input::Button::Left, 0, false, false, false});
|
||||||
|
PLOT_CHECK(c.hovered_cursor() == cid);
|
||||||
|
// The left label strip is outside the plot area.
|
||||||
|
c.on_pointer({10, 200, 0, 0, plot::input::Button::Left, 0, false, false, false});
|
||||||
|
PLOT_CHECK(c.hovered_cursor() == plot::kInvalid);
|
||||||
|
c.on_pointer({sx, 200, 0, 0, plot::input::Button::Left, 0, false, false, false});
|
||||||
|
PLOT_CHECK(c.hovered_cursor() == cid);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(ctrl_drag_moves_the_nearest_cursor) {
|
||||||
|
plot::Plot p;
|
||||||
|
p.set_viewport({0, 0, 800, 400});
|
||||||
|
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
|
||||||
|
const u32 cid = p.add_cursor(20.0, plot::Color::hex(0xFF0000));
|
||||||
|
plot::input::Controller c(p);
|
||||||
|
c.set_viewport({0, 0, 800, 400});
|
||||||
|
const f32 sx = screen_x_of(p, ax, 20.0);
|
||||||
|
c.on_pointer({sx, 200, 0, 0, plot::input::Button::Left, kCtrl, true, true, false});
|
||||||
|
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::MoveCursor);
|
||||||
|
c.on_pointer({sx + 100, 200, 100, 0, plot::input::Button::Left, kCtrl, true, false, false});
|
||||||
|
c.on_pointer({sx + 100, 200, 0, 0, plot::input::Button::Left, kCtrl, false, false, true});
|
||||||
|
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None);
|
||||||
|
PLOT_CHECK(p.cursor(cid).x > 20.0);
|
||||||
|
PLOT_CHECK_NEAR(p.cursor(cid).x, p.x_transform(ax).to_data_x(sx + 100), 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(middle_drag_on_x_strip_zooms_x_only) {
|
||||||
|
Fixture f;
|
||||||
|
f.ay = f.plot.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
|
||||||
|
const plot::PlotLayout& l = f.plot.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;
|
||||||
|
f.controller.on_pointer({strip_x, strip_y, 0, 0, plot::input::Button::Middle, 0, true, true, false});
|
||||||
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::AxisZoomX);
|
||||||
|
// Same sign convention as the wheel: a positive dy zooms in.
|
||||||
|
f.controller.on_pointer({strip_x, strip_y + 20, 0, 20, plot::input::Button::Middle, 0, true, false, false});
|
||||||
|
f.controller.on_pointer({strip_x, strip_y + 20, 0, 0, plot::input::Button::Middle, 0, false, false, true});
|
||||||
|
PLOT_CHECK(f.plot.x_range(f.ax).span() < 100.0);
|
||||||
|
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).span(), 2.0, 1e-9);
|
||||||
|
PLOT_CHECK(!f.plot.x_axis(f.ax).auto_scale);
|
||||||
|
PLOT_CHECK(f.plot.y_axis(f.ay).auto_scale); // the untouched y axis keeps auto scale
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(middle_drag_on_y_strip_zooms_y_only) {
|
||||||
|
Fixture f;
|
||||||
|
f.ay = f.plot.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||||
|
const plot::PlotLayout& l = f.plot.layout();
|
||||||
|
const f32 strip_x = l.y_label_area.x + 0.5f * l.y_label_area.w;
|
||||||
|
const f32 strip_y = l.axes.y + 0.5f * l.axes.h;
|
||||||
|
f.controller.on_pointer({strip_x, strip_y, 0, 0, plot::input::Button::Middle, 0, true, true, false});
|
||||||
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::AxisZoomY);
|
||||||
|
f.controller.on_pointer({strip_x, strip_y + 20, 0, 20, plot::input::Button::Middle, 0, true, false, false});
|
||||||
|
f.controller.on_pointer({strip_x, strip_y + 20, 0, 0, plot::input::Button::Middle, 0, false, false, true});
|
||||||
|
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), 100.0, 1e-9);
|
||||||
|
PLOT_CHECK(f.plot.y_range(f.ay).span() < 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(key_plus_zooms_in_about_the_centre) {
|
||||||
|
Fixture f;
|
||||||
|
const f64 mid_before = 0.5 * (f.plot.x_range(f.ax).min + f.plot.x_range(f.ax).max);
|
||||||
|
f.controller.on_key({plot::input::kKeyZoomIn, 0, true});
|
||||||
|
PLOT_CHECK(f.plot.x_range(f.ax).span() < 100.0);
|
||||||
|
PLOT_CHECK_NEAR(0.5 * (f.plot.x_range(f.ax).min + f.plot.x_range(f.ax).max), mid_before, 1e-9);
|
||||||
|
const f64 span_after = f.plot.x_range(f.ax).span();
|
||||||
|
f.controller.on_key({plot::input::kKeyZoomIn, 0, false}); // key-up is ignored
|
||||||
|
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), span_after, 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(key_minus_zooms_out) {
|
||||||
|
Fixture f;
|
||||||
|
f.controller.on_key({plot::input::kKeyZoomOut, 0, true});
|
||||||
|
PLOT_CHECK(f.plot.x_range(f.ax).span() > 100.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(key_f_auto_fits_traces) {
|
||||||
|
plot::Plot p;
|
||||||
|
p.set_viewport({0, 0, 800, 400});
|
||||||
|
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
|
||||||
|
const u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||||
|
std::vector<f32> ys(51);
|
||||||
|
for (u64 i = 0; i < ys.size(); ++i) ys[i] = static_cast<f32>(i) * 0.1f; // 0.0 .. 5.0
|
||||||
|
plot::data::ArraySeries s(std::move(ys), 0.0, 1.0); // x = 0 .. 50
|
||||||
|
p.add_trace({&s, plot::Color::hex(0x4FA3FF), 1.5f, 0, 0});
|
||||||
|
plot::input::Controller c(p);
|
||||||
|
c.set_viewport({0, 0, 800, 400});
|
||||||
|
c.on_key({plot::input::kKeyFit, 0, true});
|
||||||
|
PLOT_CHECK(p.x_range(ax).min < 0.0);
|
||||||
|
PLOT_CHECK(p.x_range(ax).max < 100.0); // fitted to the series' 0..50
|
||||||
|
PLOT_CHECK(p.y_range(ay).min < 0.0);
|
||||||
|
PLOT_CHECK(p.y_range(ay).max > 1.0); // fitted to the series' 0..5
|
||||||
|
PLOT_CHECK(p.x_axis(ax).auto_scale); // a fit re-arms auto scale
|
||||||
|
PLOT_CHECK(p.y_axis(ay).auto_scale);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user