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
+352
-24
@@ -187,21 +187,30 @@ PLOT_TEST(hovered_cursor_only_inside_plot_area) {
|
||||
PLOT_CHECK(c.hovered_cursor() == cid);
|
||||
}
|
||||
|
||||
PLOT_TEST(ctrl_drag_moves_the_nearest_cursor) {
|
||||
PLOT_TEST(ctrl_drag_box_zooms_x_only_even_over_a_cursor) {
|
||||
// The modifier table: Ctrl+Left is the x-only box zoom now -- it wins over the
|
||||
// cursor grab a plain press would open, and the release frames the x range from
|
||||
// the selection while the y range (and the cursor under the press) stay put.
|
||||
plot::Plot p;
|
||||
p.set_viewport({0, 0, 800, 400});
|
||||
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
|
||||
const u32 cid = p.add_cursor(20.0, plot::Color::hex(0xFF0000));
|
||||
const u32 ax = p.add_x_axis({"t", {0.0, 800.0}, false, plot::AxisPlace::Bottom, false});
|
||||
const u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||
const u32 cid = p.add_cursor(200.0, plot::Color::hex(0xFF0000));
|
||||
plot::input::Controller c(p);
|
||||
c.set_viewport({0, 0, 800, 400});
|
||||
const f32 sx = screen_x_of(p, ax, 20.0);
|
||||
const f32 sx = screen_x_of(p, ax, 200.0);
|
||||
const f64 want_lo = p.x_transform(ax).to_data_x(sx);
|
||||
const f64 want_hi = p.x_transform(ax).to_data_x(sx + 100.0f);
|
||||
c.on_pointer({sx, 200, 0, 0, plot::input::Button::Left, kCtrl, true, true, false});
|
||||
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::MoveCursor);
|
||||
c.on_pointer({sx + 100, 200, 100, 0, plot::input::Button::Left, kCtrl, true, false, false});
|
||||
c.on_pointer({sx + 100, 200, 0, 0, plot::input::Button::Left, kCtrl, false, false, true});
|
||||
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::BoxZoom);
|
||||
c.on_pointer({sx + 100, 260, 100, 60, plot::input::Button::Left, kCtrl, true, false, false});
|
||||
c.on_pointer({sx + 100, 260, 0, 0, plot::input::Button::Left, kCtrl, false, false, true});
|
||||
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None);
|
||||
PLOT_CHECK(p.cursor(cid).x > 20.0);
|
||||
PLOT_CHECK_NEAR(p.cursor(cid).x, p.x_transform(ax).to_data_x(sx + 100), 1e-9);
|
||||
PLOT_CHECK_NEAR(p.x_range(ax).min, want_lo, 1e-6);
|
||||
PLOT_CHECK_NEAR(p.x_range(ax).max, want_hi, 1e-6);
|
||||
PLOT_CHECK_NEAR(p.y_range(ay).min, -1.0, 1e-9); // the masked axis is untouched
|
||||
PLOT_CHECK_NEAR(p.y_range(ay).max, 1.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(p.cursor(cid).x, 200.0, 1e-9); // and no cursor was grabbed
|
||||
}
|
||||
|
||||
PLOT_TEST(middle_drag_on_x_strip_zooms_x_only) {
|
||||
@@ -391,9 +400,10 @@ PLOT_TEST(grab_radius_scales_with_dpi) {
|
||||
c.on_pointer({sx + outside, 200.0f * scale, 0, 0, plot::input::Button::Left, 0, false, false,
|
||||
false});
|
||||
PLOT_CHECK(c.hovered_cursor() == plot::kInvalid);
|
||||
// ...and a ctrl-drag at that same logical distance opens a cursor move, which is
|
||||
// the gesture the radius actually gates.
|
||||
c.on_pointer({sx + inside, 200.0f * scale, 0, 0, plot::input::Button::Left, kCtrl, true, true,
|
||||
// ...and a press at that same logical distance opens a cursor move, which is
|
||||
// the gesture the radius actually gates. (A plain press: Ctrl is the x-only
|
||||
// box zoom's mask now, not a cursor modifier.)
|
||||
c.on_pointer({sx + inside, 200.0f * scale, 0, 0, plot::input::Button::Left, 0, true, true,
|
||||
false});
|
||||
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::MoveCursor);
|
||||
}
|
||||
@@ -878,17 +888,9 @@ PLOT_TEST(cursor_is_grabbed_without_a_modifier) {
|
||||
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::None);
|
||||
PLOT_CHECK(f.controller.grabbed_cursor() == plot::kInvalid);
|
||||
PLOT_CHECK(f.plot.hot_handles().grabbed_cursor == plot::kInvalid);
|
||||
|
||||
// The same press with ctrl is the same gesture, not a different mode.
|
||||
const f32 moved_to = column_of(f.plot, f.ax, f.plot.cursor(cid).x);
|
||||
const f64 min_before = f.plot.x_range(f.ax).min;
|
||||
press(f.controller, moved_to, 200.0f, kCtrl);
|
||||
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor);
|
||||
PLOT_CHECK(f.controller.grabbed_cursor() == cid);
|
||||
move_to(f.controller, moved_to - 40.0f, 200.0f, kCtrl);
|
||||
PLOT_CHECK_NEAR(f.plot.cursor(cid).x, data_at(f.plot, f.ax, moved_to - 40.0f), 1e-9);
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, min_before, 1e-9); // still no pan
|
||||
release(f.controller, moved_to - 40.0f, 200.0f, kCtrl);
|
||||
// The grab is the *plain* press's gesture: Ctrl+Left is the x-only box zoom now
|
||||
// (see ctrl_drag_box_zooms_x_only_even_over_a_cursor), so there is no modifier
|
||||
// variant of a cursor grab to pin here.
|
||||
}
|
||||
|
||||
/// A snapping cursor follows the pointer for the whole drag: the drag writes the free
|
||||
@@ -1216,7 +1218,7 @@ PLOT_TEST(pressing_away_from_a_handle_still_pans) {
|
||||
const u32 cid = f.plot.add_cursor(20.0, plot::Color::hex(0x00FF00));
|
||||
const u32 rid = f.plot.add_region(free_region(40.0, 60.0));
|
||||
|
||||
for (const u32 mods : {0u, kCtrl}) {
|
||||
for (const u32 mods : {0u}) { // the plain press: Ctrl+Left is the box zoom now
|
||||
const f32 px = screen_x_of(f.plot, f.ax, 85.0);
|
||||
const f64 grabbed = data_at(f.plot, f.ax, px);
|
||||
const f64 min_before = f.plot.x_range(f.ax).min;
|
||||
@@ -1981,3 +1983,329 @@ PLOT_TEST(crosshair_tracks_motion_and_clears_on_leave) {
|
||||
PLOT_CHECK(fx.plot.crosshair().active);
|
||||
PLOT_CHECK_NEAR(fx.plot.crosshair().x, fx.plot.x_transform(0u).to_data_x(far_x), 1e-9);
|
||||
}
|
||||
|
||||
// --- hardening wave: B5 zero-notch wheel and dx pan, and the new gestures ---
|
||||
|
||||
namespace {
|
||||
|
||||
/// A plot with an x and a y axis, both auto-scaled, so every "did the gesture
|
||||
/// disarm the fit" question has an armed state to start from.
|
||||
struct ArmedFixture {
|
||||
plot::Plot plot;
|
||||
u32 ax = 0;
|
||||
u32 ay = 0;
|
||||
plot::input::Controller controller{plot};
|
||||
|
||||
ArmedFixture() {
|
||||
plot.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
||||
ax = plot.add_x_axis({"t", {0.0, 100.0}, true, plot::AxisPlace::Bottom, false});
|
||||
ay = plot.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
|
||||
controller.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
/// B5: a zero-notch wheel (`dy == 0 && dx == 0`, which hosts and trackpads really
|
||||
/// forward) changes nothing at all -- most importantly it does not run the range
|
||||
/// setters, which clear `auto_scale`, so one empty scroll must not disarm
|
||||
/// auto-fit.
|
||||
PLOT_TEST(zero_notch_wheel_disarms_nothing) {
|
||||
ArmedFixture f;
|
||||
f.controller.on_wheel({400.0f, 200.0f, 0.0f, 0.0f, 0});
|
||||
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale);
|
||||
PLOT_CHECK(f.plot.y_axis(f.ay).auto_scale);
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 0.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 100.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -1.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.0, 1e-9);
|
||||
}
|
||||
|
||||
/// B5: `dy == 0` with `dx != 0` pans x only -- 5% of the x span per notch, in axis
|
||||
/// space (log-aware), positive `dx` scrolling right -- and leaves the y axis,
|
||||
/// including its armed auto scale, exactly as it was.
|
||||
PLOT_TEST(horizontal_wheel_pans_x_only) {
|
||||
ArmedFixture f;
|
||||
f.controller.on_wheel({400.0f, 200.0f, 2.0f, 0.0f, 0}); // two notches right
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 10.0, 1e-9); // 2 * 5% of 100
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 110.0, 1e-9);
|
||||
PLOT_CHECK(!f.plot.x_axis(f.ax).auto_scale); // a pan is a user choice
|
||||
PLOT_CHECK(f.plot.y_axis(f.ay).auto_scale); // the untouched axis keeps its fit
|
||||
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -1.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.0, 1e-9);
|
||||
|
||||
// Log-aware: the pan runs in axis space, so one notch on a log axis is a
|
||||
// fraction of a *decade*, not of a linear width.
|
||||
plot::Plot p;
|
||||
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
||||
const u32 lg = p.add_x_axis({"f", {1.0, 100.0}, true, plot::AxisPlace::Bottom, true});
|
||||
plot::input::Controller c(p);
|
||||
c.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
||||
c.on_wheel({400.0f, 200.0f, 1.0f, 0.0f, 0});
|
||||
const plot::Range r = p.x_range(lg);
|
||||
PLOT_CHECK_NEAR(std::log10(r.max) - std::log10(r.min), 2.0, 1e-9); // decades kept
|
||||
PLOT_CHECK_NEAR(std::log10(r.max), 2.1, 1e-9); // one notch = 5% of the 2-decade span
|
||||
}
|
||||
|
||||
/// B5: a zoom whose factor could not be applied honestly -- non-finite (a huge
|
||||
/// downward `dy` explodes `exp`) -- is refused, and leaves the ranges *and* the
|
||||
/// armed auto scale exactly as they were.
|
||||
PLOT_TEST(wheel_zoom_refuses_a_non_finite_factor) {
|
||||
ArmedFixture f;
|
||||
f.controller.on_wheel({400.0f, 200.0f, 0.0f, -1e38f, 0});
|
||||
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale);
|
||||
PLOT_CHECK(f.plot.y_axis(f.ay).auto_scale);
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 100.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.0, 1e-9);
|
||||
}
|
||||
|
||||
/// B5: the middle-drag axis zoom carries the same guards. A motion that reports no
|
||||
/// `dy` is a factor of exactly one -- refused, so it cannot clear `auto_scale` on
|
||||
/// its way through the range setters -- and one that explodes the factor is
|
||||
/// refused too.
|
||||
PLOT_TEST(axis_zoom_refuses_degenerate_factors) {
|
||||
ArmedFixture f;
|
||||
const plot::PlotLayout& l = f.plot.layout();
|
||||
const f32 strip_x = l.axes.x + 0.5f * l.axes.w;
|
||||
const f32 strip_y = l.x_label_area.y + 0.5f * l.x_label_area.h;
|
||||
f.controller.on_pointer({strip_x, strip_y, 0, 0, plot::input::Button::Middle, 0, true, true,
|
||||
false});
|
||||
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::AxisZoomX);
|
||||
// A stationary motion: no zoom, and the fits stay armed.
|
||||
f.controller.on_pointer({strip_x, strip_y, 0, 0, plot::input::Button::Middle, 0, true, false,
|
||||
false});
|
||||
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale);
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), 100.0, 1e-9);
|
||||
// An exploding motion: refused, nothing stored, nothing disarmed.
|
||||
f.controller.on_pointer({strip_x, strip_y, 0, 1e38f, plot::input::Button::Middle, 0, true,
|
||||
false, false});
|
||||
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale);
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), 100.0, 1e-9);
|
||||
f.controller.on_pointer({strip_x, strip_y, 0, 0, plot::input::Button::Middle, 0, false, false,
|
||||
true});
|
||||
}
|
||||
|
||||
/// 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, in axis space (log-aware). An arrow
|
||||
/// says where to look: Left walks the window toward smaller x, Up toward larger y.
|
||||
PLOT_TEST(arrow_keys_pan_a_tenth_of_the_span) {
|
||||
ArmedFixture f;
|
||||
f.plot.set_x_auto_scale(f.ax, false);
|
||||
f.controller.on_key({plot::input::kKeyRight, 0, true});
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 10.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 110.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -1.0, 1e-9); // x-only
|
||||
f.controller.on_key({plot::input::kKeyLeft, 0, true});
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 0.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 100.0, 1e-9);
|
||||
f.controller.on_key({plot::input::kKeyUp, 0, true});
|
||||
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -0.8, 1e-9); // +10% of the 2 span
|
||||
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.2, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 0.0, 1e-9); // y-only
|
||||
f.controller.on_key({plot::input::kKeyDown, 0, true});
|
||||
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -1.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.0, 1e-9);
|
||||
|
||||
// Log-aware: an arrow on a log axis moves decades, not units.
|
||||
plot::Plot p;
|
||||
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
||||
const u32 lg = p.add_x_axis({"f", {1.0, 100.0}, false, plot::AxisPlace::Bottom, true});
|
||||
p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||
plot::input::Controller c(p);
|
||||
c.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
||||
c.on_key({plot::input::kKeyRight, 0, true});
|
||||
const plot::Range r = p.x_range(lg);
|
||||
PLOT_CHECK_NEAR(std::log10(r.max) - std::log10(r.min), 2.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(std::log10(r.max), 2.2, 1e-9); // +10% of the 2-decade span
|
||||
|
||||
// A key with no gesture changes nothing.
|
||||
c.on_key({'q', 0, true});
|
||||
PLOT_CHECK_NEAR(std::log10(p.x_range(lg).max), 2.2, 1e-9);
|
||||
}
|
||||
|
||||
/// Escape cancels the active drag: the ranges (and auto-scale flags) the axes held
|
||||
/// at `begin_drag` are restored, and the drag is closed -- the release that
|
||||
/// follows applies nothing.
|
||||
PLOT_TEST(escape_cancels_a_pan_and_restores_the_captured_ranges) {
|
||||
ArmedFixture f;
|
||||
const f64 x_min = f.plot.x_range(f.ax).min;
|
||||
const f64 x_max = f.plot.x_range(f.ax).max;
|
||||
const f64 y_min = f.plot.y_range(f.ay).min;
|
||||
const f64 y_max = f.plot.y_range(f.ay).max;
|
||||
f.controller.on_pointer({100.0f, 100.0f, 0, 0, plot::input::Button::Left, 0, true, true,
|
||||
false});
|
||||
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::Pan);
|
||||
f.controller.on_pointer({300.0f, 200.0f, 200.0f, 100.0f, plot::input::Button::Left, 0, true,
|
||||
false, false});
|
||||
PLOT_CHECK(f.plot.x_range(f.ax).min < x_min); // the gesture did move things
|
||||
PLOT_CHECK(!f.plot.x_axis(f.ax).auto_scale);
|
||||
f.controller.on_key({plot::input::kKeyEscape, 0, true});
|
||||
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::None);
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, x_min, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, x_max, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, y_min, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, y_max, 1e-9);
|
||||
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale); // the fit is armed again
|
||||
PLOT_CHECK(f.plot.y_axis(f.ay).auto_scale);
|
||||
// The release arrives after the cancel; there is no gesture left to end.
|
||||
f.controller.on_pointer({300.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
|
||||
true});
|
||||
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::None);
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, x_min, 1e-9);
|
||||
}
|
||||
|
||||
/// Escape drops a box zoom without applying it: the selection is discarded and the
|
||||
/// ranges the axes held when the drag began are still there at the end.
|
||||
PLOT_TEST(escape_drops_a_box_zoom_without_applying) {
|
||||
ArmedFixture f;
|
||||
const f64 x_max = f.plot.x_range(f.ax).max;
|
||||
f.controller.on_pointer({100.0f, 50.0f, 0, 0, plot::input::Button::Left, kShift, true, true,
|
||||
false});
|
||||
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::BoxZoom);
|
||||
f.controller.on_pointer({300.0f, 150.0f, 200.0f, 100.0f, plot::input::Button::Left, kShift,
|
||||
true, false, false});
|
||||
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale); // nothing applies before the release
|
||||
f.controller.on_key({plot::input::kKeyEscape, 0, true});
|
||||
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::None);
|
||||
PLOT_CHECK_NEAR(f.controller.selection_rect().w, 0.0, 1e-9); // the box is gone
|
||||
f.controller.on_pointer({300.0f, 150.0f, 0, 0, plot::input::Button::Left, kShift, false,
|
||||
false, true});
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, x_max, 1e-9); // and nothing was framed
|
||||
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale);
|
||||
}
|
||||
|
||||
/// The box-zoom masks: Alt+Left frames the y range only -- from a selection too
|
||||
/// thin horizontally to be a two-axis box, which is what makes the threshold
|
||||
/// masked with the gesture.
|
||||
PLOT_TEST(alt_drag_box_zooms_y_only) {
|
||||
ArmedFixture f;
|
||||
constexpr u32 kAlt = static_cast<u32>(plot::input::Mod::Alt);
|
||||
const plot::Rect axes = f.plot.layout().axes;
|
||||
const f64 want_lo = f.plot.y_transform(f.ay).to_data_y(axes.y + 150.0f);
|
||||
const f64 want_hi = f.plot.y_transform(f.ay).to_data_y(axes.y + 50.0f);
|
||||
// Two logical px wide: an unmasked box would call this a click.
|
||||
f.controller.on_pointer({200.0f, axes.y + 50.0f, 0, 0, plot::input::Button::Left, kAlt, true,
|
||||
true, false});
|
||||
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::BoxZoom);
|
||||
f.controller.on_pointer({202.0f, axes.y + 150.0f, 2, 100, plot::input::Button::Left, kAlt,
|
||||
true, false, false});
|
||||
f.controller.on_pointer({202.0f, axes.y + 150.0f, 0, 0, plot::input::Button::Left, kAlt,
|
||||
false, false, true});
|
||||
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, want_lo, 1e-6);
|
||||
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, want_hi, 1e-6);
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 0.0, 1e-9); // x untouched
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 100.0, 1e-9);
|
||||
}
|
||||
|
||||
/// The Ctrl mask, applied at release from a selection that only makes sense
|
||||
/// horizontally: the x range is framed from the box while the y range is left
|
||||
/// alone, and a selection thin *vertically* is still a box under the mask.
|
||||
PLOT_TEST(ctrl_mask_frames_x_from_a_thin_horizontal_band) {
|
||||
ArmedFixture f;
|
||||
const plot::Rect axes = f.plot.layout().axes;
|
||||
const f64 want_lo = f.plot.x_transform(f.ax).to_data_x(axes.x + 100.0f);
|
||||
const f64 want_hi = f.plot.x_transform(f.ax).to_data_x(axes.x + 300.0f);
|
||||
f.controller.on_pointer({axes.x + 100.0f, 200.0f, 0, 0, plot::input::Button::Left, kCtrl,
|
||||
true, true, false});
|
||||
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::BoxZoom);
|
||||
// Two logical px tall: an unmasked box would call this a click.
|
||||
f.controller.on_pointer({axes.x + 300.0f, 202.0f, 200, 2, plot::input::Button::Left, kCtrl,
|
||||
true, false, false});
|
||||
f.controller.on_pointer({axes.x + 300.0f, 202.0f, 0, 0, plot::input::Button::Left, kCtrl,
|
||||
false, false, true});
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, want_lo, 1e-6);
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, want_hi, 1e-6);
|
||||
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -1.0, 1e-9); // y untouched
|
||||
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.0, 1e-9);
|
||||
}
|
||||
|
||||
/// The double-click reset: two presses of the same button within 0.35 s and 4 px
|
||||
/// (on `PointerEvent::time_s`) re-fit every axis -- auto scale re-armed with the
|
||||
/// fit -- and the second press begins no drag.
|
||||
PLOT_TEST(double_click_refits_every_axis_and_starts_no_drag) {
|
||||
// A plot that has something to fit: the reset re-fits every axis to its traces,
|
||||
// so this fixture carries one (the ArmedFixture above is deliberately empty --
|
||||
// its tests are about auto-scale *state*, and an axis with no trace has nothing
|
||||
// to fit). The axes start disarmed so the initial layout cannot pre-fit them.
|
||||
plot::Plot p;
|
||||
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
||||
const u32 ax = p.add_x_axis({"t", {5.0, 60.0}, false, plot::AxisPlace::Bottom, false});
|
||||
const u32 ay = p.add_y_axis({"v", {-0.5, 0.5}, false, plot::AxisPlace::Left, false});
|
||||
std::vector<f32> ys(101);
|
||||
for (u64 i = 0; i <= 100u; ++i) ys[static_cast<std::size_t>(i)] = (i % 2u == 0u) ? -1.0f : 1.0f;
|
||||
plot::data::ArraySeries series(std::move(ys), 0.0, 1.0); // x = 0..100, y in [-1, 1]
|
||||
p.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, ax, ay});
|
||||
plot::input::Controller c(p);
|
||||
c.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
||||
|
||||
// Zoom in first, so the window is somewhere a reset has to leave.
|
||||
c.on_wheel({400.0f, 200.0f, 0.0f, 1.0f, 0});
|
||||
PLOT_CHECK(p.x_range(ax).span() < 60.0);
|
||||
PLOT_CHECK(p.y_range(ay).span() < 1.0);
|
||||
|
||||
c.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true, false,
|
||||
/*time_s=*/1.0});
|
||||
c.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false, true, 1.0});
|
||||
c.on_pointer({402.0f, 201.0f, 2, 1, plot::input::Button::Left, 0, true, true, false,
|
||||
1.2}); // 0.2 s and ~2.2 px later
|
||||
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None); // no drag began
|
||||
PLOT_CHECK(p.x_axis(ax).auto_scale); // the reset fit and re-armed
|
||||
PLOT_CHECK(p.y_axis(ay).auto_scale);
|
||||
// Both axes frame their traces' bounds (the 2% pad keeps the data inside them),
|
||||
// which is what a fit means here.
|
||||
PLOT_CHECK(p.x_range(ax).min < 0.0);
|
||||
PLOT_CHECK(p.x_range(ax).max > 100.0);
|
||||
PLOT_CHECK(p.y_range(ay).min < -1.0);
|
||||
PLOT_CHECK(p.y_range(ay).max > 1.0);
|
||||
// And the re-armed fit survives a layout: the frame the host draws next does not
|
||||
// move the window the reset just chose.
|
||||
const plot::Range xr = p.x_range(ax);
|
||||
p.layout();
|
||||
PLOT_CHECK_NEAR(p.x_range(ax).min, xr.min, 1e-9);
|
||||
PLOT_CHECK_NEAR(p.x_range(ax).max, xr.max, 1e-9);
|
||||
|
||||
// A third press starts a fresh gesture (a pan), not a second reset.
|
||||
c.on_pointer({402.0f, 201.0f, 0, 0, plot::input::Button::Left, 0, true, true, false, 1.4});
|
||||
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::Pan);
|
||||
c.on_pointer({402.0f, 201.0f, 0, 0, plot::input::Button::Left, 0, false, false, true, 1.4});
|
||||
}
|
||||
|
||||
/// A double-click needs the host's clock: presses that carry `time_s == 0` are
|
||||
/// never one, whatever their spacing, and presses outside the window (too slow or
|
||||
/// too far apart) are two ordinary gestures -- the second one a drag.
|
||||
PLOT_TEST(double_click_needs_both_timestamps_and_the_window) {
|
||||
ArmedFixture f;
|
||||
f.plot.set_x_auto_scale(f.ax, false);
|
||||
// No timestamps: two presses at the same spot do not reset.
|
||||
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true,
|
||||
false, 0.0});
|
||||
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
|
||||
true, 0.0});
|
||||
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true,
|
||||
false, 0.0});
|
||||
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::Pan);
|
||||
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
|
||||
true, 0.0});
|
||||
|
||||
// Too slow: 0.4 s apart is two gestures.
|
||||
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true,
|
||||
false, 10.0});
|
||||
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
|
||||
true, 10.0});
|
||||
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true,
|
||||
false, 10.4});
|
||||
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::Pan);
|
||||
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
|
||||
true, 10.4});
|
||||
|
||||
// Too far: 10 px apart is two gestures.
|
||||
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true,
|
||||
false, 20.0});
|
||||
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
|
||||
true, 20.0});
|
||||
f.controller.on_pointer({410.0f, 200.0f, 10, 0, plot::input::Button::Left, 0, true, true,
|
||||
false, 20.1});
|
||||
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::Pan);
|
||||
f.controller.on_pointer({410.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
|
||||
true, 20.1});
|
||||
}
|
||||
Reference in new issue
Block a user