feat(plot)!: harden pipeline, XY series, dash/markers, touch, packaging
CI / debug (tests + all adapters) (push) Canceled after 0s
CI / coverage (floor + measured table) (push) Canceled after 0s
CI / asan (AddressSanitizer) (push) Canceled after 0s

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:
Martino Ferrari committed 2026-09-30 17:32:06 +02:00
1 parent f132a5ec92
commit 38ef459897
77 files changed
+5905 -688

No files matched your search

+352 -24
View File
@@ -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});
}