// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari #include #include #include #include #include #include #include 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(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(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(px)), plot_->y_transform(0u).to_data_y(static_cast(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(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(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(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(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(e.dx) / static_cast(axes.w); const f64 fy = static_cast(e.dy) / static_cast(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(axes.x); const f64 ar = static_cast(axes.right()); const f64 ay = static_cast(axes.y); const f64 ab = static_cast(axes.bottom()); const f64 l = std::clamp(static_cast(sel.x), ax, ar); const f64 r = std::clamp(static_cast(sel.right()), ax, ar); const f64 t = std::clamp(static_cast(sel.y), ay, ab); const f64 b = std::clamp(static_cast(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(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(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(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(e.x)); const f64 delta = grabbed - drag_grab_x_; plot_->set_region(static_cast(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(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(drag_region_), x, drag_fixed_x_); } else { plot_->set_region(static_cast(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 that is not a positive number is not a scale -- a host that has not // measured its display, a field nobody filled in -- the same state // `Style::device()` tolerates. Here it means *no zoom*: dividing by it would make // `logical_dy` infinite and the factor infinite (or NaN), and that factor reaches // `set_x_range()`/`set_y_range()`, which store it verbatim and clear `auto_scale` // -- an axis left non-finite until an auto-fit. A negative scale would silently // invert the zoom direction too, so the whole class is refused here. const f64 scale = static_cast(plot_->ui_scale()); if (!(scale > 0.0) || !std::isfinite(scale)) return; const f64 logical_dy = static_cast(e.dy) / 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(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(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