fix(input): pan and box zoom through layout().axes, not the viewport

Every pixel-to-data conversion in the controller now maps through the
same rectangle Plot::x_transform/y_transform are built from:

- pan() derives the scale from layout().axes.w/.h, so a drag moves the
  grabbed data point by exactly the drag distance (the viewport model
  stranded it behind the pointer by the label-strip fraction).
- apply_box_zoom() clamps the selection to layout().axes and sets each
  range from the transforms (x_transform().to_data_x(l/r), y_transform()
  .to_data_y(bottom/top)); the selection's bottom edge is the y minimum.
- end_drag() takes the release event and refreshes the selection from its
  coordinates before applying, so a host that reports only press and
  release still frames the box instead of degrading it to a click.
- axis_zoom() uses a per-pixel sensitivity (200 px == one decade) rather
  than reusing the wheel's per-notch constant, so a pixel drag is no
  longer treated as a wheel notch.

Tests now assert the grab-and-drag invariant through the transform (the
old pixel-fraction assertions certified the viewport defect), add a
press+release-only box zoom, and pin the axis-zoom magnitude.
This commit is contained in:
Martino Ferrari committed 2026-09-27 22:41:42 +02:00
1 parent 5ab854104a
commit e0cdd95a3b
4 files changed
+106 -55

No files matched your search

@@ -1119,14 +1119,22 @@ PLOT_TEST(left_drag_pans_without_changing_span) {
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();
const f64 grabbed = p.x_transform(ax).to_data_x(100.0f); // data under the press point
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
PLOT_CHECK_NEAR(p.x_range(ax).min, 10.0, 1e-6); // 80/800 * 100 data units
PLOT_CHECK(p.y_range(ay).max > 1.0); // moving down raises y range
// Assert the INVARIANT, not a pixel fraction. Grab-and-drag means the data
// grabbed at the press point is under the pointer afterwards. Pixels map to data
// through layout().axes (744 px wide for an 800 px viewport given the default
// y_axis_width of 56), NOT through the whole viewport: the viewport model makes
// dragged data lag the cursor by the label-strip fraction (56/800 = 7%), which
// is a defect no pixel-fraction assertion can see because it certifies its own
// arithmetic.
PLOT_CHECK_NEAR(p.x_transform(ax).to_data_x(180.0f), grabbed, 1e-9);
PLOT_CHECK(p.y_range(ay).max > 1.0); // moving down reveals higher y
}
PLOT_TEST(shift_drag_box_zooms_to_selection) {
@@ -1134,14 +1142,18 @@ PLOT_TEST(shift_drag_box_zooms_to_selection) {
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
const f64 want_lo = p.x_transform(ax).to_data_x(100.0f);
const f64 want_hi = p.x_transform(ax).to_data_x(300.0f);
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);
// The box frames exactly the data it encloses: the ranges come from the axes
// transform, and the selection is clamped to layout().axes, not the viewport.
PLOT_CHECK_NEAR(p.x_range(ax).min, want_lo, 1e-6);
PLOT_CHECK_NEAR(p.x_range(ax).max, want_hi, 1e-6);
}
PLOT_TEST(auto_scale_disabled_by_user_interaction) {
@@ -1166,8 +1178,8 @@ Expected: FAIL to compile — `plot/input/controller.hpp` not found.
| gesture | mode | effect |
|---|---|---|
| left drag | `Pan` | translate both ranges by `-dx_px / plot_width * x_span` (and y likewise, inverted) |
| shift + left drag | `BoxZoom` | record `selection_rect()`; on release set both ranges from the rect (ignore sub-4px rects — treat as a click) |
| left drag | `Pan` | translate both ranges so the grabbed data point stays under the pointer: the pixel-to-data scale is `span / layout().axes.w` for x and `span / layout().axes.h` for y (inverted), i.e. translate by `-dx_px / axes.w * x_span`. Using the whole-viewport width here makes dragged data lag the cursor by the label-strip fraction. |
| shift + left drag | `BoxZoom` | record `selection_rect()`; on release set both ranges from the rect. Clamp the rect to `layout().axes`, map it with `x_transform(i).to_data_x(l/r)` and `y_transform(i).to_data_y(bottom/top)`, and ignore sub-4px rects (treat as a click). The **release event's coordinates are authoritative** for the gesture end: refresh the selection from them before applying, or a host that reports only press and release leaves the box empty and the zoom silently degrades to a click. |
| ctrl + left drag near a cursor | `MoveCursor` | move that cursor, re-snapping and re-evaluating its measures |
| wheel at (x,y) | — | zoom both axes about the pointer's data coordinate by `exp(-dy * k)`; hold shift to zoom x only, ctrl to zoom y only |
| middle drag on the x-axis strip | `AxisZoomX` | x-only zoom |