feat(input): host-agnostic events and pan/zoom/cursor controller

This commit is contained in:
Martino Ferrari committed 2026-09-27 22:34:16 +02:00
1 parent 606e40a9f1
commit 5ab854104a
6 files changed
+718

No files matched your search

+1
View File
@@ -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)
+101
View File
@@ -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
+65
View File
@@ -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
+313
View File
@@ -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
+1
View File
@@ -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)
+237
View File
@@ -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);
}