feat(plot)!: harden pipeline, XY series, dash/markers, touch, packaging
Breaking: Series::gather emits x relative to x0() and takes a GatherMode; consumers reconstruct absolute x in f64 before mapping (series.hpp documents the contract). Bugs, each pinned by a fail-before regression test: - render: f32 staging quantised epoch-time x into 128 px columns; every data-space draw now stages to_space(x) - to_space(origin) with the origin folded into the offset uniform in f64 (epoch/zero-origin twins rasterise identically) - ticks: major test was ULP-fragile and blanked epoch axes; majors are an integer-index test and are never truncated by the minor cap - gather: LOD envelope emitted buckets straddling [begin,end); ceil/floor bucket edges + raw lead/trail spans - live follow: log x axes held a linear span and zoomed every frame; span and offset now live in axis space (plot + both figure link-group paths) - input: a zero-notch wheel disarmed auto_scale on every axis; dy==0 pans x, factor == 1 / inf refused in wheel and drag zoom - render: the single text flush painted legend names over the crosshair readout panel; two batches, under flushed before the panel - data: NaN dropped from min/max unless it seeded the fold; non-finite samples are now excluded from every aggregate and from cached bounds Robustness: viewport normalisation (no clamp UB), non-finite range refusal, pyramid level cap at 57 (no shift/div UB through public gather), rounded ring capacity documented + pre-sized, GL blend func and UNPACK_ALIGNMENT save/restore, Renderer::release_gl() teardown, u64 buffer epochs, annotation captions clamped, button-aware GLFW row capture, cached figure row rects (no per-event layout), ruler-pair measurement refused, span-nonfinite guards in axis_ends. Features: XY series (sorted x, binary-search neighbours/snap, LOD over sample index); StreamSeries::freeze(); ArraySeries::push(); per-trace dash and markers (scatter at width 0) with auto or literal stride; GatherMode Envelope/Nearest/Mean via bucket_sum/sumsq; Style::line_aa_px; per-glyph text colour (readout values in trace colour); wheel-dx x pan; arrow-key pan; Escape cancels a drag and restores captured ranges; modifier-masked box zoom (Shift both, Ctrl x, Alt y); double-click reset via PointerEvent::time_s; ImGui key repeat unified; pinch gestures on Qt and GTK; pixelDelta wheel path and DevicePixelRatioChange handling; GNUInstallDirs, SOVERSION, SameMinorVersion, generated plot/version.hpp, relocatable plot.pc, licence install, FILES_MATCHING, GitHub Actions CI (debug/coverage/asan). Suite 283 -> 350 cases, 0 failed; coverage 93.9% (3730/3971) over 24 files, floor 80/90 held; ASAN preset suite green; all four demos run headless (the GTK scripted-resize check needs a window manager and fails identically on the parent commit); readme_main.cpp committed and compiles with the README command; pkg-config and find_package consumption verified from a relocated install prefix.
This commit is contained in:
1 parent
f132a5ec92
commit
38ef459897
77 files changed
+5905
-688
No files matched your search
+296
-40
@@ -39,6 +39,21 @@ 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;
|
||||
/// Share of an axis' span one horizontal wheel notch (`dx`) pans, in axis space:
|
||||
/// a notch is a small, deliberate step, a twentieth of the view.
|
||||
constexpr f64 kPanPerNotch = 0.05;
|
||||
/// Share of an axis' span one arrow-key press pans, in axis space -- twice the
|
||||
/// wheel's step, because a key press is a coarser statement than a detent. A held
|
||||
/// key repeats (the adapters' shared policy), so a hold pans continuously.
|
||||
constexpr f64 kPanPerPress = 0.10;
|
||||
/// The double-click window: two presses of the same button within this many
|
||||
/// seconds -- and within `kDoubleClickPxLogical` of each other -- are a double-click
|
||||
/// reset, not two gestures. Measured on `PointerEvent::time_s`, the host's clock.
|
||||
constexpr f64 kDoubleClickSeconds = 0.35;
|
||||
/// How close together the two presses of a double-click must land, in **logical**
|
||||
/// pixels: the same hand-distance rule as the other tolerances here, converted
|
||||
/// through `Style::device()` at the plot's scale.
|
||||
constexpr f32 kDoubleClickPxLogical = 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
|
||||
@@ -136,7 +151,13 @@ void Controller::on_pointer(const PointerEvent& raw) {
|
||||
plot_->layout(); // keep the chrome geometry the gestures map through current
|
||||
|
||||
if (e.pressed && e.down) {
|
||||
begin_drag(e);
|
||||
if (double_click(e)) {
|
||||
// The second press of a double-click re-fits every axis and begins no drag:
|
||||
// a reset is a click-shaped gesture, not the start of one.
|
||||
fit_all();
|
||||
} else {
|
||||
begin_drag(e);
|
||||
}
|
||||
} else if (e.released) {
|
||||
end_drag(e);
|
||||
} else if (e.down && mode_ != DragMode::None) {
|
||||
@@ -158,31 +179,59 @@ void Controller::on_wheel(const WheelEvent& raw) {
|
||||
// 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;
|
||||
// auto-fit on every axis. `dx` is refused by the same rule: it pans when `dy` is
|
||||
// zero, and a garbage pan is no more welcome than a garbage zoom. Return before
|
||||
// the event can mutate anything.
|
||||
if (!std::isfinite(raw.dy) || !std::isfinite(raw.dx)) 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);
|
||||
// A zero-notch wheel -- `dy == 0` and `dx == 0`, which a trackpad or a driver
|
||||
// happily forwards -- must change nothing at all. Letting it run would call the
|
||||
// range setters with every range unchanged, and those clear `auto_scale`: one
|
||||
// empty scroll would silently disarm auto-fit on every axis. There is nothing to
|
||||
// apply, so there is nothing to lay out or republish either.
|
||||
if (e.dy == 0.0f && e.dx == 0.0f) return;
|
||||
|
||||
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 (e.dy != 0.0f) {
|
||||
const f64 factor = zoom_factor(static_cast<f64>(e.dy));
|
||||
// The zoom is refused, and the whole event with it, when its factor could not
|
||||
// be applied honestly: a factor of exactly one changes no range but would
|
||||
// still clear `auto_scale` on the way through the setters, a non-finite one
|
||||
// (a huge downward `dy` explodes `exp`) would hand a non-finite span to the
|
||||
// setters, and a non-positive one is not a zoom at all. "Leaves the plot
|
||||
// exactly as it was" is the rule for an unusable amount.
|
||||
if (!std::isfinite(factor) || !(factor > 0.0) || factor == 1.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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A horizontal wheel pans x only: `dx` notches, 5% of the x span each, in axis
|
||||
// space (so a log axis scrolls multiplicatively, exactly as its transform maps).
|
||||
// A positive `dx` scrolls right -- the view moves toward larger x, the same
|
||||
// reading `WheelEvent`'s comment gives the field. On a live axis the range
|
||||
// setter reads the shifted window as a span and an offset behind the edge, so
|
||||
// scrolling right runs to the live edge and stops there, exactly as a drag pan
|
||||
// does.
|
||||
if (e.dx != 0.0f) shift_x_axes(static_cast<f64>(e.dx) * kPanPerNotch);
|
||||
|
||||
// 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
|
||||
@@ -197,12 +246,79 @@ void Controller::on_wheel(const WheelEvent& raw) {
|
||||
publish_hot();
|
||||
}
|
||||
|
||||
void Controller::on_gesture(const GestureEvent& raw) {
|
||||
// The same refusal rule as the wheel: a frame carrying something that is not
|
||||
// a number is not a gesture, and a frame that could not change anything --
|
||||
// a unit scale and no movement -- must not reach the range setters, which
|
||||
// clear `auto_scale` for no visible change. `scale <= 0` is not a zoom.
|
||||
if (!std::isfinite(raw.x) || !std::isfinite(raw.y) || !std::isfinite(raw.scale) ||
|
||||
!std::isfinite(raw.dx) || !std::isfinite(raw.dy)) {
|
||||
return;
|
||||
}
|
||||
if (raw.scale <= 0.0f) return;
|
||||
if (raw.scale == 1.0f && raw.dx == 0.0f && raw.dy == 0.0f) return;
|
||||
|
||||
plot_->layout();
|
||||
|
||||
const f64 factor = 1.0 / static_cast<f64>(raw.scale); // pinch out = zoom in
|
||||
if (std::isfinite(factor) && factor > 0.0 && factor != 1.0) {
|
||||
// A pinch is a two-handed instrument, so it has no modifier semantics: it
|
||||
// zooms both axes about the focal point, exactly as an unmodified wheel
|
||||
// notch pair would -- same setters, same live-axis behaviour.
|
||||
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
|
||||
zoom_x_axis(i, x_zoom_anchor(i, raw.x, raw.y), factor);
|
||||
}
|
||||
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
|
||||
zoom_y_axis(i, plot_->y_transform(i).to_data_y(static_cast<f64>(raw.y)), factor);
|
||||
}
|
||||
}
|
||||
|
||||
// The focal point's movement is a two-finger pan: the same path a one-finger
|
||||
// drag takes, deltas included, so a pinch-and-drag moves the data under the
|
||||
// fingers on every axis the drag would move.
|
||||
if (raw.dx != 0.0f || raw.dy != 0.0f) {
|
||||
PointerEvent pan_e;
|
||||
pan_e.x = raw.x;
|
||||
pan_e.y = raw.y;
|
||||
pan_e.dx = raw.dx;
|
||||
pan_e.dy = raw.dy;
|
||||
pan(pan_e);
|
||||
}
|
||||
|
||||
plot_->layout();
|
||||
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
|
||||
|
||||
// Escape cancels the active drag (see cancel_drag). A key with no drag open --
|
||||
// and any other key -- changes nothing and returns below without touching the
|
||||
// layout, the crosshair or the published handles.
|
||||
if (e.key == kKeyEscape) {
|
||||
if (mode_ != DragMode::None) cancel_drag();
|
||||
return;
|
||||
}
|
||||
|
||||
// The arrow keys pan by axis class: Left/Right every x axis, Up/Down every y
|
||||
// axis, 10% of that axis' own span per press (and per repeat, which is why the
|
||||
// adapters forward key repeat). An arrow says where to *look*: Left walks the
|
||||
// window toward smaller x, Up toward larger y -- the screen-y inversion a drag's
|
||||
// dy carries does not apply, because a drag says where the data goes and a key
|
||||
// says where the viewer goes.
|
||||
switch (e.key) {
|
||||
case kKeyLeft: shift_x_axes(-kPanPerPress); break;
|
||||
case kKeyRight: shift_x_axes(kPanPerPress); break;
|
||||
case kKeyUp: shift_y_axes(kPanPerPress); break;
|
||||
case kKeyDown: shift_y_axes(-kPanPerPress); break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
const bool pans = e.key >= kKeyLeft && e.key <= kKeyDown;
|
||||
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 (!pans && !zooms && !fits) return; // a key this controller has no gesture for
|
||||
|
||||
if (zooms) {
|
||||
const f64 notches = e.key == kKeyZoomIn ? kKeyZoomNotches : -kKeyZoomNotches;
|
||||
@@ -219,7 +335,7 @@ void Controller::on_key(const KeyEvent& e) {
|
||||
const f64 hi = to_space(a.range.max, a.log);
|
||||
zoom_y_axis(i, from_space(0.5 * (lo + hi), a.log), factor);
|
||||
}
|
||||
} else {
|
||||
} else if (fits) {
|
||||
plot_->auto_fit_x();
|
||||
for (u32 i = 0; i < plot_->y_axis_count(); ++i) plot_->auto_fit_y(i);
|
||||
}
|
||||
@@ -363,13 +479,33 @@ void Controller::begin_drag(const PointerEvent& e) {
|
||||
drag_cursor_ = kInvalid;
|
||||
drag_region_ = kInvalid;
|
||||
drag_zone_ = RegionGrab::None;
|
||||
// What Escape will restore if this gesture is cancelled: the axes' state *now*,
|
||||
// before the drag moves anything (see capture_ranges).
|
||||
capture_ranges();
|
||||
|
||||
// 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;
|
||||
// The modifier box zoom, on the left only: a selection box is what a modified
|
||||
// left press means even when it starts over a handle. The mask is resolved once,
|
||||
// here, from the modifier table -- Shift is the plain both-axis selection it has
|
||||
// always been, Ctrl frames the x range only, Alt the y range only, and any other
|
||||
// combo falls back to both -- and `apply_box_zoom()` reads it on release, per
|
||||
// axis and for the click threshold alike.
|
||||
if (e.button == Button::Left) {
|
||||
const bool shift = has_mod(e.mods, Mod::Shift);
|
||||
const bool ctrl = has_mod(e.mods, Mod::Ctrl);
|
||||
const bool alt = has_mod(e.mods, Mod::Alt);
|
||||
if (shift || ctrl || alt) {
|
||||
mode_ = DragMode::BoxZoom;
|
||||
// The masks: Ctrl alone frames the x range only, Alt alone the y range only,
|
||||
// and every other combo (Shift included) frames both -- the field says whether
|
||||
// the release *applies* that axis, so the axis named by the mask is the one
|
||||
// that stays true.
|
||||
const bool ctrl_only = ctrl && !shift && !alt;
|
||||
const bool alt_only = alt && !shift && !ctrl;
|
||||
box_zoom_x_ = !alt_only;
|
||||
box_zoom_y_ = !ctrl_only;
|
||||
selection_ = Rect{e.x, e.y, 0.0f, 0.0f};
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.button == Button::Middle) {
|
||||
const PlotLayout& lay = std::as_const(*plot_).layout();
|
||||
@@ -472,10 +608,113 @@ void Controller::end_drag(const PointerEvent& e) {
|
||||
drag_cursor_ = kInvalid;
|
||||
drag_region_ = kInvalid;
|
||||
drag_zone_ = RegionGrab::None;
|
||||
// The capture is the cancelled gesture's undo record, and this gesture was not
|
||||
// cancelled: keeping it would let a later Escape restore a state that ended long
|
||||
// ago (and `capture_ranges()` only runs at a press).
|
||||
saved_x_.clear();
|
||||
saved_y_.clear();
|
||||
}
|
||||
|
||||
// --- gestures -------------------------------------------------------------
|
||||
|
||||
void Controller::capture_ranges() {
|
||||
saved_x_.clear();
|
||||
saved_y_.clear();
|
||||
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
|
||||
const Axis& a = plot_->x_axis(i);
|
||||
saved_x_.push_back(SavedX{a.range, a.offset, a.auto_scale});
|
||||
}
|
||||
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
|
||||
const Axis& a = plot_->y_axis(i);
|
||||
saved_y_.push_back(SavedY{a.range, a.auto_scale});
|
||||
}
|
||||
}
|
||||
|
||||
void Controller::cancel_drag() {
|
||||
// The ranges the axes held at `begin_drag`, through the plot's own setters: an
|
||||
// `Off` axis gets its range and its auto-scale flag back exactly, and a live one
|
||||
// gets its span and offset restated the way the setters read them (the offset is
|
||||
// written back verbatim after the range, since the live edge of *this* moment --
|
||||
// not of the press -- is what the setter would otherwise derive it from). A
|
||||
// cancelled gesture is as if it never ran: nothing is applied, no auto-scale is
|
||||
// disarmed that was not already, and no box is framed.
|
||||
for (u32 i = 0; i < plot_->x_axis_count() && i < saved_x_.size(); ++i) {
|
||||
const SavedX& saved = saved_x_[i];
|
||||
plot_->set_x_range(i, saved.range);
|
||||
plot_->set_x_offset(i, saved.offset);
|
||||
plot_->set_x_auto_scale(i, saved.auto_scale);
|
||||
}
|
||||
for (u32 i = 0; i < plot_->y_axis_count() && i < saved_y_.size(); ++i) {
|
||||
const SavedY& saved = saved_y_[i];
|
||||
plot_->set_y_range(i, saved.range);
|
||||
plot_->set_y_auto_scale(i, saved.auto_scale);
|
||||
}
|
||||
// The gesture itself: no mode, no selection, nothing grabbed -- and the highlight
|
||||
// the renderer draws follows, through the same tail every event that changes the
|
||||
// geometry under the pointer runs.
|
||||
mode_ = DragMode::None;
|
||||
selection_ = Rect{};
|
||||
drag_cursor_ = kInvalid;
|
||||
drag_region_ = kInvalid;
|
||||
drag_zone_ = RegionGrab::None;
|
||||
plot_->layout();
|
||||
if (has_pointer_) update_crosshair(pointer_x_, pointer_y_);
|
||||
publish_hot();
|
||||
}
|
||||
|
||||
void Controller::fit_all() {
|
||||
plot_->auto_fit_x();
|
||||
for (u32 i = 0; i < plot_->y_axis_count(); ++i) plot_->auto_fit_y(i);
|
||||
// A fit moved the geometry under a stationary pointer, exactly as a wheel notch
|
||||
// does -- same tail, same reason (see on_wheel).
|
||||
plot_->layout();
|
||||
if (has_pointer_) update_crosshair(pointer_x_, pointer_y_);
|
||||
publish_hot();
|
||||
}
|
||||
|
||||
bool Controller::double_click(const PointerEvent& e) {
|
||||
const f32 radius = plot_->style().device(kDoubleClickPxLogical);
|
||||
const f32 dx = e.x - click_x_;
|
||||
const f32 dy = e.y - click_y_;
|
||||
// Both presses must carry a host timestamp for the window to mean anything: a
|
||||
// host that leaves `time_s` at 0 never produces a double-click (the field is
|
||||
// optional, and two presses "0 s apart" would otherwise fire on any host).
|
||||
const bool in_time = e.time_s > 0.0 && click_time_s_ > 0.0 &&
|
||||
e.time_s - click_time_s_ <= kDoubleClickSeconds;
|
||||
const bool in_space = dx * dx + dy * dy <= radius * radius;
|
||||
const bool is_double =
|
||||
has_click_ && click_button_ == static_cast<int>(e.button) && in_time && in_space;
|
||||
// This press is what a third one would be measured against -- unless the two
|
||||
// just made a double-click, in which case the record clears: a third press starts
|
||||
// a fresh gesture rather than re-firing the reset.
|
||||
has_click_ = !is_double;
|
||||
click_button_ = static_cast<int>(e.button);
|
||||
click_time_s_ = e.time_s;
|
||||
click_x_ = e.x;
|
||||
click_y_ = e.y;
|
||||
return is_double;
|
||||
}
|
||||
|
||||
void Controller::shift_x_axes(f64 fraction) {
|
||||
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 = fraction * (hi - lo); // positive scrolls toward larger x
|
||||
plot_->set_x_range(i, Range{from_space(lo + d, a.log), from_space(hi + d, a.log)});
|
||||
}
|
||||
}
|
||||
|
||||
void Controller::shift_y_axes(f64 fraction) {
|
||||
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 = fraction * (hi - lo); // positive scrolls toward larger y
|
||||
plot_->set_y_range(i, Range{from_space(lo + d, a.log), from_space(hi + d, a.log)});
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -510,12 +749,17 @@ void Controller::box_zoom_update(const PointerEvent& e) {
|
||||
|
||||
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.
|
||||
// A selection under a few *logical* pixels on the axes the mask will zoom is a
|
||||
// click: no zoom. The threshold is masked with the gesture -- a Ctrl drag frames
|
||||
// the x range from a selection however short it is vertically, and vice versa --
|
||||
// which is what makes a thin horizontal band a legal x-only box rather than a
|
||||
// click no matter what the y says. 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;
|
||||
if (box_zoom_x_ && sel.w < min_box) return;
|
||||
if (box_zoom_y_ && 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.
|
||||
@@ -528,17 +772,24 @@ void Controller::apply_box_zoom() {
|
||||
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;
|
||||
if (box_zoom_x_ && !(r > l)) return;
|
||||
if (box_zoom_y_ && !(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)});
|
||||
// data under it; the selection's bottom edge is the range minimum on y. Masked
|
||||
// axes are left exactly as they were -- the release applies only what the press
|
||||
// promised.
|
||||
if (box_zoom_x_) {
|
||||
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)});
|
||||
if (box_zoom_y_) {
|
||||
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)});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -621,7 +872,12 @@ void Controller::axis_zoom(const PointerEvent& e) {
|
||||
if (!(scale > 0.0) || !std::isfinite(scale)) return;
|
||||
const f64 logical_dy = static_cast<f64>(e.dy) / scale;
|
||||
const f64 factor = std::exp(-logical_dy * kZoomPerPixel);
|
||||
if (!(factor > 0.0)) return;
|
||||
// The same degenerate factors the wheel refuses: a factor of exactly one (a
|
||||
// motion that reports no dy -- running it would still clear `auto_scale` on its
|
||||
// way through the range setters), a non-finite one (an enormous dy explodes
|
||||
// `exp` into a span the setters cannot store), and a non-positive one, which is
|
||||
// not a zoom at all.
|
||||
if (!std::isfinite(factor) || !(factor > 0.0) || factor == 1.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);
|
||||
|
||||
Reference in new issue
Block a user