The repository had no licence at all, so every source was all-rights-reserved by default. Add the verbatim MIT text at LICENSE (Copyright (c) 2026 Martino Ferrari) and a two-line SPDX header to each of the 113 first-party sources -- 48 include/plot, 26 src, 10 adapters, 5 examples, 21 tests, 3 tools -- so the grant travels with each file rather than relying on the root document alone. Scripts keep their shebang on line 1; the SPDX lines follow it. Genuinely vendored code is untouched: third_party/stb_truetype.h carries no header of ours. src/render/embedded_mono_font.cpp is generated, so tools/fonts/gen_atlas.py emit() now writes the two lines into its header comment and the file was regenerated -- gen_atlas.py --check passes and the regeneration adds only those comment lines to the previously committed file. README's provenance section now states MIT and links LICENSE. No code, test or public API change.
1927 lines
96 KiB
C++
1927 lines
96 KiB
C++
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#include "harness.hpp"
|
|
|
|
#include <plot/input/controller.hpp>
|
|
#include <plot/plot/figure.hpp>
|
|
#include <plot/plot/plot.hpp>
|
|
#include <plot/data/array_series.hpp>
|
|
#include <plot/data/stream_series.hpp>
|
|
|
|
#include <cmath>
|
|
#include <limits>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
namespace {
|
|
|
|
using plot::f32;
|
|
using plot::f64;
|
|
using plot::u32;
|
|
|
|
constexpr u32 kShift = static_cast<u32>(plot::input::Mod::Shift);
|
|
constexpr u32 kCtrl = static_cast<u32>(plot::input::Mod::Ctrl);
|
|
constexpr u32 kNoAxis = ~u32{0};
|
|
|
|
/// A 800x400 plot with one x axis spanning [0, 100] and the controller attached.
|
|
struct Fixture {
|
|
plot::Plot plot;
|
|
u32 ax = kNoAxis;
|
|
u32 ay = kNoAxis;
|
|
plot::input::Controller controller{plot};
|
|
|
|
Fixture() {
|
|
plot.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
|
ax = plot.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
|
|
controller.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
|
}
|
|
};
|
|
|
|
f32 screen_x_of(const plot::Plot& p, u32 axis, f64 data) {
|
|
return static_cast<f32>(p.x_transform(axis).to_screen_x(data));
|
|
}
|
|
|
|
} // namespace
|
|
|
|
PLOT_TEST(wheel_zoom_keeps_data_under_cursor_fixed) {
|
|
plot::Plot p;
|
|
p.set_viewport({0, 0, 800, 400});
|
|
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, true, plot::AxisPlace::Bottom, false});
|
|
plot::input::Controller c(p);
|
|
c.set_viewport({0, 0, 800, 400});
|
|
const f32 cursor_x = 200.0f;
|
|
const f64 before = p.x_transform(ax).to_data_x(cursor_x);
|
|
plot::input::WheelEvent w{cursor_x, 100.0f, 0.0f, 1.0f, 0};
|
|
c.on_wheel(w);
|
|
const f64 after = p.x_transform(ax).to_data_x(cursor_x);
|
|
PLOT_CHECK_NEAR(before, after, 1e-6);
|
|
PLOT_CHECK(plot::Range{p.x_range(ax)}.span() < 100.0); // zoomed in
|
|
}
|
|
|
|
PLOT_TEST(left_drag_pans_without_changing_span) {
|
|
plot::Plot p;
|
|
p.set_viewport({0, 0, 800, 400});
|
|
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, true, plot::AxisPlace::Bottom, false});
|
|
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
|
|
// 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, NOT the whole viewport: the viewport model makes
|
|
// dragged data lag the cursor by the label-strip fraction, 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 raises y range
|
|
PLOT_CHECK(!p.x_axis(ax).auto_scale); // a pan is a user choice, not a fit
|
|
}
|
|
|
|
PLOT_TEST(shift_drag_box_zooms_to_selection) {
|
|
plot::Plot p;
|
|
p.set_viewport({0, 0, 800, 400});
|
|
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});
|
|
// 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(box_zoom_uses_release_position_without_a_move) {
|
|
// A host that reports only a press and a release -- no motion event between
|
|
// them -- MUST still frame the box the release describes: the release position
|
|
// is authoritative for the end of the gesture.
|
|
plot::Plot p;
|
|
p.set_viewport({0, 0, 800, 400});
|
|
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 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, kShift, true, true, false});
|
|
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::BoxZoom);
|
|
// No move: selection_ is still 0x0 until the release supplies the far corner.
|
|
c.on_pointer({300, 150, 0, 0, plot::input::Button::Left, kShift, false, false, true});
|
|
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None);
|
|
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(!p.x_axis(ax).auto_scale); // a box zoom is a user choice, not a fit
|
|
}
|
|
|
|
PLOT_TEST(auto_scale_disabled_by_user_interaction) {
|
|
// A wheel zoom or pan MUST clear auto_scale on the affected axis so the range
|
|
// the user chose is not snapped back by the next autoscale.
|
|
plot::Plot p;
|
|
p.set_viewport({0, 0, 800, 400});
|
|
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, true, plot::AxisPlace::Bottom, false});
|
|
plot::input::Controller c(p);
|
|
c.set_viewport({0, 0, 800, 400});
|
|
c.on_wheel({400.0f, 200.0f, 0.0f, 1.0f, 0});
|
|
PLOT_CHECK(!p.x_axis(ax).auto_scale);
|
|
}
|
|
|
|
// --- resolutions of the brief's ambiguity, pinned ------------------------
|
|
|
|
PLOT_TEST(box_zoom_below_threshold_is_a_click) {
|
|
plot::Plot p;
|
|
p.set_viewport({0, 0, 800, 400});
|
|
const u32 ax = p.add_x_axis({"t", {0.0, 800.0}, false, plot::AxisPlace::Bottom, false});
|
|
plot::input::Controller c(p);
|
|
c.set_viewport({0, 0, 800, 400});
|
|
c.on_pointer({100, 50, 0, 0, plot::input::Button::Left, kShift, true, true, false});
|
|
c.on_pointer({102, 52, 2, 2, plot::input::Button::Left, kShift, true, false, false});
|
|
c.on_pointer({102, 52, 0, 0, plot::input::Button::Left, kShift, false, false, true});
|
|
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None);
|
|
PLOT_CHECK_NEAR(p.x_range(ax).min, 0.0, 1e-9);
|
|
PLOT_CHECK_NEAR(p.x_range(ax).max, 800.0, 1e-9);
|
|
}
|
|
|
|
PLOT_TEST(shift_wheel_zooms_x_only) {
|
|
Fixture f;
|
|
f.ay = f.plot.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
|
f.controller.on_wheel({400.0f, 200.0f, 0.0f, 1.0f, kShift});
|
|
PLOT_CHECK(f.plot.x_range(f.ax).span() < 100.0);
|
|
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).span(), 2.0, 1e-9);
|
|
}
|
|
|
|
PLOT_TEST(ctrl_wheel_zooms_y_only) {
|
|
Fixture f;
|
|
f.ay = f.plot.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
|
f.controller.on_wheel({400.0f, 200.0f, 0.0f, 1.0f, kCtrl});
|
|
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), 100.0, 1e-9);
|
|
PLOT_CHECK(f.plot.y_range(f.ay).span() < 2.0);
|
|
}
|
|
|
|
PLOT_TEST(hovered_cursor_only_inside_plot_area) {
|
|
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(50.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, 50.0);
|
|
c.on_pointer({sx, 200, 0, 0, plot::input::Button::Left, 0, false, false, false});
|
|
PLOT_CHECK(c.hovered_cursor() == cid);
|
|
// The left label strip is outside the plot area.
|
|
c.on_pointer({10, 200, 0, 0, plot::input::Button::Left, 0, false, false, false});
|
|
PLOT_CHECK(c.hovered_cursor() == plot::kInvalid);
|
|
c.on_pointer({sx, 200, 0, 0, plot::input::Button::Left, 0, false, false, false});
|
|
PLOT_CHECK(c.hovered_cursor() == cid);
|
|
}
|
|
|
|
PLOT_TEST(ctrl_drag_moves_the_nearest_cursor) {
|
|
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));
|
|
plot::input::Controller c(p);
|
|
c.set_viewport({0, 0, 800, 400});
|
|
const f32 sx = screen_x_of(p, ax, 20.0);
|
|
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::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_TEST(middle_drag_on_x_strip_zooms_x_only) {
|
|
Fixture f;
|
|
f.ay = f.plot.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
|
|
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);
|
|
// Same sign convention as the wheel: a positive dy zooms in.
|
|
f.controller.on_pointer({strip_x, strip_y + 20, 0, 20, plot::input::Button::Middle, 0, true, false, false});
|
|
f.controller.on_pointer({strip_x, strip_y + 20, 0, 0, plot::input::Button::Middle, 0, false, false, true});
|
|
// 20 px of drag is a tenth of a decade: the span is 10^(-20/200) of its
|
|
// former self. A pixel is not a wheel notch, so this pins the magnitude.
|
|
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), 100.0 * std::pow(10.0, -20.0 / 200.0), 1e-6);
|
|
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).span(), 2.0, 1e-9);
|
|
PLOT_CHECK(!f.plot.x_axis(f.ax).auto_scale);
|
|
PLOT_CHECK(f.plot.y_axis(f.ay).auto_scale); // the untouched y axis keeps auto scale
|
|
}
|
|
|
|
PLOT_TEST(middle_drag_on_y_strip_zooms_y_only) {
|
|
Fixture f;
|
|
f.ay = f.plot.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
|
const plot::PlotLayout& l = f.plot.layout();
|
|
const f32 strip_x = l.y_label_area.x + 0.5f * l.y_label_area.w;
|
|
const f32 strip_y = l.axes.y + 0.5f * l.axes.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::AxisZoomY);
|
|
f.controller.on_pointer({strip_x, strip_y + 20, 0, 20, plot::input::Button::Middle, 0, true, false, false});
|
|
f.controller.on_pointer({strip_x, strip_y + 20, 0, 0, plot::input::Button::Middle, 0, false, false, true});
|
|
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), 100.0, 1e-9);
|
|
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).span(), 2.0 * std::pow(10.0, -20.0 / 200.0), 1e-6);
|
|
}
|
|
|
|
PLOT_TEST(key_plus_zooms_in_about_the_centre) {
|
|
Fixture f;
|
|
const f64 mid_before = 0.5 * (f.plot.x_range(f.ax).min + f.plot.x_range(f.ax).max);
|
|
f.controller.on_key({plot::input::kKeyZoomIn, 0, true});
|
|
PLOT_CHECK(f.plot.x_range(f.ax).span() < 100.0);
|
|
PLOT_CHECK_NEAR(0.5 * (f.plot.x_range(f.ax).min + f.plot.x_range(f.ax).max), mid_before, 1e-9);
|
|
const f64 span_after = f.plot.x_range(f.ax).span();
|
|
f.controller.on_key({plot::input::kKeyZoomIn, 0, false}); // key-up is ignored
|
|
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), span_after, 1e-12);
|
|
}
|
|
|
|
PLOT_TEST(key_minus_zooms_out) {
|
|
Fixture f;
|
|
f.controller.on_key({plot::input::kKeyZoomOut, 0, true});
|
|
PLOT_CHECK(f.plot.x_range(f.ax).span() > 100.0);
|
|
}
|
|
|
|
/// A host can hand the controller a coordinate that is not a number: ImGui reports
|
|
/// (-FLT_MAX, -FLT_MAX) for "no mouse position", and any host can produce a NaN out
|
|
/// of a degenerate mapping of its own. The pan arithmetic runs through the axis
|
|
/// transforms, so such a value would leave a range that is not a range -- and
|
|
/// because a gesture that sets a range also clears auto scale, panning and zooming
|
|
/// could not bring it back.
|
|
PLOT_TEST(non_finite_pointer_does_not_move_a_range) {
|
|
plot::Plot p;
|
|
p.set_viewport({0, 0, 800, 400});
|
|
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, true, plot::AxisPlace::Bottom, false});
|
|
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 plot::Range xr = p.x_range(ax);
|
|
const plot::Range yr = p.y_range(ay);
|
|
|
|
c.on_pointer({100, 100, 0, 0, plot::input::Button::Left, 0, true, true, false});
|
|
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::Pan);
|
|
|
|
const float inf = std::numeric_limits<float>::infinity();
|
|
const float nan = std::numeric_limits<float>::quiet_NaN();
|
|
const float sentinel = -std::numeric_limits<float>::max(); // ImGui's "no pointer"
|
|
|
|
// The pointer-left-the-window sentinel arrives at the unrepresentable end of the
|
|
// axis, so scaling and differencing it is a 1e35 pan. It must read as no
|
|
// movement at all.
|
|
c.on_pointer({sentinel, sentinel, inf, -inf, plot::input::Button::Left, 0, true, false, false});
|
|
PLOT_CHECK(std::isfinite(p.x_range(ax).min) && std::isfinite(p.x_range(ax).max));
|
|
PLOT_CHECK(std::isfinite(p.y_range(ay).min) && std::isfinite(p.y_range(ay).max));
|
|
PLOT_CHECK_NEAR(p.x_range(ax).min, xr.min, 1e-9);
|
|
PLOT_CHECK_NEAR(p.x_range(ax).max, xr.max, 1e-9);
|
|
PLOT_CHECK_NEAR(p.y_range(ay).min, yr.min, 1e-9);
|
|
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::Pan); // still the same drag
|
|
|
|
// A usable position with an unusable delta is no movement either, and a later
|
|
// usable position is not differenced against the sentinel.
|
|
c.on_pointer({180, 130, inf, nan, plot::input::Button::Left, 0, true, false, false});
|
|
PLOT_CHECK_NEAR(p.x_range(ax).min, xr.min, 1e-9);
|
|
PLOT_CHECK_NEAR(p.x_range(ax).max, xr.max, 1e-9);
|
|
PLOT_CHECK_NEAR(p.y_range(ay).min, yr.min, 1e-9);
|
|
c.on_pointer({220, 160, 40, 30, plot::input::Button::Left, 0, true, false, false});
|
|
PLOT_CHECK(std::isfinite(p.x_range(ax).min)); // a real move still pans
|
|
PLOT_CHECK(p.x_range(ax).min < xr.min);
|
|
|
|
// The buttons in a sanitised event still act: the release ends the drag.
|
|
c.on_pointer({220, 160, 0, 0, plot::input::Button::Left, 0, false, false, true});
|
|
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None);
|
|
}
|
|
|
|
PLOT_TEST(non_finite_wheel_does_not_break_a_range) {
|
|
plot::Plot p;
|
|
p.set_viewport({0, 0, 800, 400});
|
|
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, true, plot::AxisPlace::Bottom, false});
|
|
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 float inf = std::numeric_limits<float>::infinity();
|
|
const float nan = std::numeric_limits<float>::quiet_NaN();
|
|
|
|
// A zoom is anchored at the data coordinate under the pointer, so a position that
|
|
// is not a number must not become the anchor: the axis would be re-centred on
|
|
// NaN, which no gesture could undo. The fallback is a real coordinate and the
|
|
// zoom still happens. (A *finite* sentinel -- ImGui's `-FLT_MAX` for "no pointer"
|
|
// -- is not the controller's to interpret: finite coordinates are legal input.
|
|
// The adapter translates that one; see tests/test_imgui_widget.cpp.)
|
|
const double span_before = p.x_range(ax).span();
|
|
c.on_wheel({nan, nan, 0.0f, 1.0f, 0});
|
|
PLOT_CHECK(std::isfinite(p.x_range(ax).min) && std::isfinite(p.x_range(ax).max));
|
|
PLOT_CHECK(std::isfinite(p.y_range(ay).min) && std::isfinite(p.y_range(ay).max));
|
|
PLOT_CHECK(std::fabs(p.x_range(ax).min) < 1e6 && std::fabs(p.x_range(ax).max) < 1e6);
|
|
PLOT_CHECK(p.x_range(ax).span() < span_before);
|
|
|
|
// A non-finite amount is no zoom at all -- and, just as important, it must not
|
|
// touch the axes. A *zeroed* amount would be a zero-notch zoom (a factor of
|
|
// exactly one): the range would survive, but the range setters it runs through
|
|
// clear auto scale, so one garbage wheel would quietly disarm auto-fit.
|
|
p.set_x_auto_scale(ax, true);
|
|
p.set_y_auto_scale(ay, true);
|
|
const plot::Range xr = p.x_range(ax);
|
|
const plot::Range yr = p.y_range(ay);
|
|
c.on_wheel({400.0f, 200.0f, 0.0f, nan, 0});
|
|
c.on_wheel({400.0f, 200.0f, 0.0f, inf, 0});
|
|
PLOT_CHECK_NEAR(p.x_range(ax).min, xr.min, 1e-12);
|
|
PLOT_CHECK_NEAR(p.x_range(ax).max, xr.max, 1e-12);
|
|
PLOT_CHECK_NEAR(p.y_range(ay).min, yr.min, 1e-12);
|
|
PLOT_CHECK(p.x_axis(ax).auto_scale);
|
|
PLOT_CHECK(p.y_axis(ay).auto_scale);
|
|
}
|
|
|
|
PLOT_TEST(key_f_auto_fits_traces) {
|
|
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 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
|
std::vector<f32> ys(51);
|
|
for (u64 i = 0; i < ys.size(); ++i) ys[i] = static_cast<f32>(i) * 0.1f; // 0.0 .. 5.0
|
|
plot::data::ArraySeries s(std::move(ys), 0.0, 1.0); // x = 0 .. 50
|
|
p.add_trace({&s, plot::Color::hex(0x4FA3FF), 1.5f, 0, 0});
|
|
plot::input::Controller c(p);
|
|
c.set_viewport({0, 0, 800, 400});
|
|
c.on_key({plot::input::kKeyFit, 0, true});
|
|
PLOT_CHECK(p.x_range(ax).min < 0.0);
|
|
PLOT_CHECK(p.x_range(ax).max < 100.0); // fitted to the series' 0..50
|
|
PLOT_CHECK(p.y_range(ay).min < 0.0);
|
|
PLOT_CHECK(p.y_range(ay).max > 1.0); // fitted to the series' 0..5
|
|
PLOT_CHECK(p.x_axis(ax).auto_scale); // a fit re-arms auto scale
|
|
PLOT_CHECK(p.y_axis(ay).auto_scale);
|
|
}
|
|
|
|
// --- tolerances are logical, coordinates are device pixels -------------------
|
|
|
|
/// The grab radius is a distance the user aims with, so it must be the same
|
|
/// *physical* distance at any DPI: the controller compares device-pixel coordinates
|
|
/// against `logical * Plot::ui_scale()`. The press positions below are the same
|
|
/// logical distance (0.9 and 1.1 of the 7 px radius) from the cursor at both scales,
|
|
/// so the same press grabs -- and the same press just outside does not.
|
|
PLOT_TEST(grab_radius_scales_with_dpi) {
|
|
for (const f32 scale : {1.0f, 2.0f}) {
|
|
plot::Plot p;
|
|
p.set_ui_scale(scale);
|
|
p.set_viewport({0, 0, 800.0f * scale, 400.0f * scale});
|
|
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
|
|
const u32 cid = p.add_cursor(50.0, plot::Color::hex(0xFF0000));
|
|
plot::input::Controller c(p);
|
|
c.set_viewport({0, 0, 800.0f * scale, 400.0f * scale});
|
|
PLOT_CHECK_NEAR(p.ui_scale(), scale, 1e-6);
|
|
|
|
const f32 sx = screen_x_of(p, ax, 50.0);
|
|
const f32 inside = 0.9f * 7.0f * scale; // logical: within the grab radius
|
|
const f32 outside = 1.1f * 7.0f * scale; // logical: just past it
|
|
c.on_pointer({sx + inside, 200.0f * scale, 0, 0, plot::input::Button::Left, 0, false, false,
|
|
false});
|
|
PLOT_CHECK(c.hovered_cursor() == cid);
|
|
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,
|
|
false});
|
|
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::MoveCursor);
|
|
}
|
|
}
|
|
|
|
/// The box-zoom click threshold is a hand movement too: a selection the user
|
|
/// perceives as a few pixels is a click at any DPI, so both dimensions are compared
|
|
/// against `logical * Plot::ui_scale()`.
|
|
PLOT_TEST(box_zoom_click_threshold_scales_with_dpi) {
|
|
for (const f32 scale : {1.0f, 2.0f}) {
|
|
plot::Plot p;
|
|
p.set_ui_scale(scale);
|
|
p.set_viewport({0, 0, 800.0f * scale, 400.0f * scale});
|
|
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.0f * scale, 400.0f * scale});
|
|
const plot::Range before = p.x_range(ax);
|
|
|
|
// 3 logical px on each side: a click, at both scales.
|
|
const f32 x0 = 100.0f * scale;
|
|
const f32 y0 = 50.0f * scale;
|
|
const f32 small = 3.0f * scale;
|
|
c.on_pointer({x0, y0, 0, 0, plot::input::Button::Left, kShift, true, true, false});
|
|
c.on_pointer({x0 + small, y0 + small, small, small, plot::input::Button::Left, kShift, true,
|
|
false, false});
|
|
c.on_pointer({x0 + small, y0 + small, 0, 0, plot::input::Button::Left, kShift, false, false,
|
|
true});
|
|
PLOT_CHECK_NEAR(p.x_range(ax).min, before.min, 1e-9);
|
|
PLOT_CHECK_NEAR(p.x_range(ax).max, before.max, 1e-9);
|
|
|
|
// 5 logical px on each side: a box, at both scales.
|
|
const f32 large = 5.0f * scale;
|
|
c.on_pointer({x0, y0, 0, 0, plot::input::Button::Left, kShift, true, true, false});
|
|
c.on_pointer({x0 + large, y0 + large, large, large, plot::input::Button::Left, kShift, true,
|
|
false, false});
|
|
c.on_pointer({x0 + large, y0 + large, 0, 0, plot::input::Button::Left, kShift, false, false,
|
|
true});
|
|
PLOT_CHECK(p.x_range(ax).min > before.min);
|
|
PLOT_CHECK(p.x_range(ax).max < before.max);
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------- live x follow (spec §12)
|
|
//
|
|
// The gestures on an axis that is following live data. Every path below already
|
|
// existed; what is new is that none of them disarms following, and that the ranges
|
|
// they set are read by `Plot::set_x_range` as a span and an offset behind the live
|
|
// edge rather than as absolute positions.
|
|
|
|
namespace {
|
|
|
|
/// A streaming plot under the controller: one `StreamSeries` on one x axis, 800x400,
|
|
/// with `push` samples already produced and acquired. The axis starts 10 units wide;
|
|
/// a case arms following when it needs it, so "Off is the default" is the state every
|
|
/// case here starts from.
|
|
struct LiveFixture {
|
|
plot::data::StreamSeries series;
|
|
plot::Plot graph;
|
|
plot::input::Controller controller{graph};
|
|
u32 ax = 0;
|
|
u32 ay = 0;
|
|
|
|
explicit LiveFixture(u64 retain, u64 push) : series(retain, 0.0, 1.0) {
|
|
graph.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
|
ax = graph.add_x_axis({"t", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false});
|
|
ay = graph.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
|
graph.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, ax, ay});
|
|
feed(push);
|
|
controller.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
|
}
|
|
|
|
void feed(u64 n) {
|
|
for (u64 i = 0; i < n; ++i) series.push(0.0f);
|
|
series.acquire();
|
|
}
|
|
void live() { graph.set_x_follow(ax, plot::XFollow::Live); }
|
|
f64 newest() const { return series.x_bounds().max; }
|
|
plot::Range xr() const { return graph.x_range(ax); }
|
|
};
|
|
|
|
/// The centre of the x label strip: outside `axes`, which is where a zoom's anchor
|
|
/// falls back to the live edge, and where the middle-drag x zoom is aimed anyway.
|
|
plot::input::WheelEvent strip_wheel(const plot::Plot& p, f32 notches) {
|
|
const plot::Rect strip = std::as_const(p).layout().x_label_area;
|
|
return {strip.x + 0.5f * strip.w, strip.y + 0.5f * strip.h, 0.0f, notches, 0};
|
|
}
|
|
|
|
/// One left drag of `dx` device pixels across the axes, through the controller.
|
|
void drag(plot::input::Controller& c, const plot::Rect& axes, f32 dx) {
|
|
const f32 x = axes.x + 0.5f * axes.w;
|
|
const f32 y = axes.y + 0.5f * axes.h;
|
|
c.on_pointer({x, y, 0.0f, 0.0f, plot::input::Button::Left, 0, true, true, false});
|
|
c.on_pointer({x + dx, y, dx, 0.0f, plot::input::Button::Left, 0, true, false, false});
|
|
c.on_pointer({x + dx, y, 0.0f, 0.0f, plot::input::Button::Left, 0, false, false, true});
|
|
}
|
|
|
|
} // namespace
|
|
|
|
/// The regression §12 exists for. Today a wheel zoom clears the x axis' auto scale,
|
|
/// and a live plot then stops following the newest sample for good: the window stays
|
|
/// where the zoom left it while the stream walks out of it. Under `Live` the span
|
|
/// shrinks, following stays armed, and the window keeps ending where it ended --
|
|
/// which is asserted by *both* halves below, because the edge is the newest sample
|
|
/// only for a zoom anchored on it.
|
|
PLOT_TEST(wheel_zoom_keeps_following) {
|
|
LiveFixture f(200, 40);
|
|
f.graph.set_x_auto_scale(f.ax, true); // the only way to follow before this feature
|
|
f.graph.layout();
|
|
f.live();
|
|
const f64 span_before = f.xr().span();
|
|
const plot::Rect axes = std::as_const(f.graph).layout().axes;
|
|
|
|
// A notch from outside `axes` anchors on the live edge, so the window still ends
|
|
// at the newest sample afterwards -- and goes on doing so as data arrives.
|
|
f.controller.on_wheel(strip_wheel(f.graph, 1.0f));
|
|
PLOT_CHECK(f.xr().span() < span_before);
|
|
PLOT_CHECK_NEAR(f.xr().max, f.newest(), 1e-9);
|
|
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
|
|
f.feed(20);
|
|
f.graph.layout();
|
|
PLOT_CHECK_NEAR(f.xr().max, f.newest(), 1e-9);
|
|
const f64 zoomed = f.xr().span();
|
|
|
|
// A notch from inside `axes` anchors on the data under the pointer, which pushes
|
|
// the window back from the edge by what the zoom chose -- and it still follows:
|
|
// that distance is constant as the stream advances, so the newest sample is the
|
|
// same distance off the right edge on every frame instead of drifting away.
|
|
f.controller.on_wheel({axes.x + 0.5f * axes.w, axes.y + 0.5f * axes.h, 0.0f, 1.0f, 0});
|
|
PLOT_CHECK_NEAR(f.xr().span(), zoomed * std::exp(-0.15), 1e-9);
|
|
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
|
|
const f64 behind = f.newest() - f.xr().max;
|
|
PLOT_CHECK(behind > 0.0);
|
|
f.feed(25);
|
|
f.graph.layout();
|
|
PLOT_CHECK_NEAR(f.newest() - f.xr().max, behind, 1e-9);
|
|
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
|
|
}
|
|
|
|
/// Two half-notches are one full notch, because the wheel *multiplies* the span: an
|
|
/// additive step would give `2 * (span - d/2) != span - d`. The two fixtures have
|
|
/// identical state, so the comparison is exact -- and the pinned value says which
|
|
/// arithmetic produced it.
|
|
PLOT_TEST(wheel_zoom_is_relative) {
|
|
LiveFixture a(200, 40);
|
|
LiveFixture b(200, 40);
|
|
a.live();
|
|
b.live();
|
|
PLOT_CHECK_NEAR(a.xr().span(), b.xr().span(), 1e-12); // same state to start from
|
|
|
|
a.controller.on_wheel(strip_wheel(a.graph, 0.5f));
|
|
a.controller.on_wheel(strip_wheel(a.graph, 0.5f));
|
|
b.controller.on_wheel(strip_wheel(b.graph, 1.0f));
|
|
PLOT_CHECK_NEAR(a.xr().span(), b.xr().span(), 1e-9);
|
|
PLOT_CHECK_NEAR(b.xr().span(), 10.0 * std::exp(-0.15), 1e-9);
|
|
PLOT_CHECK(a.xr().span() < 10.0);
|
|
// Each window still ends at its own live edge, so the relative zoom kept following.
|
|
PLOT_CHECK_NEAR(a.xr().max, a.newest(), 1e-9);
|
|
PLOT_CHECK_NEAR(b.xr().max, b.newest(), 1e-9);
|
|
PLOT_CHECK(a.graph.x_follow(a.ax) == plot::XFollow::Live);
|
|
PLOT_CHECK(b.graph.x_follow(b.ax) == plot::XFollow::Live);
|
|
}
|
|
|
|
/// Panning a live plot moves the window, not the scale: the offset grows by the
|
|
/// drag's data delta, the span is untouched, and following is still armed. Dragging
|
|
/// right reveals earlier data, which for a live window means moving back from the
|
|
/// edge -- and the window then keeps that distance as the stream advances.
|
|
PLOT_TEST(mouse_pan_shifts_the_offset_and_preserves_the_span) {
|
|
LiveFixture f(200, 40);
|
|
f.live();
|
|
const f64 span = f.xr().span();
|
|
const plot::Rect axes = std::as_const(f.graph).layout().axes;
|
|
const f32 dx = 60.0f;
|
|
drag(f.controller, axes, dx);
|
|
|
|
const f64 want = static_cast<f64>(dx) / static_cast<f64>(axes.w) * span;
|
|
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), want, 1e-9);
|
|
PLOT_CHECK_NEAR(f.xr().span(), span, 1e-9);
|
|
PLOT_CHECK_NEAR(f.xr().max, f.newest() - want, 1e-9);
|
|
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
|
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::None);
|
|
|
|
f.feed(20);
|
|
f.graph.layout();
|
|
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), want, 1e-9);
|
|
PLOT_CHECK_NEAR(f.xr().span(), span, 1e-9);
|
|
PLOT_CHECK_NEAR(f.xr().max, f.newest() - want, 1e-9);
|
|
}
|
|
|
|
/// Dragging toward the live edge stops at it: the offset clamps at 0, so the window
|
|
/// ends at the newest sample instead of turning inside out -- the failure an
|
|
/// unclamped offset would produce, and one no gesture could undo.
|
|
PLOT_TEST(pan_cannot_pass_the_live_edge) {
|
|
LiveFixture f(200, 40);
|
|
f.live();
|
|
const plot::Rect axes = std::as_const(f.graph).layout().axes;
|
|
drag(f.controller, axes, 100.0f); // back from the edge
|
|
PLOT_CHECK(f.graph.x_offset(f.ax) > 0.0);
|
|
const f64 span = f.xr().span();
|
|
|
|
drag(f.controller, axes, -300.0f); // hard toward it, past it
|
|
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), 0.0, 1e-9);
|
|
PLOT_CHECK_NEAR(f.xr().max, f.newest(), 1e-9);
|
|
PLOT_CHECK_NEAR(f.xr().span(), span, 1e-9);
|
|
PLOT_CHECK(f.xr().min < f.xr().max); // a real window, not an inverted one
|
|
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
|
|
}
|
|
|
|
/// The point of an offset: a user who scrolled back keeps seeing what they scrolled
|
|
/// to. The window advances by exactly what the stream advanced, so the sample at its
|
|
/// right edge is the same one frame after frame, and the span is unchanged.
|
|
PLOT_TEST(offset_survives_new_samples) {
|
|
LiveFixture f(200, 48);
|
|
f.live();
|
|
f.graph.set_x_offset(f.ax, 10.0);
|
|
const f64 span = f.xr().span();
|
|
const f64 newest0 = f.newest();
|
|
const f64 edge_sample = f.xr().max;
|
|
PLOT_CHECK_NEAR(edge_sample, newest0 - 10.0, 1e-9);
|
|
|
|
for (int i = 0; i < 4; ++i) {
|
|
f.feed(5);
|
|
f.graph.layout();
|
|
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), 10.0, 1e-9);
|
|
PLOT_CHECK_NEAR(f.xr().span(), span, 1e-9);
|
|
PLOT_CHECK_NEAR(f.xr().max - edge_sample, f.newest() - newest0, 1e-9);
|
|
// ...and it lands on a sample, so the same sample is at the right edge rather
|
|
// than the window having crept between two of them.
|
|
PLOT_CHECK_NEAR((f.xr().max - newest0) / f.series.dx(),
|
|
std::round((f.xr().max - newest0) / f.series.dx()), 1e-9);
|
|
}
|
|
PLOT_CHECK_NEAR(f.xr().max, f.newest() - 10.0, 1e-9);
|
|
}
|
|
|
|
/// The pre-existing behaviour, pinned so the mode is provably opt-in: on an `Off`
|
|
/// axis a gesture clears auto scale and the window then stays exactly where the
|
|
/// gesture left it, however much data arrives. That is what following replaces, and
|
|
/// it is still what an untouched plot does -- the six other controller cases above
|
|
/// are about this path.
|
|
PLOT_TEST(off_plot_zoom_and_pan_are_unchanged) {
|
|
LiveFixture f(200, 40);
|
|
f.graph.set_x_auto_scale(f.ax, true);
|
|
f.graph.layout(); // today's implicit "follow": fit the retained window
|
|
const plot::Range fitted = f.xr();
|
|
const plot::Rect axes = std::as_const(f.graph).layout().axes;
|
|
|
|
const f32 px = axes.x + 0.3f * axes.w;
|
|
const f64 under = f.graph.x_transform(f.ax).to_data_x(px);
|
|
f.controller.on_wheel({px, axes.y + 0.5f * axes.h, 0.0f, 1.0f, 0});
|
|
PLOT_CHECK_NEAR(f.graph.x_transform(f.ax).to_data_x(px), under, 1e-9); // anchor still
|
|
PLOT_CHECK(f.xr().span() < fitted.span());
|
|
PLOT_CHECK(!f.graph.x_axis(f.ax).auto_scale); // a gesture is still a user choice
|
|
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Off); // and arms nothing
|
|
|
|
const f64 span = f.xr().span();
|
|
const f64 grabbed = f.graph.x_transform(f.ax).to_data_x(axes.x + 0.25f * axes.w);
|
|
drag(f.controller, axes, 60.0f);
|
|
PLOT_CHECK_NEAR(f.xr().span(), span, 1e-9);
|
|
PLOT_CHECK_NEAR(f.graph.x_transform(f.ax).to_data_x(axes.x + 0.25f * axes.w + 60.0f), grabbed,
|
|
1e-9);
|
|
|
|
// Frozen: the stream walks on and the window does not move. This is the pre-fix
|
|
// behaviour, and the reason `Live` exists.
|
|
const plot::Range frozen = f.xr();
|
|
f.feed(30);
|
|
f.graph.layout();
|
|
PLOT_CHECK_NEAR(f.xr().min, frozen.min, 1e-9);
|
|
PLOT_CHECK_NEAR(f.xr().max, frozen.max, 1e-9);
|
|
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Off);
|
|
}
|
|
|
|
/// The deferred DPI item from the UI-scale task: the middle-drag axis zoom's gain was
|
|
/// per *device* pixel, so the same physical drag zoomed twice as much at 2x. The gain
|
|
/// is now logical -- 200 logical px is one decade -- and the drag delta is converted
|
|
/// through the plot's scale, so the same physical drag zooms the same amount at either
|
|
/// scale. The wheel is in notches, a unit that is already device-independent, so it
|
|
/// must *not* be converted: one notch scales the span by the same factor at both.
|
|
PLOT_TEST(drag_zoom_is_logical_the_wheel_is_not_scaled) {
|
|
for (const f32 scale : {1.0f, 2.0f}) {
|
|
plot::Plot p;
|
|
p.set_ui_scale(scale);
|
|
p.set_viewport({0.0f, 0.0f, 800.0f * scale, 400.0f * scale});
|
|
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
|
|
plot::input::Controller c(p);
|
|
c.set_viewport({0.0f, 0.0f, 800.0f * scale, 400.0f * scale});
|
|
|
|
const plot::PlotLayout& l = p.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;
|
|
const f32 drag_px = 20.0f * scale; // 20 *logical* px at both scales
|
|
c.on_pointer({strip_x, strip_y, 0, 0, plot::input::Button::Middle, 0, true, true, false});
|
|
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::AxisZoomX);
|
|
c.on_pointer({strip_x, strip_y + drag_px, 0, drag_px, plot::input::Button::Middle, 0, true,
|
|
false, false});
|
|
c.on_pointer({strip_x, strip_y + drag_px, 0, 0, plot::input::Button::Middle, 0, false, false,
|
|
true});
|
|
PLOT_CHECK_NEAR(p.x_range(ax).span(), 100.0 * std::pow(10.0, -20.0 / 200.0), 1e-6);
|
|
|
|
const f64 span_before = p.x_range(ax).span();
|
|
c.on_wheel({400.0f * scale, 200.0f * scale, 0.0f, 1.0f, 0});
|
|
PLOT_CHECK_NEAR(p.x_range(ax).span(), span_before * std::exp(-0.15), 1e-9);
|
|
}
|
|
}
|
|
|
|
/// The consequences of a degenerate range on a live axis, from the gesture side: the
|
|
/// wheel, the pan and the axis zoom all refuse a span that is not a span, so the
|
|
/// frozen axis a stored zero-span left behind was one no interaction could move --
|
|
/// while `x_follow()` said `Live`. With the guard in `set_x_range` the slips a caller
|
|
/// can actually make leave the gestures working.
|
|
PLOT_TEST(live_gestures_survive_a_degenerate_range) {
|
|
LiveFixture f(200, 40);
|
|
f.live();
|
|
const plot::Rect axes = std::as_const(f.graph).layout().axes;
|
|
const f64 span = f.xr().span();
|
|
const f64 newest = f.newest();
|
|
const plot::input::WheelEvent wheel = strip_wheel(f.graph, 1.0f);
|
|
|
|
f.graph.set_x_range(f.ax, {newest, newest}); // the programmatic slip
|
|
PLOT_CHECK_NEAR(f.xr().span(), span, 1e-9);
|
|
f.graph.set_x_range(f.ax, {newest - 5.0, newest - 15.0}); // and an inverted one
|
|
PLOT_CHECK_NEAR(f.xr().span(), 10.0, 1e-9); // normalised, not stored inverted
|
|
|
|
f.controller.on_wheel(wheel);
|
|
PLOT_CHECK_NEAR(f.xr().span(), 10.0 * std::exp(-0.15), 1e-9); // the wheel still zooms
|
|
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
|
|
const f64 span_after_wheel = f.xr().span();
|
|
drag(f.controller, axes, 60.0f); // a left drag: the pan gesture
|
|
PLOT_CHECK(f.graph.x_offset(f.ax) > 0.0); // the pan still pans
|
|
PLOT_CHECK_NEAR(f.xr().span(), span_after_wheel, 1e-9);
|
|
PLOT_CHECK_NEAR(f.xr().max, f.newest() - f.graph.x_offset(f.ax), 1e-9);
|
|
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
|
|
}
|
|
|
|
// --- direct manipulation: grabbable cursors, regions and band edges (spec §5) ---
|
|
|
|
namespace {
|
|
|
|
/// The x column a region's bound sits at, as `Plot::layout()` measured it -- the same
|
|
/// number the controller hit-tests and the renderer draws the bound in. Negative when
|
|
/// the layout holds no such region, which no caller here should see.
|
|
f32 bound_x(plot::Plot& p, u32 region, bool left) {
|
|
for (const plot::RegionLayout& r : p.layout().regions) {
|
|
if (r.region != region) continue;
|
|
return plot::handle_column(left ? r.left_handle : r.right_handle);
|
|
}
|
|
return -1.0f;
|
|
}
|
|
|
|
/// A region whose bounds are stored exactly as written: `Free` snap, so a drag's
|
|
/// endpoints are the numbers the gesture put there rather than the sample grid
|
|
/// `add_region()` pulls a snapping region onto.
|
|
plot::Region free_region(f64 x0, f64 x1) {
|
|
plot::Region r;
|
|
r.x0 = x0;
|
|
r.x1 = x1;
|
|
r.snap = plot::RegionSnap::Free;
|
|
return r;
|
|
}
|
|
|
|
/// The data x under one device-pixel column.
|
|
f64 data_at(const plot::Plot& p, u32 axis, f32 px) {
|
|
return p.x_transform(axis).to_data_x(static_cast<f64>(px));
|
|
}
|
|
|
|
/// The device column a data x is drawn at.
|
|
f32 column_of(const plot::Plot& p, u32 axis, f64 data) {
|
|
return static_cast<f32>(p.x_transform(axis).to_screen_x(data));
|
|
}
|
|
|
|
/// The y twins of `data_at()`/`column_of()`: what a horizontal ruler is placed by and hit in.
|
|
f64 data_at_row(const plot::Plot& p, u32 axis, f32 row) {
|
|
return p.y_transform(axis).to_data_y(static_cast<f64>(row));
|
|
}
|
|
f32 row_of(const plot::Plot& p, u32 axis, f64 data) {
|
|
return static_cast<f32>(p.y_transform(axis).to_screen_y(data));
|
|
}
|
|
|
|
void hover(plot::input::Controller& c, f32 px, f32 py = 200.0f, u32 mods = 0) {
|
|
c.on_pointer({px, py, 0.0f, 0.0f, plot::input::Button::Left, mods, false, false, false});
|
|
}
|
|
void press(plot::input::Controller& c, f32 px, f32 py = 200.0f, u32 mods = 0) {
|
|
c.on_pointer({px, py, 0.0f, 0.0f, plot::input::Button::Left, mods, true, true, false});
|
|
}
|
|
void move_to(plot::input::Controller& c, f32 px, f32 py = 200.0f, u32 mods = 0) {
|
|
c.on_pointer({px, py, 0.0f, 0.0f, plot::input::Button::Left, mods, true, false, false});
|
|
}
|
|
void release(plot::input::Controller& c, f32 px, f32 py = 200.0f, u32 mods = 0) {
|
|
c.on_pointer({px, py, 0.0f, 0.0f, plot::input::Button::Left, mods, false, false, true});
|
|
}
|
|
|
|
/// Root-mean-square of the y values of the `y(i) == i` samples in [begin, end]: what
|
|
/// the band case's measurement has to match exactly.
|
|
f64 ramp_rms(u32 begin, u32 end) {
|
|
f64 sq = 0.0;
|
|
for (u32 i = begin; i <= end; ++i) sq += static_cast<f64>(i) * static_cast<f64>(i);
|
|
return std::sqrt(sq / static_cast<f64>(end - begin + 1u));
|
|
}
|
|
|
|
} // namespace
|
|
|
|
/// A marker is a handle like any other and needs no modifier to be moved: the hit
|
|
/// test runs before the pan is ever considered, so a press on a cursor is that
|
|
/// cursor's drag while a press anywhere else still pans. `Ctrl` is not a second
|
|
/// gesture -- it must not turn a press on a marker into anything else, which is the
|
|
/// regression to watch for.
|
|
PLOT_TEST(cursor_is_grabbed_without_a_modifier) {
|
|
Fixture f;
|
|
const u32 cid = f.plot.add_cursor(50.0, plot::Color::hex(0x00FF00));
|
|
const f32 sx = screen_x_of(f.plot, f.ax, 50.0);
|
|
const f64 range_min = f.plot.x_range(f.ax).min;
|
|
|
|
press(f.controller, sx);
|
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor);
|
|
PLOT_CHECK(f.controller.grabbed_cursor() == cid);
|
|
// The plot publishes the same answers, for the renderer and for a host that wants
|
|
// a resize cursor or a status line of its own.
|
|
PLOT_CHECK(f.plot.hot_handles().cursor == cid);
|
|
PLOT_CHECK(f.plot.hot_handles().grabbed_cursor == cid);
|
|
move_to(f.controller, sx + 100.0f);
|
|
PLOT_CHECK_NEAR(f.plot.cursor(cid).x, data_at(f.plot, f.ax, sx + 100.0f), 1e-9);
|
|
PLOT_CHECK(f.plot.cursor(cid).x > 50.0);
|
|
// The view did not pan: the gesture belonged to the marker, not to the ranges.
|
|
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, range_min, 1e-9);
|
|
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), 100.0, 1e-9);
|
|
release(f.controller, sx + 100.0f);
|
|
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);
|
|
}
|
|
|
|
/// A snapping cursor follows the pointer for the whole drag: the drag writes the free
|
|
/// pointer position (`CursorPlacement::Free`) and the snap is committed on the drop, so on a
|
|
/// *sparse* row -- where the samples are far apart -- the marker tracks the hand instead of
|
|
/// hopping between sample columns. This is the reported defect ("they stick to data in a
|
|
/// wired way") measured rather than asserted: every step is within a pixel of the pointer.
|
|
PLOT_TEST(cursor_follows_the_pointer_freely_while_dragging) {
|
|
Fixture f;
|
|
f.ay = f.plot.add_y_axis({"v", {0.0, 4.0}, false, plot::AxisPlace::Left, false});
|
|
std::vector<f32> ys{0.0f, 1.0f, 2.0f, 3.0f, 4.0f};
|
|
plot::data::ArraySeries s(std::move(ys), 0.0, 25.0); // 5 samples over [0, 100]
|
|
f.plot.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, f.ax, f.ay});
|
|
const u32 cid = f.plot.add_cursor(50.0, plot::Color::hex(0x00FF00));
|
|
f.plot.set_cursor_snap(cid, plot::SnapMode::NearestSample);
|
|
f.plot.layout();
|
|
|
|
const f32 start = screen_x_of(f.plot, f.ax, 50.0);
|
|
const f32 pitch = screen_x_of(f.plot, f.ax, 75.0) - start;
|
|
PLOT_CHECK(pitch > 100.0f); // the row really is sparse: one sample hop is over 100 px
|
|
|
|
press(f.controller, start);
|
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor);
|
|
for (f32 d = 7.0f; d <= 0.9f * pitch; d += 7.0f) {
|
|
const f32 px = start + d;
|
|
move_to(f.controller, px);
|
|
const f32 cursor_px = column_of(f.plot, f.ax, f.plot.cursor(cid).x);
|
|
PLOT_CHECK(std::fabs(cursor_px - px) <= 1.0f); // tracks the pointer, not a sample column
|
|
}
|
|
release(f.controller, start + 0.9f * pitch);
|
|
}
|
|
|
|
/// The drop commits the snap: during the drag the stored x is the free pointer position and
|
|
/// equal to no sample of the grid, and after the release it is the nearest sample. Both
|
|
/// halves in one case, so the moment the snap runs is pinned rather than inferred.
|
|
PLOT_TEST(cursor_snaps_when_the_drag_ends) {
|
|
Fixture f;
|
|
f.ay = f.plot.add_y_axis({"v", {0.0, 4.0}, false, plot::AxisPlace::Left, false});
|
|
std::vector<f32> ys{0.0f, 1.0f, 2.0f, 3.0f, 4.0f};
|
|
plot::data::ArraySeries s(std::move(ys), 0.0, 25.0); // samples at x = 0,25,50,75,100
|
|
f.plot.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, f.ax, f.ay});
|
|
const u32 cid = f.plot.add_cursor(50.0, plot::Color::hex(0x00FF00));
|
|
f.plot.set_cursor_snap(cid, plot::SnapMode::NearestSample);
|
|
f.plot.layout();
|
|
|
|
const f32 start = screen_x_of(f.plot, f.ax, 50.0);
|
|
const f32 pitch = screen_x_of(f.plot, f.ax, 75.0) - start;
|
|
const f32 to = start + 0.55f * pitch; // between the 50 and 75 samples, nearer 75
|
|
|
|
press(f.controller, start);
|
|
move_to(f.controller, to);
|
|
const f64 mid = f.plot.cursor(cid).x;
|
|
PLOT_CHECK_NEAR(mid, data_at(f.plot, f.ax, to), 1e-9); // the free pointer position
|
|
PLOT_CHECK(std::fabs(mid - 50.0) > 1.0 && std::fabs(mid - 75.0) > 1.0); // on no sample
|
|
|
|
release(f.controller, to);
|
|
PLOT_CHECK_NEAR(f.plot.cursor(cid).x, 75.0, 1e-9); // the drop landed on the grid
|
|
}
|
|
|
|
/// A cursor within the grab radius takes the press from a region's edge at the same point:
|
|
/// the hit test runs cursor-first, and both are hot, so what a press means is the cursor.
|
|
/// With the cursor clear of the bound the region wins its own edge back -- the precedence is
|
|
/// a *proximity* rule, not a blanket one that would brick the region.
|
|
PLOT_TEST(cursor_wins_the_grab_over_a_nearby_region_handle) {
|
|
Fixture f;
|
|
const u32 cid = f.plot.add_cursor(50.0, plot::Color::hex(0x00FF00));
|
|
const f32 marker = screen_x_of(f.plot, f.ax, 50.0);
|
|
// A region whose left bound sits 6 px from the marker -- inside the 7 px grab radius.
|
|
const f32 edge = marker + 6.0f;
|
|
const u32 rid = f.plot.add_region(
|
|
free_region(data_at(f.plot, f.ax, edge), data_at(f.plot, f.ax, edge + 80.0f)));
|
|
|
|
hover(f.controller, edge);
|
|
PLOT_CHECK(f.controller.hovered_cursor() == cid);
|
|
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Left); // both are hot
|
|
press(f.controller, edge);
|
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor);
|
|
PLOT_CHECK(f.controller.grabbed_cursor() == cid);
|
|
PLOT_CHECK(f.controller.grabbed_region() == plot::kInvalid);
|
|
release(f.controller, edge);
|
|
|
|
// Move the cursor well clear: now no cursor is close, and the region's own edge wins.
|
|
f.plot.set_cursor_x(cid, data_at(f.plot, f.ax, marker - 200.0f));
|
|
hover(f.controller, edge);
|
|
PLOT_CHECK(f.controller.hovered_cursor() == plot::kInvalid);
|
|
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Left);
|
|
press(f.controller, edge);
|
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::ResizeRegionLeft);
|
|
PLOT_CHECK(f.controller.grabbed_region() == rid);
|
|
release(f.controller, edge);
|
|
}
|
|
|
|
/// The other half of the precedence rule: a region with no cursor near it is as draggable as
|
|
/// it ever was -- each zone still opens its own gesture, the edge is still an edge, and a
|
|
/// body drag still preserves the width exactly.
|
|
PLOT_TEST(a_region_without_a_cursor_nearby_is_still_draggable) {
|
|
Fixture f;
|
|
const u32 cid = f.plot.add_cursor(10.0, plot::Color::hex(0x00FF00)); // far from the region
|
|
const u32 rid = f.plot.add_region(free_region(40.0, 60.0));
|
|
const f32 left = bound_x(f.plot, rid, true);
|
|
const f32 right = bound_x(f.plot, rid, false);
|
|
const f32 mid = 0.5f * (left + right);
|
|
PLOT_CHECK(right > left);
|
|
|
|
hover(f.controller, left);
|
|
PLOT_CHECK(f.controller.hovered_cursor() == plot::kInvalid);
|
|
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Left);
|
|
press(f.controller, left);
|
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::ResizeRegionLeft);
|
|
PLOT_CHECK(f.controller.grabbed_region() == rid);
|
|
release(f.controller, left);
|
|
|
|
const f64 width = f.plot.region(rid).x1 - f.plot.region(rid).x0;
|
|
press(f.controller, mid);
|
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveRegion);
|
|
move_to(f.controller, mid + 40.0f);
|
|
PLOT_CHECK_NEAR(f.plot.region(rid).x1 - f.plot.region(rid).x0, width, 1e-9); // moved, same width
|
|
release(f.controller, mid + 40.0f);
|
|
(void)cid;
|
|
}
|
|
|
|
/// The affordance is honest: the furthest press that *hovers* the cursor is exactly the
|
|
/// furthest that *grabs* it, because hover and grab read the one radius (`cursor_at()`).
|
|
/// One pixel past it neither does -- so there is no band where the highlight promises a grab
|
|
/// the press then refuses.
|
|
PLOT_TEST(hover_and_grab_use_the_same_radius) {
|
|
Fixture f;
|
|
const u32 cid = f.plot.add_cursor(50.0, plot::Color::hex(0x00FF00));
|
|
const f32 marker = screen_x_of(f.plot, f.ax, 50.0);
|
|
|
|
f32 radius = -1.0f;
|
|
for (f32 d = 0.0f; d <= 20.0f; d += 1.0f) {
|
|
hover(f.controller, marker + d);
|
|
if (f.controller.hovered_cursor() != cid) break;
|
|
radius = d;
|
|
}
|
|
PLOT_CHECK(radius >= 6.0f && radius <= 8.0f); // the reliable-grab range the brief asks for
|
|
|
|
press(f.controller, marker + radius);
|
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor);
|
|
PLOT_CHECK(f.controller.grabbed_cursor() == cid);
|
|
release(f.controller, marker + radius);
|
|
|
|
hover(f.controller, marker + radius + 1.0f);
|
|
PLOT_CHECK(f.controller.hovered_cursor() == plot::kInvalid);
|
|
press(f.controller, marker + radius + 1.0f);
|
|
PLOT_CHECK(f.controller.grabbed_cursor() == plot::kInvalid);
|
|
release(f.controller, marker + radius + 1.0f);
|
|
}
|
|
|
|
/// Spec §14 on the real pointer path: a horizontal ruler is grabbed by the same hit test as a
|
|
/// marker -- no modifier, the same radius -- and the drag writes the coordinate its own
|
|
/// orientation owns. The ruler's y follows the pointer's row; its x stays the dead field it is;
|
|
/// a vertical cursor elsewhere on the plot is not disturbed; and the view does not pan.
|
|
PLOT_TEST(horizontal_ruler_is_grabbed_and_dragged_along_y) {
|
|
Fixture f;
|
|
f.ay = f.plot.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
|
|
const u32 marker = f.plot.add_cursor(50.0, plot::Color::hex(0x00FF00));
|
|
const u32 ruler = f.plot.add_h_ruler(40.0, plot::Color::hex(0x35D0A8));
|
|
f.plot.layout();
|
|
const f32 column = screen_x_of(f.plot, f.ax, 70.0); // clear of the marker's own column
|
|
const f32 row = row_of(f.plot, f.ay, 40.0);
|
|
|
|
// Hover first: what the renderer highlights and what a press grabs are one answer.
|
|
hover(f.controller, column, row);
|
|
PLOT_CHECK(f.controller.hovered_cursor() == ruler);
|
|
PLOT_CHECK(f.plot.hot_handles().cursor == ruler);
|
|
|
|
press(f.controller, column, row);
|
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor);
|
|
PLOT_CHECK(f.controller.grabbed_cursor() == ruler);
|
|
PLOT_CHECK(f.plot.hot_handles().grabbed_cursor == ruler);
|
|
|
|
// Up is more y: the ruler follows the pointer's row through its own y axis.
|
|
move_to(f.controller, column, row - 60.0f);
|
|
PLOT_CHECK_NEAR(f.plot.cursor(ruler).y, data_at_row(f.plot, f.ay, row - 60.0f), 1e-9);
|
|
PLOT_CHECK(f.plot.cursor(ruler).y > 40.0);
|
|
// The coordinate the ruler does not own, and the marker that owns it, are both untouched.
|
|
PLOT_CHECK_NEAR(f.plot.cursor(ruler).x, 0.0, 1e-12);
|
|
PLOT_CHECK_NEAR(f.plot.cursor(marker).x, 50.0, 1e-12);
|
|
// The gesture belonged to the ruler, not to the view: no range moved.
|
|
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 0.0, 1e-9);
|
|
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), 100.0, 1e-9);
|
|
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, 0.0, 1e-9);
|
|
|
|
release(f.controller, column, row - 60.0f);
|
|
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);
|
|
// And it stays where the drag left it.
|
|
PLOT_CHECK(f.plot.cursor(ruler).y > 40.0);
|
|
}
|
|
|
|
/// An inverted (descending) y range is ordinary -- depth, row index, descending time -- because
|
|
/// `set_y_range()` stores the range verbatim and only the transform widens it. The drag's clamp
|
|
/// must therefore order the bounds: `std::clamp` requires `min <= max`, so a raw
|
|
/// `clamp(y, range.min, range.max)` collapses the ruler onto one end on such an axis (and is
|
|
/// undefined behaviour besides). The ruler has to follow the pointer here, and stay inside the
|
|
/// range.
|
|
PLOT_TEST(horizontal_ruler_follows_the_pointer_on_an_inverted_y_range) {
|
|
Fixture f;
|
|
// Descending: the layout maps `range.min` (100) to the axes' bottom and `range.max` (0) to its
|
|
// top, so a pointer further down is a *larger* y.
|
|
f.ay = f.plot.add_y_axis({"v", {100.0, 0.0}, false, plot::AxisPlace::Left, false});
|
|
const u32 ruler = f.plot.add_h_ruler(50.0, plot::Color::hex(0x35D0A8));
|
|
f.plot.layout();
|
|
const f32 column = screen_x_of(f.plot, f.ax, 50.0);
|
|
const f32 row = row_of(f.plot, f.ay, 50.0);
|
|
|
|
hover(f.controller, column, row);
|
|
PLOT_CHECK(f.controller.hovered_cursor() == ruler);
|
|
press(f.controller, column, row);
|
|
PLOT_CHECK(f.controller.grabbed_cursor() == ruler);
|
|
|
|
const f32 moved = row + 60.0f;
|
|
move_to(f.controller, column, moved);
|
|
// Exactly the data under the pointer, not a collapsed bound, and inside the range.
|
|
PLOT_CHECK_NEAR(f.plot.cursor(ruler).y, data_at_row(f.plot, f.ay, moved), 1e-9);
|
|
PLOT_CHECK(f.plot.cursor(ruler).y > 50.0);
|
|
PLOT_CHECK(f.plot.cursor(ruler).y < 100.0);
|
|
release(f.controller, column, moved);
|
|
PLOT_CHECK(f.plot.cursor(ruler).y > 50.0);
|
|
}
|
|
|
|
/// The horizontal half of `cursor_snaps_when_the_drag_ends`: an `HRulerSnap::NearestTick` ruler
|
|
/// dragged through the controller writes the free pointer row for the whole gesture and lands on
|
|
/// a y tick only on release -- the snap-on-drop a marker now uses, which a ruler did *not* before
|
|
/// (it snapped continuously inside `set_cursor_y`). This is the case that catches a regression
|
|
/// to that continuous behaviour: mid-drag the stored y is on no tick and equals the pointer's row,
|
|
/// and only the drop moves it to the grid.
|
|
PLOT_TEST(a_dragged_nearest_tick_ruler_snaps_only_when_the_drag_ends) {
|
|
Fixture f;
|
|
f.ay = f.plot.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
|
|
const u32 ruler = f.plot.add_h_ruler(40.0, plot::Color::hex(0x35D0A8));
|
|
f.plot.set_cursor_h_snap(ruler, plot::HRulerSnap::NearestTick);
|
|
f.plot.layout();
|
|
|
|
const std::vector<plot::Tick>& ticks = f.plot.y_axis(f.ay).ticks;
|
|
PLOT_CHECK(ticks.size() >= 3u);
|
|
// A row between two adjacent ticks, nearer the lower-valued one (the larger screen row).
|
|
const f64 lo = ticks[1].value;
|
|
const f64 hi = ticks[2].value;
|
|
PLOT_CHECK(lo < hi);
|
|
const f64 between = lo + 0.4 * (hi - lo); // equal to no tick, nearer `lo`
|
|
const auto is_tick = [&ticks](f64 y) {
|
|
for (const plot::Tick& t : ticks) {
|
|
if (std::fabs(t.value - y) < 1e-9) return true;
|
|
}
|
|
return false;
|
|
};
|
|
PLOT_CHECK(!is_tick(between));
|
|
|
|
const f32 column = screen_x_of(f.plot, f.ax, 70.0);
|
|
const f32 to = static_cast<f32>(row_of(f.plot, f.ay, between));
|
|
const f32 from = static_cast<f32>(row_of(f.plot, f.ay, f.plot.cursor(ruler).y));
|
|
|
|
press(f.controller, column, from);
|
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor);
|
|
PLOT_CHECK(f.controller.grabbed_cursor() == ruler);
|
|
move_to(f.controller, column, to);
|
|
// Mid-drag the stored y is the free pointer row and on no tick: the snap has not run.
|
|
PLOT_CHECK_NEAR(f.plot.cursor(ruler).y, data_at_row(f.plot, f.ay, to), 1e-9);
|
|
PLOT_CHECK(!is_tick(f.plot.cursor(ruler).y));
|
|
|
|
// The drop commits the snap: the stored y is the nearest tick and it was not before.
|
|
release(f.controller, column, to);
|
|
PLOT_CHECK_NEAR(f.plot.cursor(ruler).y, lo, 1e-9);
|
|
PLOT_CHECK(is_tick(f.plot.cursor(ruler).y));
|
|
}
|
|
|
|
/// Nearest-first resolution *across* orientations: a pointer within reach of both a marker and a
|
|
/// ruler takes the nearer of the two, so neither widens the other's target -- which is what lets
|
|
/// one hit test serve two instruments. Each press below is 1 px from its winner and 4 px from the
|
|
/// other, both inside the logical grab radius. What the winner's drag writes is its own
|
|
/// coordinate and nothing else.
|
|
PLOT_TEST(vertical_and_horizontal_cursors_do_not_confuse_each_other) {
|
|
Fixture f;
|
|
f.ay = f.plot.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
|
|
const u32 marker = f.plot.add_cursor(50.0, plot::Color::hex(0x00FF00));
|
|
const u32 ruler = f.plot.add_h_ruler(40.0, plot::Color::hex(0x35D0A8));
|
|
f.plot.layout();
|
|
const f32 marker_x = screen_x_of(f.plot, f.ax, 50.0);
|
|
const f32 ruler_y = row_of(f.plot, f.ay, 40.0);
|
|
const f32 near = 1.0f;
|
|
const f32 far = 4.0f;
|
|
|
|
// Nearer the marker: the point is on both, and the marker is what hovers and what a press
|
|
// grabs -- the ruler's y does not move with the drag that follows.
|
|
const f32 marker_side_x = marker_x + near;
|
|
const f32 ruler_side_y = ruler_y + far;
|
|
hover(f.controller, marker_side_x, ruler_side_y);
|
|
PLOT_CHECK(f.controller.hovered_cursor() == marker);
|
|
press(f.controller, marker_side_x, ruler_side_y);
|
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor);
|
|
PLOT_CHECK(f.controller.grabbed_cursor() == marker);
|
|
move_to(f.controller, marker_side_x + 40.0f, ruler_side_y);
|
|
PLOT_CHECK_NEAR(f.plot.cursor(marker).x, data_at(f.plot, f.ax, marker_side_x + 40.0f), 1e-9);
|
|
PLOT_CHECK_NEAR(f.plot.cursor(ruler).y, 40.0, 1e-12);
|
|
PLOT_CHECK_NEAR(f.plot.cursor(ruler).x, 0.0, 1e-12);
|
|
release(f.controller, marker_side_x + 40.0f, ruler_side_y);
|
|
|
|
// The symmetric case: the marker put back under the pointer, and the press 1 px from the ruler
|
|
// and 4 px from the marker. The ruler takes it, and its y alone moves.
|
|
f.plot.set_cursor_x(marker, data_at(f.plot, f.ax, marker_x));
|
|
const f32 near_ruler_x = marker_x + far;
|
|
const f32 near_ruler_y = ruler_y - near;
|
|
hover(f.controller, near_ruler_x, near_ruler_y);
|
|
PLOT_CHECK(f.controller.hovered_cursor() == ruler);
|
|
press(f.controller, near_ruler_x, near_ruler_y);
|
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor);
|
|
PLOT_CHECK(f.controller.grabbed_cursor() == ruler);
|
|
const f64 marker_before = f.plot.cursor(marker).x;
|
|
move_to(f.controller, near_ruler_x, near_ruler_y + 30.0f);
|
|
PLOT_CHECK_NEAR(f.plot.cursor(ruler).y, data_at_row(f.plot, f.ay, near_ruler_y + 30.0f), 1e-9);
|
|
PLOT_CHECK_NEAR(f.plot.cursor(marker).x, marker_before, 1e-12);
|
|
release(f.controller, near_ruler_x, near_ruler_y + 30.0f);
|
|
PLOT_CHECK(f.plot.cursor(ruler).y < 40.0);
|
|
}
|
|
|
|
/// The other half of "a superset of the old gesture": a press that is on no handle
|
|
/// reaches the pan exactly as it did before, with and without ctrl, and neither the
|
|
/// cursor nor the region under the view is disturbed.
|
|
PLOT_TEST(pressing_away_from_a_handle_still_pans) {
|
|
Fixture f;
|
|
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}) {
|
|
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;
|
|
press(f.controller, px, 200.0f, mods);
|
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::Pan);
|
|
PLOT_CHECK(f.controller.grabbed_cursor() == plot::kInvalid);
|
|
PLOT_CHECK(f.controller.grabbed_region() == plot::kInvalid);
|
|
PLOT_CHECK(f.plot.hot_handles().cursor == plot::kInvalid);
|
|
PLOT_CHECK(f.plot.hot_handles().region == plot::kInvalid);
|
|
// The pan is driven by the event's own delta, so this motion has to report one:
|
|
// a host that sends an absolute position with dx = 0 has not moved the drag.
|
|
f.controller.on_pointer(
|
|
{px + 70.0f, 200.0f, 70.0f, 0.0f, plot::input::Button::Left, mods, true, false, false});
|
|
release(f.controller, px + 70.0f, 200.0f, mods);
|
|
// Grab-and-drag, the invariant the pan has always held: the data under the press
|
|
// is under the pointer afterwards.
|
|
PLOT_CHECK_NEAR(data_at(f.plot, f.ax, px + 70.0f), grabbed, 1e-9);
|
|
PLOT_CHECK(f.plot.x_range(f.ax).min < min_before);
|
|
PLOT_CHECK_NEAR(f.plot.cursor(cid).x, 20.0, 1e-9);
|
|
PLOT_CHECK_NEAR(f.plot.region(rid).x0, 40.0, 1e-9);
|
|
PLOT_CHECK_NEAR(f.plot.region(rid).x1, 60.0, 1e-9);
|
|
}
|
|
}
|
|
|
|
/// Nearest-first resolution: a bound within the grab radius beats the body it sits
|
|
/// inside, and the body answers only away from both bounds. The bounds are *inside*
|
|
/// the fill, so a body-first hit test would report `Body` here and a region could
|
|
/// never be resized.
|
|
PLOT_TEST(region_edge_wins_over_region_body) {
|
|
Fixture f;
|
|
const u32 rid = f.plot.add_region(free_region(30.0, 70.0));
|
|
const f32 left = bound_x(f.plot, rid, true);
|
|
const f32 right = bound_x(f.plot, rid, false);
|
|
PLOT_CHECK(left >= 0.0f && right > left);
|
|
const f32 mid = 0.5f * (left + right);
|
|
|
|
// No pointer seen yet: nothing is on anything.
|
|
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::None);
|
|
|
|
hover(f.controller, mid);
|
|
PLOT_CHECK(f.controller.hovered_region() == rid);
|
|
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Body);
|
|
PLOT_CHECK(f.plot.hot_handles().region == rid);
|
|
PLOT_CHECK(f.plot.hot_handles().zone == plot::RegionGrab::Body);
|
|
|
|
hover(f.controller, left);
|
|
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Left);
|
|
PLOT_CHECK(f.plot.hot_handles().zone == plot::RegionGrab::Left);
|
|
hover(f.controller, right);
|
|
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Right);
|
|
PLOT_CHECK(f.plot.hot_handles().zone == plot::RegionGrab::Right);
|
|
// A couple of pixels inside the bound is still the bound, not the body.
|
|
hover(f.controller, left + 2.0f);
|
|
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Left);
|
|
// Just outside the fill nothing is hot, so the `Body` answer above is not an
|
|
// accident of treating a wide neighbourhood as the region.
|
|
hover(f.controller, left - 12.0f);
|
|
PLOT_CHECK(f.controller.hovered_region() == plot::kInvalid);
|
|
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::None);
|
|
|
|
// Each bound opens the drag that moves that bound; the body opens the one that
|
|
// moves the interval.
|
|
press(f.controller, left);
|
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::ResizeRegionLeft);
|
|
PLOT_CHECK(f.controller.grabbed_region() == rid);
|
|
release(f.controller, left);
|
|
press(f.controller, right);
|
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::ResizeRegionRight);
|
|
release(f.controller, right);
|
|
press(f.controller, mid);
|
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveRegion);
|
|
release(f.controller, mid);
|
|
PLOT_CHECK(f.controller.grabbed_region() == plot::kInvalid);
|
|
|
|
// A cursor marker is hit-tested before any region, so a press on it is still the
|
|
// cursor's drag.
|
|
const u32 cid = f.plot.add_cursor(50.0, plot::Color::hex(0x00FF00));
|
|
const f32 marker = screen_x_of(f.plot, f.ax, 50.0);
|
|
hover(f.controller, marker);
|
|
// The region is under the pointer too -- the marker sits inside its interval, so the
|
|
// region's answer is `Body` -- and the cursor still wins the drag: the two hit tests
|
|
// are separate, and the cursor's is the one that decides the gesture.
|
|
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Body);
|
|
PLOT_CHECK(f.controller.hovered_region() == rid);
|
|
press(f.controller, marker);
|
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor);
|
|
PLOT_CHECK(f.controller.grabbed_cursor() == cid);
|
|
PLOT_CHECK(f.controller.grabbed_region() == plot::kInvalid);
|
|
release(f.controller, marker);
|
|
}
|
|
|
|
/// A body drag translates both bounds by the *same* data delta -- the width is
|
|
/// preserved exactly -- and each motion is a function of the pointer rather than a
|
|
/// step from the last one, so a pointer that returns to where it started returns the
|
|
/// region there too even when the host's reported deltas say otherwise.
|
|
PLOT_TEST(region_body_move_preserves_width) {
|
|
Fixture f;
|
|
const u32 rid = f.plot.add_region(free_region(30.0, 70.0));
|
|
const f64 width = f.plot.region(rid).x1 - f.plot.region(rid).x0;
|
|
const f32 mid = 0.5f * (bound_x(f.plot, rid, true) + bound_x(f.plot, rid, false));
|
|
|
|
press(f.controller, mid);
|
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveRegion);
|
|
PLOT_CHECK(f.controller.grabbed_region() == rid);
|
|
move_to(f.controller, mid + 120.0f);
|
|
const f64 delta = data_at(f.plot, f.ax, mid + 120.0f) - data_at(f.plot, f.ax, mid);
|
|
PLOT_CHECK_NEAR(f.plot.region(rid).x0, 30.0 + delta, 1e-9);
|
|
PLOT_CHECK_NEAR(f.plot.region(rid).x1, 70.0 + delta, 1e-9);
|
|
PLOT_CHECK_NEAR(f.plot.region(rid).x1 - f.plot.region(rid).x0, width, 1e-9);
|
|
PLOT_CHECK(f.plot.region(rid).x0 > 30.0);
|
|
|
|
// The deltas here are a lie -- 0, 0 while the pointer moved back -- and the bounds
|
|
// are rebuilt from the pointer's own coordinate, so nothing accumulates.
|
|
f.controller.on_pointer({mid, 200.0f, 0.0f, 0.0f, plot::input::Button::Left, 0u, true, false, false});
|
|
PLOT_CHECK_NEAR(f.plot.region(rid).x0, 30.0, 1e-9);
|
|
PLOT_CHECK_NEAR(f.plot.region(rid).x1, 70.0, 1e-9);
|
|
release(f.controller, mid);
|
|
}
|
|
|
|
/// A bound dragged past its partner *swaps* the interval rather than inverting or
|
|
/// clamping it: the store keeps (min, max), so the region always has a positive
|
|
/// width, and the pointer keeps hold of the bound it grabbed -- which, having crossed
|
|
/// over, is now the other one.
|
|
PLOT_TEST(region_bounds_stay_ordered_when_dragged_past_each_other) {
|
|
Fixture f;
|
|
const u32 rid = f.plot.add_region(free_region(30.0, 70.0));
|
|
const f32 right = bound_x(f.plot, rid, false);
|
|
|
|
// Drag the right bound well past the left one.
|
|
const f32 to = column_of(f.plot, f.ax, 12.0);
|
|
const f64 want = data_at(f.plot, f.ax, to);
|
|
PLOT_CHECK(want < 30.0); // the drag really does cross the other bound
|
|
press(f.controller, right);
|
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::ResizeRegionRight);
|
|
move_to(f.controller, to);
|
|
PLOT_CHECK_NEAR(f.plot.region(rid).x0, want, 1e-9); // the dragged bound, now the lower one
|
|
PLOT_CHECK_NEAR(f.plot.region(rid).x1, 30.0, 1e-9); // the one it was dragged past
|
|
PLOT_CHECK(f.plot.region(rid).x0 < f.plot.region(rid).x1);
|
|
|
|
// The grabbed bound is still the one being moved -- it is `x0` now, and the next
|
|
// motion moves it without the gesture having to be restarted.
|
|
const f32 back = column_of(f.plot, f.ax, 20.0);
|
|
move_to(f.controller, back);
|
|
PLOT_CHECK_NEAR(f.plot.region(rid).x0, data_at(f.plot, f.ax, back), 1e-9);
|
|
PLOT_CHECK_NEAR(f.plot.region(rid).x1, 30.0, 1e-9);
|
|
release(f.controller, back);
|
|
|
|
// And the mirror image: the left bound dragged past the right one.
|
|
f.plot.set_region(rid, 30.0, 70.0);
|
|
const f32 left = bound_x(f.plot, rid, true);
|
|
const f32 far = column_of(f.plot, f.ax, 90.0);
|
|
const f64 want_far = data_at(f.plot, f.ax, far);
|
|
press(f.controller, left);
|
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::ResizeRegionLeft);
|
|
move_to(f.controller, far);
|
|
PLOT_CHECK_NEAR(f.plot.region(rid).x0, 70.0, 1e-9);
|
|
PLOT_CHECK_NEAR(f.plot.region(rid).x1, want_far, 1e-9);
|
|
PLOT_CHECK(f.plot.region(rid).x0 < f.plot.region(rid).x1);
|
|
release(f.controller, far);
|
|
}
|
|
|
|
/// A region whose two bounds share a column cannot be grabbed by an edge -- an edge
|
|
/// would win the hit test every time and the region could never be moved again once
|
|
/// collapsed -- so it is grabbed by its body, which keeps it movable. The threshold is
|
|
/// the narrowest region whose bounds a pointer can tell apart: below it the body
|
|
/// answers, above it the bound does.
|
|
PLOT_TEST(collapsed_region_cannot_be_grabbed_by_an_edge) {
|
|
Fixture f;
|
|
const u32 rid = f.plot.add_region(free_region(50.0, 50.0));
|
|
const f32 col = bound_x(f.plot, rid, true);
|
|
PLOT_CHECK_NEAR(static_cast<f64>(col), static_cast<f64>(bound_x(f.plot, rid, false)), 1e-6);
|
|
hover(f.controller, col);
|
|
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Body);
|
|
press(f.controller, col);
|
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveRegion);
|
|
move_to(f.controller, col + 60.0f);
|
|
const f64 delta = data_at(f.plot, f.ax, col + 60.0f) - data_at(f.plot, f.ax, col);
|
|
PLOT_CHECK_NEAR(f.plot.region(rid).x0, 50.0 + delta, 1e-9);
|
|
PLOT_CHECK_NEAR(f.plot.region(rid).x1, 50.0 + delta, 1e-9); // moved, still collapsed
|
|
release(f.controller, col + 60.0f);
|
|
|
|
// Two pixels wide is still narrower than the minimum (3 logical px at scale 1): the
|
|
// same answer at either bound.
|
|
const u32 narrow = f.plot.add_region(free_region(data_at(f.plot, f.ax, 400.0f),
|
|
data_at(f.plot, f.ax, 402.0f)));
|
|
hover(f.controller, 400.0f);
|
|
PLOT_CHECK(f.controller.region_grab(narrow) == plot::RegionGrab::Body);
|
|
hover(f.controller, 401.0f);
|
|
PLOT_CHECK(f.controller.region_grab(narrow) == plot::RegionGrab::Body);
|
|
press(f.controller, 401.0f);
|
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveRegion);
|
|
release(f.controller, 401.0f);
|
|
|
|
// Six pixels is wide enough to tell the bounds apart, and there the bound wins.
|
|
const u32 wide = f.plot.add_region(free_region(data_at(f.plot, f.ax, 400.0f),
|
|
data_at(f.plot, f.ax, 406.0f)));
|
|
hover(f.controller, 400.0f);
|
|
PLOT_CHECK(f.controller.region_grab(wide) == plot::RegionGrab::Left);
|
|
hover(f.controller, 406.0f);
|
|
PLOT_CHECK(f.controller.region_grab(wide) == plot::RegionGrab::Right);
|
|
}
|
|
|
|
/// The shaded measurement band's two edges *are* the cursors that bound it, so
|
|
/// dragging one is the cursor drag with a different hit target: the grab maps to the
|
|
/// cursor owning that edge, the other edge stays where it was, and `Plot::measurement`
|
|
/// -- which the demo and the renderer both read -- follows as it already does.
|
|
PLOT_TEST(band_edge_drag_moves_the_owning_cursor_and_updates_the_measurement) {
|
|
constexpr u32 kSamples = 100u;
|
|
std::vector<f32> samples(kSamples);
|
|
for (u32 i = 0; i < kSamples; ++i) samples[i] = static_cast<f32>(i);
|
|
plot::data::ArraySeries s(std::move(samples), 0.0, 1.0);
|
|
|
|
Fixture f;
|
|
f.ay = f.plot.add_y_axis({"v", {0.0, 99.0}, false, plot::AxisPlace::Left, false});
|
|
const u32 tr = f.plot.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, f.ax, f.ay});
|
|
const u32 a = f.plot.add_cursor(10.0, plot::Color::hex(0x00FF00));
|
|
const u32 b = f.plot.add_cursor(60.0, plot::Color::hex(0xFF00FF));
|
|
const u32 m = f.plot.add_measure(a, b, tr);
|
|
PLOT_CHECK(f.plot.measurement(m).valid);
|
|
PLOT_CHECK_NEAR(f.plot.measurement(m).mean, 35.0, 1e-9); // samples 10..60
|
|
PLOT_CHECK_NEAR(f.plot.measurement(m).dx, 50.0, 1e-9);
|
|
PLOT_CHECK_NEAR(f.plot.measurement(m).ymax, 60.0, 1e-9);
|
|
|
|
// The band's left edge is cursor `a` at its own column, and the drag moves that
|
|
// cursor -- not the band, not the view. The pointer lands *between* two samples and
|
|
// the readout is over the nearest sample to each cursor, which is the measurement's
|
|
// documented rule: a cursor at 20.3 measures from the sample at x == 20.
|
|
const f32 edge_a = screen_x_of(f.plot, f.ax, 10.0);
|
|
const f32 to = column_of(f.plot, f.ax, 20.3);
|
|
press(f.controller, edge_a);
|
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor);
|
|
PLOT_CHECK(f.controller.grabbed_cursor() == a);
|
|
move_to(f.controller, to);
|
|
PLOT_CHECK_NEAR(f.plot.cursor(a).x, 20.3, 1e-3); // the bound followed the pointer
|
|
PLOT_CHECK_NEAR(f.plot.cursor(b).x, 60.0, 1e-9); // the other edge did not move
|
|
const plot::Measurement moved = f.plot.measurement(m);
|
|
PLOT_CHECK(moved.valid);
|
|
PLOT_CHECK_NEAR(moved.dx, 40.0, 1e-9); // samples 20..60: 41 of them, dx == 1
|
|
PLOT_CHECK_NEAR(moved.mean, 40.0, 1e-9);
|
|
PLOT_CHECK_NEAR(moved.ymin, 20.0, 1e-9);
|
|
PLOT_CHECK_NEAR(moved.ymax, 60.0, 1e-9);
|
|
PLOT_CHECK_NEAR(moved.rms, ramp_rms(20u, 60u), 1e-9);
|
|
PLOT_CHECK_NEAR(moved.integral, static_cast<f64>((20 + 60) * 41 / 2), 1e-9);
|
|
release(f.controller, to);
|
|
|
|
// The right edge is cursor `b`, and grabbing it moves `b` alone.
|
|
const f32 edge_b = screen_x_of(f.plot, f.ax, 60.0);
|
|
const f32 to_b = column_of(f.plot, f.ax, 50.3);
|
|
press(f.controller, edge_b);
|
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor);
|
|
PLOT_CHECK(f.controller.grabbed_cursor() == b);
|
|
move_to(f.controller, to_b);
|
|
PLOT_CHECK_NEAR(f.plot.cursor(b).x, 50.3, 1e-3);
|
|
PLOT_CHECK_NEAR(f.plot.cursor(a).x, 20.3, 1e-3);
|
|
const plot::Measurement narrowed = f.plot.measurement(m);
|
|
PLOT_CHECK(narrowed.valid);
|
|
PLOT_CHECK_NEAR(narrowed.dx, 30.0, 1e-9); // samples 20..50: 31 of them
|
|
PLOT_CHECK_NEAR(narrowed.mean, 35.0, 1e-9);
|
|
PLOT_CHECK_NEAR(narrowed.ymin, 20.0, 1e-9);
|
|
PLOT_CHECK_NEAR(narrowed.ymax, 50.0, 1e-9);
|
|
release(f.controller, to_b);
|
|
}
|
|
|
|
/// A region is geometry in *data* coordinates, and `XFollow::Live` recomputes the x window
|
|
/// on every layout -- so the two claims that matter under a live axis are that the region
|
|
/// stays exactly where it was put in data space while the view scrolls under it, and that
|
|
/// dragging one moves the region rather than being read as a change to the range. The
|
|
/// live path owns the range, so both halves are things it could plausibly break: a region
|
|
/// measured in pixels would drift with the window, and a drag that reached the range
|
|
/// setters would move the view *and* clear nothing good.
|
|
PLOT_TEST(region_holds_in_data_space_under_live_follow) {
|
|
LiveFixture f(40, 200); // a saturated ring: the window slides, its width does not
|
|
f.live();
|
|
f.graph.layout();
|
|
const f64 window_span = f.xr().span();
|
|
const f64 x0 = f.xr().min + 0.25 * window_span;
|
|
const f64 x1 = f.xr().min + 0.65 * window_span;
|
|
const u32 rid = f.graph.add_region(free_region(x0, x1));
|
|
PLOT_CHECK_NEAR(f.graph.region(rid).x0, x0, 1e-12);
|
|
PLOT_CHECK_NEAR(f.graph.region(rid).x1, x1, 1e-12);
|
|
const f32 left_before = bound_x(f.graph, rid, true);
|
|
const f32 right_before = bound_x(f.graph, rid, false);
|
|
PLOT_CHECK(left_before >= 0.0f && right_before > left_before);
|
|
|
|
// The window advances on its own, which is what the follow path is for.
|
|
const f64 newest_before = f.newest();
|
|
f.feed(3);
|
|
f.graph.layout();
|
|
PLOT_CHECK(f.xr().max > newest_before);
|
|
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
|
|
// The region's own interval is untouched, to the last bit ...
|
|
PLOT_CHECK_NEAR(f.graph.region(rid).x0, x0, 1e-12);
|
|
PLOT_CHECK_NEAR(f.graph.region(rid).x1, x1, 1e-12);
|
|
// ... while the pixels it is drawn in moved: the view scrolled under a region that
|
|
// stayed in data space.
|
|
PLOT_CHECK(bound_x(f.graph, rid, true) < left_before);
|
|
PLOT_CHECK(bound_x(f.graph, rid, false) < right_before);
|
|
|
|
// A body drag moves the region and nothing else: not the window, not the follow state.
|
|
const plot::Range held = f.xr();
|
|
const f32 mid = 0.5f * (bound_x(f.graph, rid, true) + bound_x(f.graph, rid, false));
|
|
const f32 to = mid + 40.0f;
|
|
const f64 delta = data_at(f.graph, f.ax, to) - data_at(f.graph, f.ax, mid);
|
|
press(f.controller, mid);
|
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveRegion);
|
|
PLOT_CHECK(f.controller.grabbed_region() == rid);
|
|
move_to(f.controller, to);
|
|
PLOT_CHECK_NEAR(f.graph.region(rid).x0, x0 + delta, 1e-9);
|
|
PLOT_CHECK_NEAR(f.graph.region(rid).x1, x1 + delta, 1e-9);
|
|
PLOT_CHECK_NEAR(f.xr().min, held.min, 1e-9); // the range the follow path owns is untouched
|
|
PLOT_CHECK_NEAR(f.xr().max, held.max, 1e-9);
|
|
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
|
|
release(f.controller, to);
|
|
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
|
|
PLOT_CHECK_NEAR(f.graph.region(rid).x0, x0 + delta, 1e-9);
|
|
PLOT_CHECK_NEAR(f.graph.region(rid).x1, x1 + delta, 1e-9);
|
|
// And the region is still where the pointer left it once more samples arrive: the
|
|
// window moves, the region does not.
|
|
f.feed(2);
|
|
f.graph.layout();
|
|
PLOT_CHECK(f.xr().max > held.max);
|
|
PLOT_CHECK_NEAR(f.graph.region(rid).x0, x0 + delta, 1e-12);
|
|
PLOT_CHECK_NEAR(f.graph.region(rid).x1, x1 + delta, 1e-12);
|
|
}
|
|
|
|
/// `RegionSnap::NearestSample` is applied **once**, on release -- not on every motion. A
|
|
/// body dragged a fraction of a sample would otherwise snap both ends back onto the
|
|
/// samples they started on, and the region would never move at all while the pointer did;
|
|
/// a region that never snapped would instead end its drag between two samples, which is
|
|
/// what the snap exists to prevent. Both halves are observable, and one drag shows both.
|
|
PLOT_TEST(region_snap_is_applied_once_on_release) {
|
|
constexpr u32 kSamples = 120u;
|
|
std::vector<f32> samples(kSamples);
|
|
for (u32 i = 0; i < kSamples; ++i) samples[i] = static_cast<f32>(i);
|
|
plot::data::ArraySeries s(std::move(samples), 0.0, 1.0); // the sample grid is the integers
|
|
|
|
Fixture f;
|
|
f.ay = f.plot.add_y_axis({"v", {0.0, 120.0}, false, plot::AxisPlace::Left, false});
|
|
f.plot.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, f.ax, f.ay});
|
|
|
|
plot::Region snapping; // the default: NearestSample
|
|
snapping.x0 = 10.4;
|
|
snapping.x1 = 20.6;
|
|
const u32 rid = f.plot.add_region(snapping);
|
|
// On the way in, both bounds land on the grid the traces define.
|
|
PLOT_CHECK_NEAR(f.plot.region(rid).x0, 10.0, 1e-9);
|
|
PLOT_CHECK_NEAR(f.plot.region(rid).x1, 21.0, 1e-9);
|
|
// The contrast: a Free region keeps what it was given. It is placed clear of the snapped
|
|
// one, because two bodies under the pointer resolve to the region on top -- the later
|
|
// one -- and this case is about the drag of *this* region.
|
|
plot::Region kept = snapping;
|
|
kept.x0 = 40.4;
|
|
kept.x1 = 50.6;
|
|
kept.snap = plot::RegionSnap::Free;
|
|
const u32 free_id = f.plot.add_region(kept);
|
|
PLOT_CHECK_NEAR(f.plot.region(free_id).x0, 40.4, 1e-12);
|
|
PLOT_CHECK_NEAR(f.plot.region(free_id).x1, 50.6, 1e-12);
|
|
|
|
// Drag the snapped region's body by six tenths of a sample.
|
|
const f32 mid = 0.5f * (bound_x(f.plot, rid, true) + bound_x(f.plot, rid, false));
|
|
const f32 to = column_of(f.plot, f.ax, data_at(f.plot, f.ax, mid) + 0.6);
|
|
press(f.controller, mid);
|
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveRegion);
|
|
move_to(f.controller, to);
|
|
// Mid-drag the bounds are exactly where the pointer put them: no snapping yet.
|
|
PLOT_CHECK_NEAR(f.plot.region(rid).x0, 10.6, 1e-3);
|
|
PLOT_CHECK_NEAR(f.plot.region(rid).x1, 21.6, 1e-3);
|
|
release(f.controller, to);
|
|
// On release they are pulled onto the grid, once, and both ends moved.
|
|
PLOT_CHECK_NEAR(f.plot.region(rid).x0, 11.0, 1e-9);
|
|
PLOT_CHECK_NEAR(f.plot.region(rid).x1, 22.0, 1e-9);
|
|
PLOT_CHECK(f.plot.region(rid).x1 > f.plot.region(rid).x0); // the interval survived both ends moving
|
|
}
|
|
|
|
/// A region the brief promises a pointer can still *move*: one whose bounds sit closer
|
|
/// together than the fixed grab radius allows on both sides. With that radius kept on each
|
|
/// bound, every point of a region narrower than twice it (14 logical px) lies within a
|
|
/// bound's reach, so the edge branch always wins and the interior is unreachable -- such a
|
|
/// region could be resized but never moved, and the collapsed-region rescue only engages
|
|
/// below 3 px. The bands are therefore cut back until the middle is left, while a region
|
|
/// wide enough for two full bands and an interior keeps the radius it always had.
|
|
PLOT_TEST(narrow_region_stays_movable_by_its_body) {
|
|
Fixture f;
|
|
const f32 radius = f.plot.style().device(7.0f); // the controller's own grab radius, logical
|
|
f32 column = 300.0f; // one strip of columns per region below
|
|
|
|
for (const f32 width_px : {4.0f, 6.0f, 9.0f}) {
|
|
const f64 a = data_at(f.plot, f.ax, column);
|
|
const f64 b = data_at(f.plot, f.ax, column + width_px);
|
|
const u32 rid = f.plot.add_region(free_region(a, b));
|
|
const f32 middle = column + 0.5f * width_px;
|
|
|
|
// Both bounds are grabbable: the edge still wins where the pointer is on it ...
|
|
hover(f.controller, column);
|
|
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Left);
|
|
hover(f.controller, column + width_px);
|
|
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Right);
|
|
// ... and the middle is the body, so the region can be moved as well as resized.
|
|
hover(f.controller, middle);
|
|
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Body);
|
|
press(f.controller, middle);
|
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveRegion);
|
|
PLOT_CHECK(f.controller.grabbed_region() == rid);
|
|
move_to(f.controller, middle + 30.0f);
|
|
const f64 delta = data_at(f.plot, f.ax, middle + 30.0f) - data_at(f.plot, f.ax, middle);
|
|
PLOT_CHECK_NEAR(f.plot.region(rid).x0, a + delta, 1e-9);
|
|
PLOT_CHECK_NEAR(f.plot.region(rid).x1, b + delta, 1e-9); // moved, width intact
|
|
release(f.controller, middle + 30.0f);
|
|
column += 80.0f;
|
|
}
|
|
|
|
// Where there is room the radius is unchanged: a press a few pixels inside a wide
|
|
// region's bound is that bound and not the body.
|
|
const u32 wide = f.plot.add_region(
|
|
free_region(data_at(f.plot, f.ax, 600.0f), data_at(f.plot, f.ax, 700.0f)));
|
|
hover(f.controller, 600.0f + radius - 1.0f);
|
|
PLOT_CHECK(f.controller.region_grab(wide) == plot::RegionGrab::Left);
|
|
press(f.controller, 600.0f + radius - 1.0f);
|
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::ResizeRegionLeft);
|
|
release(f.controller, 600.0f + radius - 1.0f);
|
|
}
|
|
|
|
/// `publish_hot()` is the single writer of `HotHandles`, and the wheel and the keyboard
|
|
/// change the geometry under a *stationary* pointer. Without a publish of their own, a
|
|
/// frame drawn right after a scroll would highlight a handle the pointer has left -- or
|
|
/// miss one it has just landed on -- until the next mouse move, which is exactly the
|
|
/// stale-highlight defect the publish exists to prevent. The layout has to be refreshed
|
|
/// with it: the region rects the hit test reads were measured for the range the zoom has
|
|
/// just replaced.
|
|
PLOT_TEST(hot_handles_follow_a_gesture_that_moves_the_geometry) {
|
|
Fixture f;
|
|
const u32 cid = f.plot.add_cursor(50.0, plot::Color::hex(0x00FF00));
|
|
const u32 off_centre = f.plot.add_cursor(75.0, plot::Color::hex(0xFF00FF));
|
|
const u32 rid = f.plot.add_region(free_region(70.0, 90.0));
|
|
const f32 radius = f.plot.style().device(7.0f);
|
|
|
|
// The pointer sits four pixels to the right of the marker: on it, and published as such.
|
|
const f32 px = screen_x_of(f.plot, f.ax, 50.0) + 4.0f;
|
|
hover(f.controller, px);
|
|
PLOT_CHECK(f.plot.hot_handles().cursor == cid);
|
|
|
|
// A zoom *in* about the pointer grows the marker's offset from it by the zoom factor --
|
|
// 4 px becomes 9.8 with six notches -- so the marker leaves without the pointer moving at
|
|
// all. Six rather than three: the grab radius is 7 px, and the point is a marker *out* of
|
|
// reach, so the zoom has to clear it comfortably.
|
|
f.controller.on_wheel({px, 200.0f, 0.0f, 6.0f, 0u});
|
|
PLOT_CHECK(std::fabs(screen_x_of(f.plot, f.ax, 50.0) - px) > radius);
|
|
PLOT_CHECK(f.controller.hovered_cursor() == plot::kInvalid);
|
|
PLOT_CHECK(f.plot.hot_handles().cursor == plot::kInvalid);
|
|
|
|
// And the other way round: the matching zoom out about the same column brings it back
|
|
// under the stationary pointer, where the published state has to be the marker again.
|
|
f.controller.on_wheel({px, 200.0f, 0.0f, -6.0f, 0u});
|
|
PLOT_CHECK(std::fabs(screen_x_of(f.plot, f.ax, 50.0) - px) <= radius);
|
|
PLOT_CHECK(f.controller.hovered_cursor() == cid);
|
|
PLOT_CHECK(f.plot.hot_handles().cursor == cid);
|
|
|
|
// The keyboard path: a zoom about the plot centre moves the *other* marker, which the
|
|
// pointer is on, out from under it.
|
|
const f32 on_marker = screen_x_of(f.plot, f.ax, 75.0);
|
|
hover(f.controller, on_marker);
|
|
PLOT_CHECK(f.plot.hot_handles().cursor == off_centre);
|
|
f.controller.on_key({plot::input::kKeyZoomIn, 0u, true});
|
|
PLOT_CHECK(std::fabs(screen_x_of(f.plot, f.ax, 75.0) - on_marker) > radius);
|
|
PLOT_CHECK(f.plot.hot_handles().cursor == plot::kInvalid);
|
|
|
|
// And a region bound: the zone that is published after the gesture is the zone the
|
|
// geometry now gives, not the one the bound had before the zoom.
|
|
const f32 bound = bound_x(f.plot, rid, true);
|
|
hover(f.controller, bound);
|
|
PLOT_CHECK(f.plot.hot_handles().region == rid);
|
|
PLOT_CHECK(f.plot.hot_handles().zone == plot::RegionGrab::Left);
|
|
f.controller.on_key({plot::input::kKeyZoomIn, 0u, true});
|
|
PLOT_CHECK(std::fabs(bound_x(f.plot, rid, true) - bound) > radius); // the bound moved away
|
|
PLOT_CHECK(f.plot.hot_handles().zone == f.controller.region_grab(rid));
|
|
PLOT_CHECK(f.plot.hot_handles().zone != plot::RegionGrab::Left);
|
|
}
|
|
|
|
/// A `Figure`'s shared x axis is the **bottom row's own**, so the axis zoom is
|
|
/// reachable there through the one hit test the controller has always used: the press
|
|
/// that lands in the bottom row's x label strip starts `AxisZoomX` exactly as it does on
|
|
/// a stand-alone plot, and because the rows share one x link the zoom moves all of them.
|
|
///
|
|
/// A figure that put its shared axis *outside* every row would leave this gesture
|
|
/// unreachable on every row at once: `x_label_area` would be zero-height, `contains()`
|
|
/// would never be true, and a middle drag would fall through to `DragMode::None`.
|
|
///
|
|
/// The other half is what makes that a statement about *which* row: the same drag on an
|
|
/// interior row -- at the point a label strip would land if that row had one -- is
|
|
/// deliberately not the gesture. The row reserved no x chrome, so there is no strip for
|
|
/// the press to be inside, and the zoom the bottom row's strip can start cannot be
|
|
/// started from a row that has none. If that were not so the shared axis would be one
|
|
/// strip among several, each of which could zoom the one range they all share.
|
|
PLOT_TEST(a_middle_drag_on_a_figures_bottom_row_zooms_the_shared_x_axis) {
|
|
plot::Figure figure(3u);
|
|
figure.set_viewport({0, 0, 800, 600});
|
|
for (u32 row = 0; row < 3u; ++row) {
|
|
plot::Plot& p = figure.plot(row);
|
|
p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
|
|
p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
|
}
|
|
const u32 group = figure.add_x_link();
|
|
for (u32 row = 0; row < 3u; ++row) PLOT_CHECK(figure.link_x(row, group));
|
|
const plot::Range before = figure.x_link_range(group);
|
|
|
|
figure.layout();
|
|
const plot::PlotLayout& bottom = figure.plot(2u).layout();
|
|
PLOT_CHECK(bottom.x_label_area.h > 0.0f);
|
|
const f32 strip_x = bottom.axes.x + 0.5f * bottom.axes.w;
|
|
const f32 strip_y = bottom.x_label_area.y + 0.5f * bottom.x_label_area.h;
|
|
PLOT_CHECK(bottom.x_label_area.contains(strip_x, strip_y));
|
|
|
|
plot::input::Controller c(figure.plot(2u));
|
|
c.set_viewport(figure.viewport());
|
|
c.on_pointer({strip_x, strip_y, 0, 0, plot::input::Button::Middle, 0, true, true, false});
|
|
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::AxisZoomX);
|
|
// Same sign and magnitude as on a single plot: a positive dy zooms in, 20 px being a
|
|
// tenth of a decade.
|
|
c.on_pointer({strip_x, strip_y + 20, 0, 20, plot::input::Button::Middle, 0, true, false, false});
|
|
c.on_pointer({strip_x, strip_y + 20, 0, 0, plot::input::Button::Middle, 0, false, false, true});
|
|
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None);
|
|
|
|
// The gesture reached the row's own axis...
|
|
PLOT_CHECK_NEAR(figure.plot(2u).x_range(0u).span(), 100.0 * std::pow(10.0, -20.0 / 200.0), 1e-6);
|
|
// ... and, through the link, every other row with it: one x axis, zoomed once.
|
|
figure.layout();
|
|
for (u32 row = 0; row < 3u; ++row) {
|
|
PLOT_CHECK_NEAR(figure.plot(row).x_range(0u).span(), figure.x_link_range(group).span(), 1e-9);
|
|
PLOT_CHECK_NEAR(figure.plot(row).x_range(0u).span(), 100.0 * std::pow(10.0, -20.0 / 200.0),
|
|
1e-6);
|
|
}
|
|
PLOT_CHECK(figure.x_link_range(group).span() < before.span());
|
|
|
|
// An interior row is not the same gesture. A press at the point a label strip *would*
|
|
// land in -- the band this row's chrome would occupy if the figure gave it one -- is
|
|
// not inside anything: the row reserved no x chrome, so its `x_label_area` is
|
|
// zero-height and no press can be inside it. The middle drag therefore falls through
|
|
// to `DragMode::None`, and because `update_drag()` does nothing in that mode the
|
|
// group's window -- the one range every row shares -- is untouched by it.
|
|
const plot::PlotLayout& interior = figure.plot(0u).layout();
|
|
PLOT_CHECK(interior.x_label_area.h == 0.0f);
|
|
const f32 interior_x = interior.axes.x + 0.5f * interior.axes.w;
|
|
const f32 interior_y = figure.layout().rows[0].bottom() - 0.5f * bottom.x_label_area.h;
|
|
PLOT_CHECK(!interior.x_label_area.contains(interior_x, interior_y));
|
|
const plot::Range shared_before = figure.x_link_range(group);
|
|
|
|
plot::input::Controller interior_controller(figure.plot(0u));
|
|
interior_controller.on_pointer(
|
|
{interior_x, interior_y, 0, 0, plot::input::Button::Middle, 0, true, true, false});
|
|
PLOT_CHECK(interior_controller.drag_mode() == plot::input::DragMode::None);
|
|
interior_controller.on_pointer(
|
|
{interior_x, interior_y + 20, 0, 20, plot::input::Button::Middle, 0, true, false, false});
|
|
interior_controller.on_pointer(
|
|
{interior_x, interior_y + 20, 0, 0, plot::input::Button::Middle, 0, false, false, true});
|
|
PLOT_CHECK(interior_controller.drag_mode() == plot::input::DragMode::None);
|
|
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).span(), shared_before.span(), 1e-12);
|
|
PLOT_CHECK_NEAR(figure.x_link_range(group).span(), shared_before.span(), 1e-12);
|
|
}
|
|
|
|
// The live edge a *link group* follows, seen by the input layer.
|
|
//
|
|
// A figure row's x axis is `XFollow::Off` by construction -- the group owns the shared
|
|
// window and its follow state, and `link_x()` refuses a `Live` member -- so before a
|
|
// following group published its edge, `Controller::x_zoom_anchor()` could not see that the
|
|
// axis was following at all. A wheel over the row's axis strip then anchored on the
|
|
// coordinate the pointer's x extrapolates to (the middle of the axes for a strip-centre
|
|
// pointer) rather than on the live edge, and the window walked back off the newest sample
|
|
// by a fifth of its width. These two cases pin the fix from the two ends: the gesture keeps
|
|
// the edge, and the hint that makes it possible exists exactly while a group follows.
|
|
PLOT_TEST(a_wheel_on_a_following_figures_axis_strip_keeps_the_live_edge) {
|
|
constexpr u32 kFigureRows = 3u;
|
|
constexpr f64 kWheelDx = 1.0 / 1000.0;
|
|
plot::data::StreamSeries series(1u << 12, 0.0, kWheelDx);
|
|
plot::f32 samples[512];
|
|
for (u32 i = 0; i < 512u; ++i) samples[i] = static_cast<plot::f32>(i % 7u);
|
|
series.push(samples, 512u);
|
|
series.acquire();
|
|
|
|
plot::Figure figure(kFigureRows);
|
|
figure.set_viewport({0.0f, 0.0f, 800.0f, 600.0f});
|
|
for (u32 row = 0; row < kFigureRows; ++row) {
|
|
plot::Plot& p = figure.plot(row);
|
|
p.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
|
|
p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
|
p.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, 0u, 0u, "s"});
|
|
PLOT_CHECK(p.trace_count() == 1u);
|
|
}
|
|
const u32 group = figure.add_x_link();
|
|
for (u32 row = 0; row < kFigureRows; ++row) PLOT_CHECK(figure.link_x(row, group));
|
|
PLOT_CHECK(figure.set_x_link_range(group, {0.0, 0.25}));
|
|
PLOT_CHECK(figure.set_x_link_follow(group, plot::XFollow::Live));
|
|
figure.layout();
|
|
|
|
const f64 newest = series.x_bounds().max;
|
|
PLOT_CHECK_NEAR(figure.x_link_range(group).max, newest, 1e-12);
|
|
for (u32 row = 0; row < kFigureRows; ++row) {
|
|
f64 edge = 0.0;
|
|
PLOT_CHECK(figure.plot(row).x_live_owner(0u, edge));
|
|
PLOT_CHECK_NEAR(edge, newest, 1e-12);
|
|
// The member's own axis is still Off: the group is what follows.
|
|
PLOT_CHECK(figure.plot(row).x_follow(0u) == plot::XFollow::Off);
|
|
}
|
|
|
|
const plot::PlotLayout& bottom = figure.plot(kFigureRows - 1u).layout();
|
|
const f32 strip_x = bottom.axes.x + 0.5f * bottom.axes.w;
|
|
const f32 strip_y = bottom.x_label_area.y + 0.5f * bottom.x_label_area.h;
|
|
PLOT_CHECK(bottom.x_label_area.contains(strip_x, strip_y));
|
|
const plot::Range before = figure.x_link_range(group);
|
|
|
|
plot::input::Controller c(figure.plot(kFigureRows - 1u));
|
|
c.set_viewport(figure.viewport());
|
|
const plot::input::WheelEvent wheel{strip_x, strip_y, 0.0f, 1.0f, 0u};
|
|
for (u32 i = 0; i < 3u; ++i) c.on_wheel(wheel);
|
|
figure.layout();
|
|
|
|
const plot::Range after = figure.x_link_range(group);
|
|
// The zoom reached the group's one window and narrowed it...
|
|
PLOT_CHECK(after.span() < before.span() * 0.99);
|
|
// ... and left it ending on the live edge, which is the anchor a gesture outside `axes`
|
|
// must use. Pre-fix this is `newest - 0.2 span` or so: the extrapolated anchor.
|
|
PLOT_CHECK_NEAR(after.max, newest, 1e-12);
|
|
for (u32 row = 0; row < kFigureRows; ++row) {
|
|
PLOT_CHECK_NEAR(figure.plot(row).x_range(0u).max, after.max, 1e-12);
|
|
PLOT_CHECK_NEAR(figure.plot(row).x_range(0u).min, after.min, 1e-12);
|
|
}
|
|
}
|
|
|
|
PLOT_TEST(a_figures_live_edge_hint_is_published_only_while_the_group_follows) {
|
|
constexpr u32 kFigureRows = 2u;
|
|
plot::data::StreamSeries series(1u << 10, 0.0, 1.0 / 100.0);
|
|
plot::f32 samples[64];
|
|
for (u32 i = 0; i < 64u; ++i) samples[i] = 1.0f;
|
|
series.push(samples, 64u);
|
|
series.acquire();
|
|
|
|
plot::Figure figure(kFigureRows);
|
|
figure.set_viewport({0.0f, 0.0f, 400.0f, 400.0f});
|
|
for (u32 row = 0; row < kFigureRows; ++row) {
|
|
plot::Plot& p = figure.plot(row);
|
|
p.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
|
|
p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
|
p.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, 0u, 0u, "s"});
|
|
}
|
|
const u32 group = figure.add_x_link();
|
|
for (u32 row = 0; row < kFigureRows; ++row) PLOT_CHECK(figure.link_x(row, group));
|
|
PLOT_CHECK(figure.set_x_link_range(group, {0.0, 0.5}));
|
|
PLOT_CHECK(figure.set_x_link_follow(group, plot::XFollow::Live));
|
|
figure.layout();
|
|
|
|
f64 edge = 0.0;
|
|
PLOT_CHECK(figure.plot(0u).x_live_owner(0u, edge));
|
|
PLOT_CHECK_NEAR(edge, series.x_bounds().max, 1e-12);
|
|
|
|
// Disarming takes the edge away at once -- nothing may anchor on an edge no group is
|
|
// following -- and leaves the range exactly where it was.
|
|
const plot::Range held = figure.x_link_range(group);
|
|
PLOT_CHECK(figure.set_x_link_follow(group, plot::XFollow::Off));
|
|
PLOT_CHECK(!figure.plot(0u).x_live_owner(0u, edge));
|
|
PLOT_CHECK_NEAR(figure.x_link_range(group).max, held.max, 1e-12);
|
|
|
|
// Arming publishes it again, and a row that leaves the group loses it.
|
|
PLOT_CHECK(figure.set_x_link_follow(group, plot::XFollow::Live));
|
|
figure.layout();
|
|
PLOT_CHECK(figure.plot(1u).x_live_owner(0u, edge));
|
|
PLOT_CHECK(figure.link_x(1u, 0u)); // back to the unlinked group
|
|
PLOT_CHECK(!figure.plot(1u).x_live_owner(0u, edge));
|
|
PLOT_CHECK(figure.plot(0u).x_live_owner(0u, edge)); // row 0 is still in the group
|
|
|
|
// A plot no figure owns is never claimed, `Live` or not: the hint is an owner's, and a
|
|
// single plot anchors on its own edge through `x_live_edge()`, which is unchanged.
|
|
plot::Plot solo;
|
|
solo.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
|
|
solo.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
|
solo.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, 0u, 0u, "s"});
|
|
solo.set_x_follow(0u, plot::XFollow::Live);
|
|
solo.layout();
|
|
PLOT_CHECK(solo.x_follow(0u) == plot::XFollow::Live);
|
|
PLOT_CHECK(!solo.x_live_owner(0u, edge));
|
|
}
|
|
|
|
// ------------------------------------------------- the crosshair (spec §15)
|
|
|
|
/// Spec §15, the wiring: a pointer over the data area places the crosshair at the data
|
|
/// coordinates under it, and a pointer that leaves the axes clears it. A press neither clears it
|
|
/// nor moves it away from the pointer -- it follows the pointer, and a pointer still over the
|
|
/// axes is still pointing at what it grabbed -- while a wheel, which moves the data without
|
|
/// moving the pointer, re-anchors it to the same pointer position rather than leaving it on the
|
|
/// value it was placed at.
|
|
PLOT_TEST(crosshair_tracks_motion_and_clears_on_leave) {
|
|
Fixture fx;
|
|
fx.plot.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
|
|
fx.plot.layout();
|
|
|
|
const plot::Rect axes = fx.plot.layout().axes;
|
|
const f32 cx = axes.x + 0.37f * axes.w;
|
|
const f32 cy = axes.y + 0.61f * axes.h;
|
|
|
|
// No pointer has been seen, so there is no crosshair: it is a hover, and a hover needs a
|
|
// pointer.
|
|
PLOT_CHECK(!fx.plot.crosshair().active);
|
|
|
|
// A motion inside the data area places it at the coordinates under the pointer, through the
|
|
// same transforms every gesture uses -- so the line, the value and the pointer cannot drift.
|
|
fx.controller.on_pointer({cx, cy, 0.0f, 0.0f, plot::input::Button::Left, 0u, false,
|
|
/*down=*/false, false});
|
|
PLOT_CHECK(fx.plot.crosshair().active);
|
|
PLOT_CHECK_NEAR(fx.plot.crosshair().x, fx.plot.x_transform(0u).to_data_x(cx), 1e-9);
|
|
PLOT_CHECK_NEAR(fx.plot.crosshair().y, fx.plot.y_transform(0u).to_data_y(cy), 1e-9);
|
|
// Both lines: a pointer over the axes knows its x and its y.
|
|
PLOT_CHECK(fx.plot.crosshair().vertical);
|
|
PLOT_CHECK(fx.plot.crosshair().horizontal);
|
|
|
|
// Moving re-anchors it, and a press keeps it: the pointer did not leave the axes.
|
|
const f32 moved_x = axes.x + 0.52f * axes.w;
|
|
fx.controller.on_pointer({moved_x, cy, moved_x - cx, 0.0f, plot::input::Button::Left, 0u, true,
|
|
true, false});
|
|
PLOT_CHECK(fx.plot.crosshair().active);
|
|
PLOT_CHECK_NEAR(fx.plot.crosshair().x, fx.plot.x_transform(0u).to_data_x(moved_x), 1e-9);
|
|
fx.controller.on_pointer({moved_x, cy, 0.0f, 0.0f, plot::input::Button::Left, 0u, false, false,
|
|
true});
|
|
PLOT_CHECK(fx.plot.crosshair().active);
|
|
|
|
// A pointer that leaves the data area clears it -- into the y label strip, where no data is
|
|
// under the pointer at all.
|
|
fx.controller.on_pointer({axes.x - 4.0f, cy, -4.0f, 0.0f, plot::input::Button::Left, 0u, false,
|
|
false, false});
|
|
PLOT_CHECK(fx.plot.layout().y_label_area.contains(axes.x - 4.0f, cy)); // the premise
|
|
PLOT_CHECK(!fx.plot.crosshair().active);
|
|
// ... and back inside re-places it, with the coordinate of the new position.
|
|
fx.controller.on_pointer({cx, cy, 0.0f, 0.0f, plot::input::Button::Left, 0u, false, false,
|
|
false});
|
|
PLOT_CHECK(fx.plot.crosshair().active);
|
|
PLOT_CHECK_NEAR(fx.plot.crosshair().x, fx.plot.x_transform(0u).to_data_x(cx), 1e-9);
|
|
|
|
// A wheel moved the data under a stationary pointer, so the crosshair is re-anchored: the
|
|
// value it reports is the one under the pointer *now*, not the one it was placed at.
|
|
const f64 before = fx.plot.crosshair().x;
|
|
fx.controller.on_wheel({cx, cy, 0.0f, 1.0f, 0u});
|
|
PLOT_CHECK(fx.plot.crosshair().active);
|
|
PLOT_CHECK_NEAR(fx.plot.crosshair().x, fx.plot.x_transform(0u).to_data_x(cx), 1e-9);
|
|
PLOT_CHECK(fx.plot.crosshair().x != before);
|
|
// The same for an axis zoom from the keyboard: the ranges move, the pointer does not.
|
|
const f64 after_wheel = fx.plot.crosshair().x;
|
|
fx.controller.on_key({plot::input::kKeyZoomOut, 0u, true});
|
|
PLOT_CHECK(fx.plot.crosshair().active);
|
|
PLOT_CHECK_NEAR(fx.plot.crosshair().x, fx.plot.x_transform(0u).to_data_x(cx), 1e-9);
|
|
PLOT_CHECK(fx.plot.crosshair().x != after_wheel);
|
|
|
|
// A resize moves the axes under a stationary pointer, so the crosshair is re-placed against
|
|
// the new rect -- and a resize that leaves the pointer outside the new axes clears it, the
|
|
// same rule as a pointer that left.
|
|
const f32 far_x = axes.right() - 2.0f;
|
|
fx.controller.on_pointer({far_x, cy, far_x - cx, 0.0f, plot::input::Button::Left, 0u, false,
|
|
false, false});
|
|
PLOT_CHECK(fx.plot.crosshair().active);
|
|
fx.controller.set_viewport({0.0f, 0.0f, 800.0f, 120.0f}); // the pointer is now below it
|
|
PLOT_CHECK(!fx.plot.layout().axes.contains(far_x, cy)); // the premise: the pointer is out
|
|
PLOT_CHECK(!fx.plot.crosshair().active);
|
|
// Restoring the rect puts the pointer back over the data, and the crosshair with it.
|
|
fx.controller.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
|
PLOT_CHECK(fx.plot.layout().axes.contains(far_x, cy));
|
|
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);
|
|
}
|