Files
plotter/src/input/controller.cpp
T

704 lines
33 KiB
C++

#include <plot/input/controller.hpp>
#include <plot/math/transform.hpp>
#include <plot/plot/plot.hpp>
#include <algorithm>
#include <cmath>
#include <numbers>
#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;
/// 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. 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
/// radius the comparison needs through `Style::device()` at the plot's own scale
/// (see `cursor_at()`). An unscaled constant would make grabbing physically harder
/// -- half as forgiving -- on a 2x display, which is the defect the scale exists to
/// prevent. Comfortably wider than a marker's 1 px stroke, so a press lands on the
/// handle the hover highlight promised: `cursor_at()` is the *one* value both the
/// hover and the grab read, and a region's edge bands are cut back from this same
/// number, so the affordance the host sees and the gesture it gets agree.
constexpr f32 kCursorHitRadiusLogical = 7.0f;
/// A box-zoom selection narrower than this on either side is a click. Logical for
/// the same reason: the gesture is the same hand movement on any display.
constexpr f32 kMinBoxLogical = 4.0f;
/// The narrowest *interior* a region's body keeps, in **logical** pixels: the edge bands are
/// cut back until this much of the middle is left, so a region too narrow for the full grab
/// radius on each side is still something a pointer can take hold of and *move*. Without it
/// every interior point of a region narrower than twice the radius (14 logical px) lies
/// within a bound's reach, and the bound branch would always win -- such a region could be
/// resized but never moved. Half of it either side of the midpoint, which is what makes the
/// middle of even the narrowest region with grabbable bounds (3 logical px) reachable.
constexpr f32 kMinRegionBodyLogical = 2.0f;
/// The narrowest region whose bounds are grabbable, in **logical** pixels. Closer
/// together than this, the two bounds are one target the pointer cannot separate:
/// an edge would win the hit test every time and the region could never be moved
/// again, so a *narrower* region is grabbed by its body -- widened to this same
/// minimum around its bounds, which keeps a collapsed region something a pointer can
/// still take hold of rather than a line. Logical like the two tolerances above, and
/// converted through the same one call.
constexpr f32 kMinRegionEdgeGrabLogical = 3.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); }
/// True when a coordinate is one arithmetic can work with. Every event here comes
/// from a host, and a host can hand over anything: a toolkit's "no pointer"
/// sentinel, a NaN from a degenerate mapping of its own, an infinity from a lost
/// device. A non-finite value rides the pan and zoom arithmetic into a range that
/// is no longer a range, and since a gesture that sets a range also clears auto
/// scale, `f` would be the only way back -- so it must not get in.
bool finite(f32 v) noexcept { return std::isfinite(v); }
/// `e`, with an unusable position replaced by the last known one and an unusable
/// delta by no movement. Sanitising rather than rejecting keeps the buttons in the
/// same event alive: a drag whose release arrived with a garbage position must
/// still end.
PointerEvent sanitized(const PointerEvent& e, bool have_pointer, f32 last_x,
f32 last_y) noexcept {
PointerEvent in = e;
if (!finite(in.x) || !finite(in.y)) {
in.x = have_pointer ? last_x : 0.0f;
in.y = have_pointer ? last_y : 0.0f;
in.dx = 0.0f;
in.dy = 0.0f;
return in;
}
if (!finite(in.dx)) in.dx = 0.0f;
if (!finite(in.dy)) in.dy = 0.0f;
return in;
}
/// `e`, with an unusable position replaced by the last known one. The wheel's
/// amount is deliberately left alone: `on_wheel` rejects an unusable one *before*
/// the event is acted on, because a zeroed amount is a zoom of exactly one, and
/// running that over an unchanged range would still clear every axis' auto scale.
WheelEvent sanitized(const WheelEvent& e, bool have_pointer, f32 last_x, f32 last_y) noexcept {
WheelEvent in = e;
if (!finite(in.x) || !finite(in.y)) {
in.x = have_pointer ? last_x : 0.0f;
in.y = have_pointer ? last_y : 0.0f;
}
return in;
}
} // 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();
// A resize moves the axes (and with them the data under a stationary pointer), so the
// crosshair is re-placed against the new rect -- and cleared if the pointer is now outside
// it, which is the same rule as a pointer that left.
if (has_pointer_) update_crosshair(pointer_x_, pointer_y_);
// The hover the layout is part of: a handle under the pointer may be somewhere
// else now, and the highlight the renderer draws reads what this publishes.
publish_hot();
}
void Controller::on_pointer(const PointerEvent& raw) {
const PointerEvent e = sanitized(raw, has_pointer_, pointer_x_, pointer_y_);
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);
} else if (e.released) {
end_drag(e);
} else if (e.down && mode_ != DragMode::None) {
update_drag(e);
}
// The crosshair is placed (or cleared) from the pointer's own position, after the gesture
// above: a press does not clear it -- the pointer is still over the axes, still pointing at
// whatever it just grabbed -- and a drag keeps it re-anchored to the pointer as the data
// moves under it.
update_crosshair(e.x, e.y);
// Every exit above ends here: a hover that moved, a gesture that opened, and a
// gesture that closed all change what is under the pointer, and the publish is
// what makes the renderer's highlight (and a host's resize cursor) follow.
publish_hot();
}
void Controller::on_wheel(const WheelEvent& raw) {
// An amount that is not a number is not a wheel: sanitising it to zero would look
// like a zero-notch zoom -- a factor of exactly one -- and the zoom body would
// still run, calling the range setters with the unchanged range. Those clear
// `auto_scale` unconditionally, so a single garbage wheel would silently disarm
// auto-fit on every axis. Return before the event can mutate anything.
if (!std::isfinite(raw.dy)) return;
const WheelEvent e = sanitized(raw, has_pointer_, pointer_x_, pointer_y_);
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, x_zoom_anchor(i, e.x, e.y), 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);
}
}
// The ranges moved under a pointer that did not, so the geometry under it changed: refresh
// the layout -- the region rects the hit test reads were measured for the range this wheel
// has just replaced -- and republish what is hot. A frame drawn right after a scroll then
// emphasizes the handle the pointer is over *now*, rather than the one it was over before,
// without waiting for the mouse to move again.
plot_->layout();
// The data under the pointer moved with the ranges, so the crosshair -- which is a data
// coordinate -- is re-anchored to the pointer rather than left on the value it was placed
// at: it follows the pointer, and this is the one thing that moves the data without moving
// the pointer.
if (has_pointer_) update_crosshair(pointer_x_, pointer_y_);
publish_hot();
}
void Controller::on_key(const KeyEvent& e) {
if (!e.down) return; // every action below is press-only
const bool zooms = e.key == kKeyZoomIn || e.key == kKeyZoomOut;
const bool fits = e.key == kKeyFit || e.key == kKeyFitUpper;
if (!zooms && !fits) return; // a key this controller has no gesture for
if (zooms) {
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);
}
} else {
plot_->auto_fit_x();
for (u32 i = 0; i < plot_->y_axis_count(); ++i) plot_->auto_fit_y(i);
}
// A key stroke that moved a range moved the geometry under a stationary pointer, exactly
// as a wheel notch does: refresh the layout and republish what is hot, so the highlight a
// frame draws is the handle the pointer is over now (see on_wheel). A key this controller
// has no gesture for changes nothing and returns above -- and so does not touch the
// crosshair either.
plot_->layout();
if (has_pointer_) update_crosshair(pointer_x_, pointer_y_);
publish_hot();
}
void Controller::update_crosshair(f32 px, f32 py) {
// Inside the data area the crosshair is at the coordinates under the pointer; outside it
// there is no data to read, so there is no crosshair -- the null answer, rather than a
// value clamped to the nearest edge of the window.
const PlotLayout& layout = std::as_const(*plot_).layout();
if (!layout.axes.contains(px, py)) {
plot_->clear_crosshair();
return;
}
// Both coordinates, on the primary axis pair (the same pair the grid and a region's fill
// are placed through): a pointer over the axes knows its x and its y, so a single plot's
// crosshair is the full cross. A `Figure` is driven by `Figure::set_crosshair()` instead,
// which applies one x to every row and draws no horizontal line -- its rows share x and
// not y.
plot_->set_crosshair(plot_->x_transform(0u).to_data_x(static_cast<f64>(px)),
plot_->y_transform(0u).to_data_y(static_cast<f64>(py)), true);
}
u64 Controller::hovered_cursor() const {
if (!has_pointer_) return kInvalid;
return cursor_at(pointer_x_, pointer_y_);
}
u64 Controller::hovered_region() const {
if (!has_pointer_) return kInvalid;
return region_hot(pointer_x_, pointer_y_).region;
}
RegionGrab Controller::region_grab(u32 region) const {
if (!has_pointer_) return RegionGrab::None;
for (const RegionLayout& candidate : std::as_const(*plot_).layout().regions) {
if (candidate.region == region) return zone_hit(candidate, pointer_x_, pointer_y_).zone;
}
return RegionGrab::None; // an id that names no visible region
}
Controller::ZoneHit Controller::zone_hit(const RegionLayout& region, f32 px, f32 py) const {
ZoneHit hit;
// Off the data area nothing is hot, whether the pointer is over a label strip or
// off the plot entirely. A bound that has scrolled just outside `axes` is still
// within reach of a pointer inside it -- the slack the cursor hit test has too --
// but the strips are not a place a region can be grabbed from.
if (!std::as_const(*plot_).layout().axes.contains(px, py)) return hit;
const f32 left_x = handle_column(region.left_handle);
const f32 right_x = handle_column(region.right_handle);
const f32 min_w = plot_->style().device(kMinRegionEdgeGrabLogical);
// Grabbable bounds: the bounds are told apart only while the region is wide enough to
// hold a band at each end and an interior between them -- below that the body is the
// only handle it has, which is the collapsed-region rule above -- and each band is cut
// back so that interior always exists. The radius every other handle in this controller
// is hit-tested with is kept wherever a region can afford it: the band is that radius
// while twice it plus the minimum interior still fits, and half the remainder after
// that, so the middle of a 6 px region is a body rather than inside both bounds. One
// tolerance, so what `hovered_cursor()` calls a hit and what a region bound calls a hit
// agree wherever there is room for both.
if (right_x - left_x >= min_w) {
const f32 radius = plot_->style().device(kCursorHitRadiusLogical);
const f32 band = std::min(radius, 0.5f * (right_x - left_x -
plot_->style().device(kMinRegionBodyLogical)));
const f32 dl = std::fabs(px - left_x);
const f32 dr = std::fabs(px - right_x);
const f32 d = std::min(dl, dr);
if (d <= band) {
hit.zone = dl <= dr ? RegionGrab::Left : RegionGrab::Right;
hit.distance = d;
return hit;
}
}
// No bound in range: the body, widened by half the minimum on each side so a
// region too narrow to have grabbable bounds is still something a pointer can take
// hold of rather than a line it cannot hit.
const f32 grow = 0.5f * min_w;
if (px >= left_x - grow && px <= right_x + grow) hit.zone = RegionGrab::Body;
return hit;
}
Controller::RegionHot Controller::region_hot(f32 px, f32 py) const {
RegionHot hot;
u64 body = kInvalid;
f32 nearest = 0.0f;
for (const RegionLayout& candidate : std::as_const(*plot_).layout().regions) {
const ZoneHit hit = zone_hit(candidate, px, py);
if (hit.zone == RegionGrab::None) continue;
if (hit.zone == RegionGrab::Body) {
// Remembered rather than chosen: a body only wins when no bound *anywhere* is
// in range, and the last region holding the pointer is the one on top --
// regions are drawn in order, so a later one covers an earlier one's interior.
body = candidate.region;
continue;
}
if (hot.region == kInvalid || hit.distance < nearest) {
hot.region = candidate.region;
hot.zone = hit.zone;
nearest = hit.distance;
}
}
if (hot.region != kInvalid) return hot;
return body == kInvalid ? RegionHot{} : RegionHot{body, RegionGrab::Body};
}
void Controller::publish_hot() {
HotHandles hot;
if (has_pointer_) {
hot.cursor = cursor_at(pointer_x_, pointer_y_);
if (drag_region_ != kInvalid) {
// An open region drag keeps its own zone hot for as long as it runs, so the
// highlight follows the grab rather than the pointer: a body dragged out from
// under the pointer still reads as the region being moved, and a bound keeps
// its emphasis after the pointer has left it.
hot.region = drag_region_;
hot.zone = drag_zone_;
} else {
const RegionHot region = region_hot(pointer_x_, pointer_y_);
hot.region = region.region;
hot.zone = region.zone;
}
}
hot.grabbed_cursor = drag_cursor_;
plot_->set_hot_handles(hot);
}
// --- drag dispatch --------------------------------------------------------
void Controller::begin_drag(const PointerEvent& e) {
origin_x_ = e.x;
origin_y_ = e.y;
drag_cursor_ = kInvalid;
drag_region_ = kInvalid;
drag_zone_ = RegionGrab::None;
// Shift first, and only on the left: it is a modifier gesture of its own, and a
// selection box is what it means even when it starts over a handle.
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::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;
}
// Everything else -- the left and the right button both, since a host that
// reserves the left one still drives its drags with the right -- grabs the handle
// under the press before it pans. Ctrl needs no special case: a press on a cursor
// does exactly this with or without it, and a ctrl press on nothing still pans.
if (begin_handle_drag(e)) return;
mode_ = DragMode::Pan; // left and right drag both pan
}
bool Controller::begin_handle_drag(const PointerEvent& e) {
// Cursor markers first: they are what a measure's band edges *are* (a band
// between two cursors is resized by dragging those cursors), and a marker is drawn
// over everything else, so it is the thing a press on it means.
const u64 cursor = cursor_at(e.x, e.y);
if (cursor != kInvalid) {
mode_ = DragMode::MoveCursor;
drag_cursor_ = cursor;
return true;
}
const RegionHot hot = region_hot(e.x, e.y);
if (hot.region == kInvalid) return false;
drag_region_ = hot.region;
drag_zone_ = hot.zone;
const Region& region = plot_->region(static_cast<u32>(hot.region));
switch (hot.zone) {
case RegionGrab::Left:
// The other bound is held for the whole gesture: each motion is then the two
// numbers this drag is made of, not a step from the previous one, so a pointer
// that moves back and forth cannot accumulate a drift -- and when it crosses
// the held bound the store orders them, which swaps the interval instead of
// inverting it (see Plot::set_region).
mode_ = DragMode::ResizeRegionLeft;
drag_fixed_x_ = region.x1;
return true;
case RegionGrab::Right:
mode_ = DragMode::ResizeRegionRight;
drag_fixed_x_ = region.x0;
return true;
case RegionGrab::Body:
// A body drag preserves the width exactly by construction: both bounds move by
// the same delta from where they were when the gesture opened.
mode_ = DragMode::MoveRegion;
drag_x0_ = region.x0;
drag_x1_ = region.x1;
drag_grab_x_ = plot_->x_transform(0u).to_data_x(static_cast<f64>(e.x));
return true;
case RegionGrab::None:
break;
}
drag_region_ = kInvalid;
drag_zone_ = RegionGrab::None;
return false;
}
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::MoveRegion: move_region(e); break;
case DragMode::ResizeRegionLeft:
case DragMode::ResizeRegionRight: resize_region(e); break;
case DragMode::AxisZoomX:
case DragMode::AxisZoomY: axis_zoom(e); break;
case DragMode::None: break;
}
}
void Controller::end_drag(const PointerEvent& e) {
// The release carries the authoritative end of the gesture: refresh the
// selection from it first, so a host that reports only a press and a release
// (no motion event in between) still frames the box the release describes
// instead of leaving selection_ at 0x0 and degrading the zoom to a click.
if (mode_ == DragMode::BoxZoom) {
box_zoom_update(e);
apply_box_zoom();
}
// A region's snap is applied exactly once, here, on the gesture's final bounds:
// the drag itself stores them verbatim, so they follow the pointer instead of
// sticking to the sample grid as it moves (see Plot::snap_region()).
if (drag_region_ != kInvalid) plot_->snap_region(static_cast<u32>(drag_region_));
// And the same for a grabbed cursor: `move_cursor()` wrote the free pointer position
// for the whole gesture, so this is where the marker lands on its own snap rule --
// the drop commits the snap, both orientations alike (see Plot::snap_cursor()).
if (drag_cursor_ != kInvalid) plot_->snap_cursor(static_cast<u32>(drag_cursor_));
mode_ = DragMode::None;
selection_ = Rect{};
drag_cursor_ = kInvalid;
drag_region_ = kInvalid;
drag_zone_ = RegionGrab::None;
}
// --- gestures -------------------------------------------------------------
void Controller::pan(const PointerEvent& e) {
// The transforms map a range onto layout().axes, so the pixel-to-data scale is
// the range span over the axes rect -- not over the whole viewport. Using the
// viewport (56 px of label strip on an 800 px plot) makes dragged data lag the
// pointer by the strip's fraction, breaking grab-and-drag.
const Rect axes = std::as_const(*plot_).layout().axes;
if (!(axes.w > 0.0f) || !(axes.h > 0.0f)) return;
const f64 fx = static_cast<f64>(e.dx) / static_cast<f64>(axes.w);
const f64 fy = static_cast<f64>(e.dy) / static_cast<f64>(axes.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 *logical* pixels on either side is a click: no zoom.
// The selection is measured in device pixels like everything the controller is
// handed, so the threshold is converted at the plot's own scale -- through the one
// place that conversion lives, so a change to its fallback is picked up here too.
const f32 min_box = plot_->style().device(kMinBoxLogical);
if (sel.w < min_box || sel.h < min_box) return;
// Clamped to the axes rect the transforms are built from, so a drag that left
// the plot area cannot ask for data outside what the box covers on screen.
const Rect axes = std::as_const(*plot_).layout().axes;
const f64 ax = static_cast<f64>(axes.x);
const f64 ar = static_cast<f64>(axes.right());
const f64 ay = static_cast<f64>(axes.y);
const f64 ab = static_cast<f64>(axes.bottom());
const f64 l = std::clamp(static_cast<f64>(sel.x), ax, ar);
const f64 r = std::clamp(static_cast<f64>(sel.right()), ax, ar);
const f64 t = std::clamp(static_cast<f64>(sel.y), ay, ab);
const f64 b = std::clamp(static_cast<f64>(sel.bottom()), ay, ab);
if (!(r > l) || !(b > t)) return;
// The ranges come straight from the transforms, so the box frames exactly the
// data under it; the selection's bottom edge is the range minimum on y.
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
const Transform tx = plot_->x_transform(i);
plot_->set_x_range(i, Range{tx.to_data_x(l), tx.to_data_x(r)});
}
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
const Transform ty = plot_->y_transform(i);
plot_->set_y_range(i, Range{ty.to_data_y(b), ty.to_data_y(t)});
}
}
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);
// A grabbed cursor mutates the coordinate its own orientation owns and nothing else, and
// each half names the one setter for it -- so a ruler dragged along y can never disturb a
// marker's x, and the dead coordinate of either is untouched by construction.
//
// Both writes are `CursorPlacement::Free`: the marker follows the pointer for the whole
// gesture and lands on its grid only when the drag ends (`end_drag()` calls
// `Plot::snap_cursor()`), so a snapping cursor tracks the hand instead of hopping between
// samples -- the defect reported from use. A caller reading `cursor(id).x` / `.y` while
// the drag runs reads that free position.
if (cursor.is_horizontal()) {
// Clamped to the ruler's own y axis: a pointer that has left the axes has no data under
// it, and the coordinate it extrapolates to is past the end of the axis the ruler reads
// values off -- a position where the ruler would either vanish (the renderer skips a row
// outside `axes`) or, worse, have to be dragged back by a zoom nobody asked for. Clamping
// keeps it readable at the edge it was dragged to instead. `set_cursor_y()` itself stays
// verbatim, so a *caller* that means to park a ruler off range still can.
const Axis& axis = plot_->y_axis(cursor.axis_y);
const f64 y = plot_->y_transform(cursor.axis_y).to_data_y(static_cast<f64>(e.y));
// `std::clamp` needs an ordered pair and a y range may be inverted -- descending depth,
// row index or time is ordinary -- because `set_y_range()` stores it verbatim and only
// the transform widens it. Order the bounds rather than assuming `min <= max`, or an
// inverted axis would collapse the dragged ruler onto one end (and the call is UB).
plot_->set_cursor_y(id, std::clamp(y, std::min(axis.range.min, axis.range.max),
std::max(axis.range.min, axis.range.max)),
CursorPlacement::Free);
return;
}
plot_->set_cursor_x(id, plot_->x_transform(cursor.axis_x).to_data_x(static_cast<f64>(e.x)),
CursorPlacement::Free);
}
void Controller::move_region(const PointerEvent& e) {
if (drag_region_ == kInvalid) return;
// The pointer's own data coordinate, not the event's delta: the delta a host
// reports for one motion is not always the difference between the positions it
// reported, and a region's two bounds must move by exactly the same amount or the
// width changes. Both come from the bounds the gesture opened with plus this one
// offset, so nothing accumulates over a long drag.
const f64 grabbed = plot_->x_transform(0u).to_data_x(static_cast<f64>(e.x));
const f64 delta = grabbed - drag_grab_x_;
plot_->set_region(static_cast<u32>(drag_region_), drag_x0_ + delta, drag_x1_ + delta);
}
void Controller::resize_region(const PointerEvent& e) {
if (drag_region_ == kInvalid) return;
const f64 x = plot_->x_transform(0u).to_data_x(static_cast<f64>(e.x));
// The bound the gesture did not grab stays where it was for the whole drag, and
// the grabbed one is wherever the pointer says. When the two cross, the *store*
// orders them (`set_region` keeps min and max), so the interval swaps rather than
// inverting -- and the pointer keeps hold of its bound: the next motion is still
// "the other end plus this pointer", with no state to get stuck in.
if (mode_ == DragMode::ResizeRegionLeft) {
plot_->set_region(static_cast<u32>(drag_region_), x, drag_fixed_x_);
} else {
plot_->set_region(static_cast<u32>(drag_region_), drag_fixed_x_, x);
}
}
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. 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, x_zoom_anchor(i, e.x, e.y), 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 --------------------------------------------------------------
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.
//
// A figure row's axis is `Off` even while its window follows, because a link group
// owns the shared window *and* the follow state (`Figure::link_x()` refuses a `Live`
// member) -- so the axis' own follow flag is not the whole answer. A following group
// publishes its edge to every member (`Plot::x_live_owner()`), and that edge is read
// second: it is the group's -- the union of its members' newest x -- which is the
// coordinate the window actually ends at, and the member's own series would be a
// different answer in a figure whose rows are fed at different rates.
if (!std::as_const(*plot_).layout().axes.contains(px, py)) {
f64 live = 0.0;
if (plot_->x_follow(axis) == XFollow::Live && plot_->x_live_edge(axis, live)) return live;
if (plot_->x_live_owner(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;
const f32 radius = plot_->style().device(kCursorHitRadiusLogical);
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;
// One hit test for both orientations, because a cursor *is* one line across the axes:
// what the pointer has to be near is that line's own coordinate -- the column of a
// vertical marker, the row of a horizontal ruler -- measured with the same radius
// either way, which is what keeps hover and grab agreeing about a ruler exactly as
// they do about a marker. A point within reach of one of each resolves to the nearer,
// so neither orientation widens the other's target.
const bool horizontal = cursor.is_horizontal();
const f32 d = std::fabs(plot_->cursor_screen_coordinate(i) - (horizontal ? py : px));
if (d > radius) 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