feat(plot)!: harden pipeline, XY series, dash/markers, touch, packaging
Breaking: Series::gather emits x relative to x0() and takes a GatherMode; consumers reconstruct absolute x in f64 before mapping (series.hpp documents the contract). Bugs, each pinned by a fail-before regression test: - render: f32 staging quantised epoch-time x into 128 px columns; every data-space draw now stages to_space(x) - to_space(origin) with the origin folded into the offset uniform in f64 (epoch/zero-origin twins rasterise identically) - ticks: major test was ULP-fragile and blanked epoch axes; majors are an integer-index test and are never truncated by the minor cap - gather: LOD envelope emitted buckets straddling [begin,end); ceil/floor bucket edges + raw lead/trail spans - live follow: log x axes held a linear span and zoomed every frame; span and offset now live in axis space (plot + both figure link-group paths) - input: a zero-notch wheel disarmed auto_scale on every axis; dy==0 pans x, factor == 1 / inf refused in wheel and drag zoom - render: the single text flush painted legend names over the crosshair readout panel; two batches, under flushed before the panel - data: NaN dropped from min/max unless it seeded the fold; non-finite samples are now excluded from every aggregate and from cached bounds Robustness: viewport normalisation (no clamp UB), non-finite range refusal, pyramid level cap at 57 (no shift/div UB through public gather), rounded ring capacity documented + pre-sized, GL blend func and UNPACK_ALIGNMENT save/restore, Renderer::release_gl() teardown, u64 buffer epochs, annotation captions clamped, button-aware GLFW row capture, cached figure row rects (no per-event layout), ruler-pair measurement refused, span-nonfinite guards in axis_ends. Features: XY series (sorted x, binary-search neighbours/snap, LOD over sample index); StreamSeries::freeze(); ArraySeries::push(); per-trace dash and markers (scatter at width 0) with auto or literal stride; GatherMode Envelope/Nearest/Mean via bucket_sum/sumsq; Style::line_aa_px; per-glyph text colour (readout values in trace colour); wheel-dx x pan; arrow-key pan; Escape cancels a drag and restores captured ranges; modifier-masked box zoom (Shift both, Ctrl x, Alt y); double-click reset via PointerEvent::time_s; ImGui key repeat unified; pinch gestures on Qt and GTK; pixelDelta wheel path and DevicePixelRatioChange handling; GNUInstallDirs, SOVERSION, SameMinorVersion, generated plot/version.hpp, relocatable plot.pc, licence install, FILES_MATCHING, GitHub Actions CI (debug/coverage/asan). Suite 283 -> 350 cases, 0 failed; coverage 93.9% (3730/3971) over 24 files, floor 80/90 held; ASAN preset suite green; all four demos run headless (the GTK scripted-resize check needs a window manager and fails identically on the parent commit); readme_main.cpp committed and compiles with the README command; pkg-config and find_package consumption verified from a relocated install prefix.
This commit is contained in:
1 parent
f132a5ec92
commit
38ef459897
77 files changed
+5905
-688
No files matched your search
+22
-4
@@ -32,6 +32,7 @@ add_executable(plot_tests
|
||||
test_ring_buffer.cpp
|
||||
test_minmax_pyramid.cpp
|
||||
test_series.cpp
|
||||
test_xy_series.cpp
|
||||
test_ticks.cpp
|
||||
test_measure.cpp
|
||||
test_plot.cpp
|
||||
@@ -77,8 +78,8 @@ target_include_directories(plot_tests PRIVATE "${CMAKE_SOURCE_DIR}/adapters")
|
||||
|
||||
# The optional runtime-font backend brings its own cases, and they exist only when
|
||||
# its target does: the header, the class and the archive are all absent otherwise,
|
||||
# so there would be nothing to compile them against (see PLOT_BUILD_TTF_FONT in the
|
||||
# top-level CMakeLists.txt). Guarded on the target rather than on the option, like
|
||||
# so there would be nothing to compile them against (see PLOT_BUILD_TTF_FONT in
|
||||
# cmake/plot_options.cmake). Guarded on the target rather than on the option, like
|
||||
# the adapter cases above, so a target that exists for any reason gets its cases.
|
||||
#
|
||||
# Two paths are handed over as definitions because a case needs them and should
|
||||
@@ -157,6 +158,18 @@ foreach(plot_header IN LISTS plot_public_headers)
|
||||
list(APPEND plot_header_check_sources "${plot_header_check_source}")
|
||||
endforeach()
|
||||
|
||||
# The generated version header is public (the umbrella names it) but is not
|
||||
# under the source include root -- the top-level CMakeLists.txt configures it
|
||||
# into <build>/include/plot/version.hpp -- so the glob above cannot see it. It
|
||||
# gets the same standalone-compile TU by hand, and reaches the header through
|
||||
# plot::core's build-tree include interface.
|
||||
file(GENERATE
|
||||
OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/header_checks/plot_version.cpp"
|
||||
CONTENT
|
||||
"// Generated by tests/CMakeLists.txt: <plot/version.hpp> must compile on its own.\n#include <plot/version.hpp>\n")
|
||||
list(APPEND plot_header_check_sources
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/header_checks/plot_version.cpp")
|
||||
|
||||
add_library(plot_header_self_containment OBJECT ${plot_header_check_sources})
|
||||
target_link_libraries(plot_header_self_containment PRIVATE plot::core)
|
||||
plot_apply_warnings(plot_header_self_containment)
|
||||
@@ -189,8 +202,12 @@ plot_apply_warnings(plot_umbrella_check)
|
||||
|
||||
# Coverage check on the umbrella. Included here so a public header missing from
|
||||
# plot.hpp stops the configure; the ctest entry below runs the same script again
|
||||
# so the check is also a visible test.
|
||||
# so the check is also a visible test. The generated version header is passed in
|
||||
# both times: the script must require it of the umbrella, and must look for the
|
||||
# file in the build tree where configure_file() put it.
|
||||
set(PLOT_INCLUDE_DIR "${CMAKE_SOURCE_DIR}/include")
|
||||
set(PLOT_GENERATED_INCLUDE_DIR "${CMAKE_BINARY_DIR}/include")
|
||||
set(PLOT_EXTRA_PUBLIC_HEADERS "plot/version.hpp")
|
||||
set(PLOT_UMBRELLA "${CMAKE_SOURCE_DIR}/include/plot/plot.hpp")
|
||||
include("${CMAKE_SOURCE_DIR}/cmake/plot_umbrella_check.cmake")
|
||||
|
||||
@@ -199,6 +216,7 @@ add_test(NAME plot_headers_self_contained
|
||||
add_test(NAME plot_umbrella_covers_public_headers
|
||||
COMMAND ${CMAKE_COMMAND}
|
||||
-DPLOT_INCLUDE_DIR=${CMAKE_SOURCE_DIR}/include
|
||||
-DPLOT_UMBRELLA=${CMAKE_SOURCE_DIR}/include/plot/plot.hpp
|
||||
-DPLOT_GENERATED_INCLUDE_DIR=${CMAKE_BINARY_DIR}/include
|
||||
-DPLOT_EXTRA_PUBLIC_HEADERS=plot/version.hpp
|
||||
-P "${CMAKE_SOURCE_DIR}/cmake/plot_umbrella_check.cmake")
|
||||
add_test(NAME plot_umbrella_check COMMAND plot_umbrella_check)
|
||||
+352
-24
@@ -187,21 +187,30 @@ PLOT_TEST(hovered_cursor_only_inside_plot_area) {
|
||||
PLOT_CHECK(c.hovered_cursor() == cid);
|
||||
}
|
||||
|
||||
PLOT_TEST(ctrl_drag_moves_the_nearest_cursor) {
|
||||
PLOT_TEST(ctrl_drag_box_zooms_x_only_even_over_a_cursor) {
|
||||
// The modifier table: Ctrl+Left is the x-only box zoom now -- it wins over the
|
||||
// cursor grab a plain press would open, and the release frames the x range from
|
||||
// the selection while the y range (and the cursor under the press) stay put.
|
||||
plot::Plot p;
|
||||
p.set_viewport({0, 0, 800, 400});
|
||||
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
|
||||
const u32 cid = p.add_cursor(20.0, plot::Color::hex(0xFF0000));
|
||||
const u32 ax = p.add_x_axis({"t", {0.0, 800.0}, false, plot::AxisPlace::Bottom, false});
|
||||
const u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||
const u32 cid = p.add_cursor(200.0, plot::Color::hex(0xFF0000));
|
||||
plot::input::Controller c(p);
|
||||
c.set_viewport({0, 0, 800, 400});
|
||||
const f32 sx = screen_x_of(p, ax, 20.0);
|
||||
const f32 sx = screen_x_of(p, ax, 200.0);
|
||||
const f64 want_lo = p.x_transform(ax).to_data_x(sx);
|
||||
const f64 want_hi = p.x_transform(ax).to_data_x(sx + 100.0f);
|
||||
c.on_pointer({sx, 200, 0, 0, plot::input::Button::Left, kCtrl, true, true, false});
|
||||
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::MoveCursor);
|
||||
c.on_pointer({sx + 100, 200, 100, 0, plot::input::Button::Left, kCtrl, true, false, false});
|
||||
c.on_pointer({sx + 100, 200, 0, 0, plot::input::Button::Left, kCtrl, false, false, true});
|
||||
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::BoxZoom);
|
||||
c.on_pointer({sx + 100, 260, 100, 60, plot::input::Button::Left, kCtrl, true, false, false});
|
||||
c.on_pointer({sx + 100, 260, 0, 0, plot::input::Button::Left, kCtrl, false, false, true});
|
||||
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None);
|
||||
PLOT_CHECK(p.cursor(cid).x > 20.0);
|
||||
PLOT_CHECK_NEAR(p.cursor(cid).x, p.x_transform(ax).to_data_x(sx + 100), 1e-9);
|
||||
PLOT_CHECK_NEAR(p.x_range(ax).min, want_lo, 1e-6);
|
||||
PLOT_CHECK_NEAR(p.x_range(ax).max, want_hi, 1e-6);
|
||||
PLOT_CHECK_NEAR(p.y_range(ay).min, -1.0, 1e-9); // the masked axis is untouched
|
||||
PLOT_CHECK_NEAR(p.y_range(ay).max, 1.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(p.cursor(cid).x, 200.0, 1e-9); // and no cursor was grabbed
|
||||
}
|
||||
|
||||
PLOT_TEST(middle_drag_on_x_strip_zooms_x_only) {
|
||||
@@ -391,9 +400,10 @@ PLOT_TEST(grab_radius_scales_with_dpi) {
|
||||
c.on_pointer({sx + outside, 200.0f * scale, 0, 0, plot::input::Button::Left, 0, false, false,
|
||||
false});
|
||||
PLOT_CHECK(c.hovered_cursor() == plot::kInvalid);
|
||||
// ...and a ctrl-drag at that same logical distance opens a cursor move, which is
|
||||
// the gesture the radius actually gates.
|
||||
c.on_pointer({sx + inside, 200.0f * scale, 0, 0, plot::input::Button::Left, kCtrl, true, true,
|
||||
// ...and a press at that same logical distance opens a cursor move, which is
|
||||
// the gesture the radius actually gates. (A plain press: Ctrl is the x-only
|
||||
// box zoom's mask now, not a cursor modifier.)
|
||||
c.on_pointer({sx + inside, 200.0f * scale, 0, 0, plot::input::Button::Left, 0, true, true,
|
||||
false});
|
||||
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::MoveCursor);
|
||||
}
|
||||
@@ -878,17 +888,9 @@ PLOT_TEST(cursor_is_grabbed_without_a_modifier) {
|
||||
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::None);
|
||||
PLOT_CHECK(f.controller.grabbed_cursor() == plot::kInvalid);
|
||||
PLOT_CHECK(f.plot.hot_handles().grabbed_cursor == plot::kInvalid);
|
||||
|
||||
// The same press with ctrl is the same gesture, not a different mode.
|
||||
const f32 moved_to = column_of(f.plot, f.ax, f.plot.cursor(cid).x);
|
||||
const f64 min_before = f.plot.x_range(f.ax).min;
|
||||
press(f.controller, moved_to, 200.0f, kCtrl);
|
||||
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor);
|
||||
PLOT_CHECK(f.controller.grabbed_cursor() == cid);
|
||||
move_to(f.controller, moved_to - 40.0f, 200.0f, kCtrl);
|
||||
PLOT_CHECK_NEAR(f.plot.cursor(cid).x, data_at(f.plot, f.ax, moved_to - 40.0f), 1e-9);
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, min_before, 1e-9); // still no pan
|
||||
release(f.controller, moved_to - 40.0f, 200.0f, kCtrl);
|
||||
// The grab is the *plain* press's gesture: Ctrl+Left is the x-only box zoom now
|
||||
// (see ctrl_drag_box_zooms_x_only_even_over_a_cursor), so there is no modifier
|
||||
// variant of a cursor grab to pin here.
|
||||
}
|
||||
|
||||
/// A snapping cursor follows the pointer for the whole drag: the drag writes the free
|
||||
@@ -1216,7 +1218,7 @@ PLOT_TEST(pressing_away_from_a_handle_still_pans) {
|
||||
const u32 cid = f.plot.add_cursor(20.0, plot::Color::hex(0x00FF00));
|
||||
const u32 rid = f.plot.add_region(free_region(40.0, 60.0));
|
||||
|
||||
for (const u32 mods : {0u, kCtrl}) {
|
||||
for (const u32 mods : {0u}) { // the plain press: Ctrl+Left is the box zoom now
|
||||
const f32 px = screen_x_of(f.plot, f.ax, 85.0);
|
||||
const f64 grabbed = data_at(f.plot, f.ax, px);
|
||||
const f64 min_before = f.plot.x_range(f.ax).min;
|
||||
@@ -1981,3 +1983,329 @@ PLOT_TEST(crosshair_tracks_motion_and_clears_on_leave) {
|
||||
PLOT_CHECK(fx.plot.crosshair().active);
|
||||
PLOT_CHECK_NEAR(fx.plot.crosshair().x, fx.plot.x_transform(0u).to_data_x(far_x), 1e-9);
|
||||
}
|
||||
|
||||
// --- hardening wave: B5 zero-notch wheel and dx pan, and the new gestures ---
|
||||
|
||||
namespace {
|
||||
|
||||
/// A plot with an x and a y axis, both auto-scaled, so every "did the gesture
|
||||
/// disarm the fit" question has an armed state to start from.
|
||||
struct ArmedFixture {
|
||||
plot::Plot plot;
|
||||
u32 ax = 0;
|
||||
u32 ay = 0;
|
||||
plot::input::Controller controller{plot};
|
||||
|
||||
ArmedFixture() {
|
||||
plot.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
||||
ax = plot.add_x_axis({"t", {0.0, 100.0}, true, plot::AxisPlace::Bottom, false});
|
||||
ay = plot.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
|
||||
controller.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
/// B5: a zero-notch wheel (`dy == 0 && dx == 0`, which hosts and trackpads really
|
||||
/// forward) changes nothing at all -- most importantly it does not run the range
|
||||
/// setters, which clear `auto_scale`, so one empty scroll must not disarm
|
||||
/// auto-fit.
|
||||
PLOT_TEST(zero_notch_wheel_disarms_nothing) {
|
||||
ArmedFixture f;
|
||||
f.controller.on_wheel({400.0f, 200.0f, 0.0f, 0.0f, 0});
|
||||
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale);
|
||||
PLOT_CHECK(f.plot.y_axis(f.ay).auto_scale);
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 0.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 100.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -1.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.0, 1e-9);
|
||||
}
|
||||
|
||||
/// B5: `dy == 0` with `dx != 0` pans x only -- 5% of the x span per notch, in axis
|
||||
/// space (log-aware), positive `dx` scrolling right -- and leaves the y axis,
|
||||
/// including its armed auto scale, exactly as it was.
|
||||
PLOT_TEST(horizontal_wheel_pans_x_only) {
|
||||
ArmedFixture f;
|
||||
f.controller.on_wheel({400.0f, 200.0f, 2.0f, 0.0f, 0}); // two notches right
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 10.0, 1e-9); // 2 * 5% of 100
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 110.0, 1e-9);
|
||||
PLOT_CHECK(!f.plot.x_axis(f.ax).auto_scale); // a pan is a user choice
|
||||
PLOT_CHECK(f.plot.y_axis(f.ay).auto_scale); // the untouched axis keeps its fit
|
||||
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -1.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.0, 1e-9);
|
||||
|
||||
// Log-aware: the pan runs in axis space, so one notch on a log axis is a
|
||||
// fraction of a *decade*, not of a linear width.
|
||||
plot::Plot p;
|
||||
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
||||
const u32 lg = p.add_x_axis({"f", {1.0, 100.0}, true, plot::AxisPlace::Bottom, true});
|
||||
plot::input::Controller c(p);
|
||||
c.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
||||
c.on_wheel({400.0f, 200.0f, 1.0f, 0.0f, 0});
|
||||
const plot::Range r = p.x_range(lg);
|
||||
PLOT_CHECK_NEAR(std::log10(r.max) - std::log10(r.min), 2.0, 1e-9); // decades kept
|
||||
PLOT_CHECK_NEAR(std::log10(r.max), 2.1, 1e-9); // one notch = 5% of the 2-decade span
|
||||
}
|
||||
|
||||
/// B5: a zoom whose factor could not be applied honestly -- non-finite (a huge
|
||||
/// downward `dy` explodes `exp`) -- is refused, and leaves the ranges *and* the
|
||||
/// armed auto scale exactly as they were.
|
||||
PLOT_TEST(wheel_zoom_refuses_a_non_finite_factor) {
|
||||
ArmedFixture f;
|
||||
f.controller.on_wheel({400.0f, 200.0f, 0.0f, -1e38f, 0});
|
||||
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale);
|
||||
PLOT_CHECK(f.plot.y_axis(f.ay).auto_scale);
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 100.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.0, 1e-9);
|
||||
}
|
||||
|
||||
/// B5: the middle-drag axis zoom carries the same guards. A motion that reports no
|
||||
/// `dy` is a factor of exactly one -- refused, so it cannot clear `auto_scale` on
|
||||
/// its way through the range setters -- and one that explodes the factor is
|
||||
/// refused too.
|
||||
PLOT_TEST(axis_zoom_refuses_degenerate_factors) {
|
||||
ArmedFixture f;
|
||||
const plot::PlotLayout& l = f.plot.layout();
|
||||
const f32 strip_x = l.axes.x + 0.5f * l.axes.w;
|
||||
const f32 strip_y = l.x_label_area.y + 0.5f * l.x_label_area.h;
|
||||
f.controller.on_pointer({strip_x, strip_y, 0, 0, plot::input::Button::Middle, 0, true, true,
|
||||
false});
|
||||
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::AxisZoomX);
|
||||
// A stationary motion: no zoom, and the fits stay armed.
|
||||
f.controller.on_pointer({strip_x, strip_y, 0, 0, plot::input::Button::Middle, 0, true, false,
|
||||
false});
|
||||
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale);
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), 100.0, 1e-9);
|
||||
// An exploding motion: refused, nothing stored, nothing disarmed.
|
||||
f.controller.on_pointer({strip_x, strip_y, 0, 1e38f, plot::input::Button::Middle, 0, true,
|
||||
false, false});
|
||||
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale);
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), 100.0, 1e-9);
|
||||
f.controller.on_pointer({strip_x, strip_y, 0, 0, plot::input::Button::Middle, 0, false, false,
|
||||
true});
|
||||
}
|
||||
|
||||
/// The arrow keys pan by axis class: Left/Right every x axis, Up/Down every y
|
||||
/// axis, 10% of that axis' own span per press, in axis space (log-aware). An arrow
|
||||
/// says where to look: Left walks the window toward smaller x, Up toward larger y.
|
||||
PLOT_TEST(arrow_keys_pan_a_tenth_of_the_span) {
|
||||
ArmedFixture f;
|
||||
f.plot.set_x_auto_scale(f.ax, false);
|
||||
f.controller.on_key({plot::input::kKeyRight, 0, true});
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 10.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 110.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -1.0, 1e-9); // x-only
|
||||
f.controller.on_key({plot::input::kKeyLeft, 0, true});
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 0.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 100.0, 1e-9);
|
||||
f.controller.on_key({plot::input::kKeyUp, 0, true});
|
||||
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -0.8, 1e-9); // +10% of the 2 span
|
||||
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.2, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 0.0, 1e-9); // y-only
|
||||
f.controller.on_key({plot::input::kKeyDown, 0, true});
|
||||
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -1.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.0, 1e-9);
|
||||
|
||||
// Log-aware: an arrow on a log axis moves decades, not units.
|
||||
plot::Plot p;
|
||||
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
||||
const u32 lg = p.add_x_axis({"f", {1.0, 100.0}, false, plot::AxisPlace::Bottom, true});
|
||||
p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||
plot::input::Controller c(p);
|
||||
c.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
||||
c.on_key({plot::input::kKeyRight, 0, true});
|
||||
const plot::Range r = p.x_range(lg);
|
||||
PLOT_CHECK_NEAR(std::log10(r.max) - std::log10(r.min), 2.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(std::log10(r.max), 2.2, 1e-9); // +10% of the 2-decade span
|
||||
|
||||
// A key with no gesture changes nothing.
|
||||
c.on_key({'q', 0, true});
|
||||
PLOT_CHECK_NEAR(std::log10(p.x_range(lg).max), 2.2, 1e-9);
|
||||
}
|
||||
|
||||
/// Escape cancels the active drag: the ranges (and auto-scale flags) the axes held
|
||||
/// at `begin_drag` are restored, and the drag is closed -- the release that
|
||||
/// follows applies nothing.
|
||||
PLOT_TEST(escape_cancels_a_pan_and_restores_the_captured_ranges) {
|
||||
ArmedFixture f;
|
||||
const f64 x_min = f.plot.x_range(f.ax).min;
|
||||
const f64 x_max = f.plot.x_range(f.ax).max;
|
||||
const f64 y_min = f.plot.y_range(f.ay).min;
|
||||
const f64 y_max = f.plot.y_range(f.ay).max;
|
||||
f.controller.on_pointer({100.0f, 100.0f, 0, 0, plot::input::Button::Left, 0, true, true,
|
||||
false});
|
||||
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::Pan);
|
||||
f.controller.on_pointer({300.0f, 200.0f, 200.0f, 100.0f, plot::input::Button::Left, 0, true,
|
||||
false, false});
|
||||
PLOT_CHECK(f.plot.x_range(f.ax).min < x_min); // the gesture did move things
|
||||
PLOT_CHECK(!f.plot.x_axis(f.ax).auto_scale);
|
||||
f.controller.on_key({plot::input::kKeyEscape, 0, true});
|
||||
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::None);
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, x_min, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, x_max, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, y_min, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, y_max, 1e-9);
|
||||
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale); // the fit is armed again
|
||||
PLOT_CHECK(f.plot.y_axis(f.ay).auto_scale);
|
||||
// The release arrives after the cancel; there is no gesture left to end.
|
||||
f.controller.on_pointer({300.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
|
||||
true});
|
||||
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::None);
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, x_min, 1e-9);
|
||||
}
|
||||
|
||||
/// Escape drops a box zoom without applying it: the selection is discarded and the
|
||||
/// ranges the axes held when the drag began are still there at the end.
|
||||
PLOT_TEST(escape_drops_a_box_zoom_without_applying) {
|
||||
ArmedFixture f;
|
||||
const f64 x_max = f.plot.x_range(f.ax).max;
|
||||
f.controller.on_pointer({100.0f, 50.0f, 0, 0, plot::input::Button::Left, kShift, true, true,
|
||||
false});
|
||||
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::BoxZoom);
|
||||
f.controller.on_pointer({300.0f, 150.0f, 200.0f, 100.0f, plot::input::Button::Left, kShift,
|
||||
true, false, false});
|
||||
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale); // nothing applies before the release
|
||||
f.controller.on_key({plot::input::kKeyEscape, 0, true});
|
||||
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::None);
|
||||
PLOT_CHECK_NEAR(f.controller.selection_rect().w, 0.0, 1e-9); // the box is gone
|
||||
f.controller.on_pointer({300.0f, 150.0f, 0, 0, plot::input::Button::Left, kShift, false,
|
||||
false, true});
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, x_max, 1e-9); // and nothing was framed
|
||||
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale);
|
||||
}
|
||||
|
||||
/// The box-zoom masks: Alt+Left frames the y range only -- from a selection too
|
||||
/// thin horizontally to be a two-axis box, which is what makes the threshold
|
||||
/// masked with the gesture.
|
||||
PLOT_TEST(alt_drag_box_zooms_y_only) {
|
||||
ArmedFixture f;
|
||||
constexpr u32 kAlt = static_cast<u32>(plot::input::Mod::Alt);
|
||||
const plot::Rect axes = f.plot.layout().axes;
|
||||
const f64 want_lo = f.plot.y_transform(f.ay).to_data_y(axes.y + 150.0f);
|
||||
const f64 want_hi = f.plot.y_transform(f.ay).to_data_y(axes.y + 50.0f);
|
||||
// Two logical px wide: an unmasked box would call this a click.
|
||||
f.controller.on_pointer({200.0f, axes.y + 50.0f, 0, 0, plot::input::Button::Left, kAlt, true,
|
||||
true, false});
|
||||
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::BoxZoom);
|
||||
f.controller.on_pointer({202.0f, axes.y + 150.0f, 2, 100, plot::input::Button::Left, kAlt,
|
||||
true, false, false});
|
||||
f.controller.on_pointer({202.0f, axes.y + 150.0f, 0, 0, plot::input::Button::Left, kAlt,
|
||||
false, false, true});
|
||||
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, want_lo, 1e-6);
|
||||
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, want_hi, 1e-6);
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 0.0, 1e-9); // x untouched
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 100.0, 1e-9);
|
||||
}
|
||||
|
||||
/// The Ctrl mask, applied at release from a selection that only makes sense
|
||||
/// horizontally: the x range is framed from the box while the y range is left
|
||||
/// alone, and a selection thin *vertically* is still a box under the mask.
|
||||
PLOT_TEST(ctrl_mask_frames_x_from_a_thin_horizontal_band) {
|
||||
ArmedFixture f;
|
||||
const plot::Rect axes = f.plot.layout().axes;
|
||||
const f64 want_lo = f.plot.x_transform(f.ax).to_data_x(axes.x + 100.0f);
|
||||
const f64 want_hi = f.plot.x_transform(f.ax).to_data_x(axes.x + 300.0f);
|
||||
f.controller.on_pointer({axes.x + 100.0f, 200.0f, 0, 0, plot::input::Button::Left, kCtrl,
|
||||
true, true, false});
|
||||
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::BoxZoom);
|
||||
// Two logical px tall: an unmasked box would call this a click.
|
||||
f.controller.on_pointer({axes.x + 300.0f, 202.0f, 200, 2, plot::input::Button::Left, kCtrl,
|
||||
true, false, false});
|
||||
f.controller.on_pointer({axes.x + 300.0f, 202.0f, 0, 0, plot::input::Button::Left, kCtrl,
|
||||
false, false, true});
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, want_lo, 1e-6);
|
||||
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, want_hi, 1e-6);
|
||||
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -1.0, 1e-9); // y untouched
|
||||
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.0, 1e-9);
|
||||
}
|
||||
|
||||
/// The double-click reset: two presses of the same button within 0.35 s and 4 px
|
||||
/// (on `PointerEvent::time_s`) re-fit every axis -- auto scale re-armed with the
|
||||
/// fit -- and the second press begins no drag.
|
||||
PLOT_TEST(double_click_refits_every_axis_and_starts_no_drag) {
|
||||
// A plot that has something to fit: the reset re-fits every axis to its traces,
|
||||
// so this fixture carries one (the ArmedFixture above is deliberately empty --
|
||||
// its tests are about auto-scale *state*, and an axis with no trace has nothing
|
||||
// to fit). The axes start disarmed so the initial layout cannot pre-fit them.
|
||||
plot::Plot p;
|
||||
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
||||
const u32 ax = p.add_x_axis({"t", {5.0, 60.0}, false, plot::AxisPlace::Bottom, false});
|
||||
const u32 ay = p.add_y_axis({"v", {-0.5, 0.5}, false, plot::AxisPlace::Left, false});
|
||||
std::vector<f32> ys(101);
|
||||
for (u64 i = 0; i <= 100u; ++i) ys[static_cast<std::size_t>(i)] = (i % 2u == 0u) ? -1.0f : 1.0f;
|
||||
plot::data::ArraySeries series(std::move(ys), 0.0, 1.0); // x = 0..100, y in [-1, 1]
|
||||
p.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, ax, ay});
|
||||
plot::input::Controller c(p);
|
||||
c.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
||||
|
||||
// Zoom in first, so the window is somewhere a reset has to leave.
|
||||
c.on_wheel({400.0f, 200.0f, 0.0f, 1.0f, 0});
|
||||
PLOT_CHECK(p.x_range(ax).span() < 60.0);
|
||||
PLOT_CHECK(p.y_range(ay).span() < 1.0);
|
||||
|
||||
c.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true, false,
|
||||
/*time_s=*/1.0});
|
||||
c.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false, true, 1.0});
|
||||
c.on_pointer({402.0f, 201.0f, 2, 1, plot::input::Button::Left, 0, true, true, false,
|
||||
1.2}); // 0.2 s and ~2.2 px later
|
||||
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None); // no drag began
|
||||
PLOT_CHECK(p.x_axis(ax).auto_scale); // the reset fit and re-armed
|
||||
PLOT_CHECK(p.y_axis(ay).auto_scale);
|
||||
// Both axes frame their traces' bounds (the 2% pad keeps the data inside them),
|
||||
// which is what a fit means here.
|
||||
PLOT_CHECK(p.x_range(ax).min < 0.0);
|
||||
PLOT_CHECK(p.x_range(ax).max > 100.0);
|
||||
PLOT_CHECK(p.y_range(ay).min < -1.0);
|
||||
PLOT_CHECK(p.y_range(ay).max > 1.0);
|
||||
// And the re-armed fit survives a layout: the frame the host draws next does not
|
||||
// move the window the reset just chose.
|
||||
const plot::Range xr = p.x_range(ax);
|
||||
p.layout();
|
||||
PLOT_CHECK_NEAR(p.x_range(ax).min, xr.min, 1e-9);
|
||||
PLOT_CHECK_NEAR(p.x_range(ax).max, xr.max, 1e-9);
|
||||
|
||||
// A third press starts a fresh gesture (a pan), not a second reset.
|
||||
c.on_pointer({402.0f, 201.0f, 0, 0, plot::input::Button::Left, 0, true, true, false, 1.4});
|
||||
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::Pan);
|
||||
c.on_pointer({402.0f, 201.0f, 0, 0, plot::input::Button::Left, 0, false, false, true, 1.4});
|
||||
}
|
||||
|
||||
/// A double-click needs the host's clock: presses that carry `time_s == 0` are
|
||||
/// never one, whatever their spacing, and presses outside the window (too slow or
|
||||
/// too far apart) are two ordinary gestures -- the second one a drag.
|
||||
PLOT_TEST(double_click_needs_both_timestamps_and_the_window) {
|
||||
ArmedFixture f;
|
||||
f.plot.set_x_auto_scale(f.ax, false);
|
||||
// No timestamps: two presses at the same spot do not reset.
|
||||
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true,
|
||||
false, 0.0});
|
||||
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
|
||||
true, 0.0});
|
||||
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true,
|
||||
false, 0.0});
|
||||
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::Pan);
|
||||
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
|
||||
true, 0.0});
|
||||
|
||||
// Too slow: 0.4 s apart is two gestures.
|
||||
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true,
|
||||
false, 10.0});
|
||||
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
|
||||
true, 10.0});
|
||||
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true,
|
||||
false, 10.4});
|
||||
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::Pan);
|
||||
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
|
||||
true, 10.4});
|
||||
|
||||
// Too far: 10 px apart is two gestures.
|
||||
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true,
|
||||
false, 20.0});
|
||||
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
|
||||
true, 20.0});
|
||||
f.controller.on_pointer({410.0f, 200.0f, 10, 0, plot::input::Button::Left, 0, true, true,
|
||||
false, 20.1});
|
||||
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::Pan);
|
||||
f.controller.on_pointer({410.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
|
||||
true, 20.1});
|
||||
}
|
||||
@@ -786,6 +786,82 @@ PLOT_TEST(a_following_group_advances_all_rows_together) {
|
||||
}
|
||||
}
|
||||
|
||||
/// B4, on a following *log* group: the group's span and offset live in the shared
|
||||
/// axis' axis space -- decades -- exactly as a single `Live` axis' do, so the
|
||||
/// window slides with the newest sample without rescaling. The visible decades
|
||||
/// stay what was asked for on every row, and a member panned mid-follow restates
|
||||
/// its scroll state in the same decades rather than in a linear width that would
|
||||
/// decay as the edge moved.
|
||||
PLOT_TEST(a_following_log_group_holds_decades) {
|
||||
plot::data::StreamSeries left(1024u, 1.0, 10.0); // samples at x = 1, 11, 21, ...
|
||||
plot::data::StreamSeries right(1024u, 1.0, 10.0);
|
||||
for (int i = 0; i < 30; ++i) left.push(0.5f);
|
||||
for (int i = 0; i < 20; ++i) right.push(0.5f);
|
||||
left.acquire();
|
||||
right.acquire();
|
||||
|
||||
plot::Figure figure(2u);
|
||||
figure.set_viewport(whole_figure());
|
||||
for (plot::u32 row = 0; row < 2u; ++row) {
|
||||
plot::Plot& p = figure.plot(row);
|
||||
p.add_x_axis({"t", {1.0, 1000.0}, false, plot::AxisPlace::Bottom, /*log=*/true});
|
||||
p.add_y_axis({"v", {0.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||
}
|
||||
figure.plot(0u).add_trace({&left, plot::Color::hex(0x4FA3FF), 1.5f, 0u, 0u});
|
||||
figure.plot(1u).add_trace({&right, plot::Color::hex(0xFF8080), 1.5f, 0u, 0u});
|
||||
|
||||
const plot::u32 group = figure.add_x_link();
|
||||
PLOT_CHECK(figure.link_x(0u, group));
|
||||
PLOT_CHECK(figure.link_x(1u, group));
|
||||
PLOT_CHECK(figure.set_x_link_follow(group, plot::XFollow::Live));
|
||||
|
||||
const auto decades_of = [](const plot::Plot& p) {
|
||||
const plot::Range r = p.x_range(0u);
|
||||
return std::log10(r.max) - std::log10(r.min);
|
||||
};
|
||||
|
||||
// One decade wide, ending at the fastest row's edge.
|
||||
const plot::f64 edge0 = left.x_bounds().max;
|
||||
PLOT_CHECK(figure.set_x_link_range(group, {edge0 / 10.0, edge0}));
|
||||
figure.layout();
|
||||
PLOT_CHECK_NEAR(decades_of(figure.plot(0u)), 1.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(decades_of(figure.plot(1u)), 1.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).max, edge0, 1e-8);
|
||||
|
||||
// The streams advance at different rates; the window follows the group's edge --
|
||||
// the faster row's -- and holds its decade on every row.
|
||||
for (int step = 1; step <= 8; ++step) {
|
||||
for (int i = 0; i < 3; ++i) left.push(0.5f);
|
||||
for (int i = 0; i < 1; ++i) right.push(0.5f);
|
||||
left.acquire();
|
||||
right.acquire();
|
||||
figure.layout();
|
||||
PLOT_CHECK_NEAR(decades_of(figure.plot(0u)), 1.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(decades_of(figure.plot(1u)), 1.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).max, left.x_bounds().max, 1e-8);
|
||||
PLOT_CHECK_NEAR(figure.plot(1u).x_range(0u).max, left.x_bounds().max, 1e-8);
|
||||
}
|
||||
|
||||
// A member panned while the group follows: the adopted scroll state is the
|
||||
// adopted range's span *in decades* -- two here (edge/1000 to edge/10 is a
|
||||
// factor of 100) -- and the offset one decade behind the edge, both of which
|
||||
// survive the next frames' advances.
|
||||
const plot::f64 edge = left.x_bounds().max;
|
||||
figure.plot(1u).set_x_range(0u, {edge / 1000.0, edge / 10.0});
|
||||
figure.layout();
|
||||
PLOT_CHECK_NEAR(decades_of(figure.plot(0u)), 2.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(decades_of(figure.plot(1u)), 2.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).max, edge / 10.0, 1e-8);
|
||||
for (int step = 0; step < 3; ++step) {
|
||||
left.push(0.5f);
|
||||
left.acquire();
|
||||
figure.layout();
|
||||
PLOT_CHECK_NEAR(decades_of(figure.plot(0u)), 2.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(decades_of(figure.plot(1u)), 2.0, 1e-9);
|
||||
}
|
||||
PLOT_CHECK(figure.x_link_follow(group) == plot::XFollow::Live);
|
||||
}
|
||||
|
||||
// ------------------------------------------------- the crosshair (spec §15)
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -494,13 +494,16 @@ PLOT_TEST(render_programs_are_inert_until_created) {
|
||||
line.set_offset({0.0f, 0.0f});
|
||||
line.set_viewport({256.0f, 256.0f});
|
||||
line.set_width(2.0f);
|
||||
// The AA fringe is a uniform now, not a hardcoded pixel: 1.0 is the fringe the
|
||||
// renderer's own flush sets by default, and the same value the pixel bounds
|
||||
// below were derived from.
|
||||
line.set_aa(1.0f);
|
||||
line.set_color(plot::Color::hex(0xFFFFFF));
|
||||
text.bind();
|
||||
text.set_viewport({256.0f, 256.0f});
|
||||
text.set_px_range({8.0f, 8.0f});
|
||||
text.set_glyph_uv({1.0f / 16.0f, 1.0f / 7.0f});
|
||||
text.set_texture_size({128.0f, 56.0f});
|
||||
text.set_color(plot::Color::hex(0xFFFFFF));
|
||||
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
|
||||
ctx.destroy();
|
||||
}
|
||||
@@ -541,6 +544,10 @@ PLOT_TEST(line_pipeline_draws_an_aa_strip) {
|
||||
program.set_offset({64.0f, 100.0f});
|
||||
program.set_viewport({static_cast<f32>(ctx.width), static_cast<f32>(ctx.height)});
|
||||
program.set_width(3.0f);
|
||||
// The AA fringe is a uniform now, not a hardcoded pixel: 1.0 is the fringe the
|
||||
// renderer's own flush sets by default, and the value the pixel bounds below
|
||||
// were derived from.
|
||||
program.set_aa(1.0f);
|
||||
program.set_color(plot::Color::hex(0xFF00FF));
|
||||
lines.bind();
|
||||
plot::gl::glDrawArraysInstanced(plot::gl::GL_TRIANGLE_STRIP, 0, 4, 3);
|
||||
@@ -619,7 +626,7 @@ PLOT_TEST(text_pipeline_draws_a_glyph) {
|
||||
// an identity.
|
||||
batch.add_text("A", {64.0f, 100.0f}, 1.0f, font);
|
||||
PLOT_CHECK(batch.glyph_count() == 1);
|
||||
PLOT_CHECK(batch.data().size() == 6);
|
||||
PLOT_CHECK(batch.data().size() == 10);
|
||||
glyphs.upload(batch.data().data(), batch.glyph_count());
|
||||
|
||||
plot::gl::glViewport(0, 0, static_cast<plot::gl::GLsizei>(ctx.width),
|
||||
@@ -640,7 +647,6 @@ PLOT_TEST(text_pipeline_draws_a_glyph) {
|
||||
// White, so the blended pixel value *is* the coverage byte: the shader emits
|
||||
// `vec4(rgb, a * coverage)`, and a white tint over the black clear lands at
|
||||
// `1.0 * coverage` in every channel.
|
||||
program.set_color(plot::Color::hex(0xFFFFFF));
|
||||
atlas.bind();
|
||||
glyphs.bind();
|
||||
plot::gl::glDrawArraysInstanced(plot::gl::GL_TRIANGLE_STRIP, 0, 4, 1);
|
||||
|
||||
@@ -168,6 +168,133 @@ PLOT_TEST(a_drag_that_crosses_rows_is_ended_by_the_release_in_the_other_row) {
|
||||
PLOT_CHECK(row0.drag_mode() == plot::input::DragMode::None);
|
||||
}
|
||||
|
||||
// --- a second button does not steal or end the captured row -----------------
|
||||
|
||||
namespace {
|
||||
|
||||
/// A two-row figure handed to a host: the fixture for the capture's *button*. No
|
||||
/// region this time -- what the test observes is which controller a press lands
|
||||
/// on, and a pan is the easiest gesture to watch a plot's range move with.
|
||||
struct TwoRows {
|
||||
TwoRows() : figure(2u) {
|
||||
figure.set_viewport({0.0f, 0.0f, 300.0f, 600.0f});
|
||||
for (plot::u32 row = 0; row < 2u; ++row) {
|
||||
plot::Plot& p = figure.plot(row);
|
||||
ax[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});
|
||||
}
|
||||
host.set_figure(&figure);
|
||||
figure.layout();
|
||||
}
|
||||
|
||||
plot::f32 row_y(plot::u32 row) const {
|
||||
const plot::Rect r = figure.layout().rows[row];
|
||||
return r.y + 0.5f * r.h;
|
||||
}
|
||||
|
||||
plot::Figure figure;
|
||||
plot::glfw::Host host;
|
||||
plot::u32 ax[2] = {0u, 0u};
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
/// The finding (R5): the capture remembered only the *row*, so a second button
|
||||
/// pressed while a drag ran stole nothing but its release still ended the capture
|
||||
/// -- `pointer_target()` cleared `captured_` on **any** release. A chord (or a
|
||||
/// trackpad that reports a second finger as a button) therefore stranded the first
|
||||
/// button's gesture in its row: the next event of the *first* button was routed by
|
||||
/// the pointer's position again, to a controller that never saw the gesture begin.
|
||||
/// The capture now records the button as well, and only that button's release ends
|
||||
/// it.
|
||||
///
|
||||
/// What the test observes is purely the host's routing -- which controller a fresh
|
||||
/// press reaches -- and not the controller's drag modes, which a second button's
|
||||
/// press/release pair legitimately rewrites: press-and-move pairs pan whichever
|
||||
/// controller they are delivered to, so "whose range moved" says who got the events.
|
||||
PLOT_TEST(a_second_button_neither_steals_nor_ends_the_captured_row) {
|
||||
TwoRows r;
|
||||
plot::input::Controller& row1 = *r.host.row_controller(1u);
|
||||
const plot::f32 x = 150.0f;
|
||||
|
||||
// Left press in row 0: the capture is row 0's.
|
||||
r.host.feed_pointer_event({x, r.row_y(0u), 0.0f, 0.0f, plot::input::Button::Left, 0u, true,
|
||||
true, false});
|
||||
|
||||
// Right press in row 1 while the left gesture runs: the single-gesture policy --
|
||||
// the capture is not stolen, so this press reaches row 0's controller too, and
|
||||
// row 1's controller sees nothing at all.
|
||||
r.host.feed_pointer_event({x, r.row_y(1u), 0.0f, 0.0f, plot::input::Button::Right, 0u, true,
|
||||
true, false});
|
||||
PLOT_CHECK(row1.drag_mode() == plot::input::DragMode::None);
|
||||
|
||||
// The right release must not end the left button's capture (the finding above):
|
||||
// only the owning button's release does.
|
||||
r.host.feed_pointer_event({x, r.row_y(1u), 0.0f, 0.0f, plot::input::Button::Right, 0u, false,
|
||||
false, true});
|
||||
|
||||
// The proof that the capture survived: a fresh LEFT press landing physically in
|
||||
// row 1 is still routed to the captured row 0...
|
||||
const plot::Range row0_before = r.figure.plot(0u).x_range(r.ax[0]);
|
||||
const plot::Range row1_before = r.figure.plot(1u).x_range(r.ax[1]);
|
||||
r.host.feed_pointer_event({x, r.row_y(1u), 0.0f, 0.0f, plot::input::Button::Left, 0u, true,
|
||||
true, false});
|
||||
r.host.feed_pointer_event({x + 40.0f, r.row_y(1u), 40.0f, 0.0f, plot::input::Button::Left, 0u,
|
||||
true, false, false});
|
||||
// ... so the drag it begins pans row 0 and leaves row 1 alone. (With the capture
|
||||
// gone, this press-and-move belongs to row 1 and the two assertions swap.)
|
||||
PLOT_CHECK(std::abs(r.figure.plot(0u).x_range(r.ax[0]).min - row0_before.min) > 1e-6);
|
||||
PLOT_CHECK(std::abs(r.figure.plot(1u).x_range(r.ax[1]).min - row1_before.min) < 1e-9);
|
||||
|
||||
// The owning button's release ends the capture, wherever it lands...
|
||||
r.host.feed_pointer_event({x + 40.0f, r.row_y(1u), 0.0f, 0.0f, plot::input::Button::Left, 0u,
|
||||
false, false, true});
|
||||
// ... and the next press belongs to the row it lands in again: a fresh press in
|
||||
// row 1 now pans row 1, not row 0.
|
||||
const plot::Range row0_after = r.figure.plot(0u).x_range(r.ax[0]);
|
||||
r.host.feed_pointer_event({x, r.row_y(1u), 0.0f, 0.0f, plot::input::Button::Left, 0u, true,
|
||||
true, false});
|
||||
r.host.feed_pointer_event({x + 40.0f, r.row_y(1u), 40.0f, 0.0f, plot::input::Button::Left, 0u,
|
||||
true, false, false});
|
||||
PLOT_CHECK(std::abs(r.figure.plot(1u).x_range(r.ax[1]).min - row1_before.min) > 1e-6);
|
||||
PLOT_CHECK(std::abs(r.figure.plot(0u).x_range(r.ax[0]).min - row0_after.min) < 1e-9);
|
||||
r.host.feed_pointer_event({x + 40.0f, r.row_y(1u), 0.0f, 0.0f, plot::input::Button::Left, 0u,
|
||||
false, false, true});
|
||||
}
|
||||
|
||||
// --- the row-rect cache ------------------------------------------------------
|
||||
|
||||
/// The finding (R7): `controller_at()` and `pointer_target()` read
|
||||
/// `Figure::layout()` *per event*, and a non-const `layout()` recomputes -- every
|
||||
/// pointer move on a multi-row figure re-laid every row out (ticks, grid, chrome
|
||||
/// and all), twice for the readers that share a handler. The host now keeps the
|
||||
/// rects of the last `refresh_row_rects()` and every handler reads that cache: the
|
||||
/// run loop refreshes it once per frame, `set_figure()` fills it immediately (so a
|
||||
/// scripted run routes against the figure it was just handed), and in between the
|
||||
/// rows the handlers answer from are the rows the last frame drew -- at most one
|
||||
/// frame stale across a resize, never a second layout.
|
||||
PLOT_TEST(row_routing_reads_the_cache_until_it_is_refreshed) {
|
||||
Routed r(3u);
|
||||
const plot::Rect row1_before = r.figure.layout().rows[1];
|
||||
const plot::f32 y = row1_before.y + 0.5f * row1_before.h;
|
||||
PLOT_CHECK(&r.host.controller_at(150.0f, y) == r.host.row_controller(1u));
|
||||
|
||||
// Re-shape the figure without telling the host: the geometry the *next* frame
|
||||
// would draw. The cache still holds the 600-pixel-tall rows, and the handler
|
||||
// must keep answering from them -- with the old per-event layout the same pixel
|
||||
// already answered for the new geometry (row 2), which is exactly what made
|
||||
// every event pay for a relayout.
|
||||
r.figure.set_viewport({0.0f, 0.0f, 300.0f, 300.0f});
|
||||
r.figure.layout();
|
||||
PLOT_CHECK(&r.host.controller_at(150.0f, y) == r.host.row_controller(1u));
|
||||
|
||||
// The scripted-run equivalent of the frame: the loop's one refresh per frame.
|
||||
// From here the handlers answer with the new rects, and the pixel -- the old
|
||||
// row 1's centre, now the bottom edge of a 300-pixel figure -- is row 2's.
|
||||
r.host.refresh_row_rects();
|
||||
PLOT_CHECK(&r.host.controller_at(150.0f, y) == r.host.row_controller(2u));
|
||||
}
|
||||
|
||||
PLOT_TEST(the_host_keeps_its_own_controller_while_no_figure_is_set) {
|
||||
plot::glfw::Host host;
|
||||
PLOT_CHECK(host.figure() == nullptr);
|
||||
|
||||
@@ -26,7 +26,9 @@ using plot::u32;
|
||||
using plot::gtk4::DeviceMapping;
|
||||
using plot::gtk4::DragAnchor;
|
||||
using plot::gtk4::ScrollUnit;
|
||||
using plot::gtk4::ZoomAnchor;
|
||||
using plot::gtk4::button_from;
|
||||
using plot::gtk4::key_from;
|
||||
using plot::gtk4::kSurfacePixelsPerNotch;
|
||||
using plot::gtk4::mods_from;
|
||||
using plot::gtk4::scroll_notches;
|
||||
@@ -212,3 +214,106 @@ PLOT_TEST(gtk_buttons_map_to_the_controllers_three_or_to_nothing) {
|
||||
PLOT_CHECK(!button_from(8u).has_value()); // a navigation button
|
||||
PLOT_CHECK(!button_from(9u).has_value());
|
||||
}
|
||||
|
||||
// --- the pinch --------------------------------------------------------------
|
||||
|
||||
// `GtkGestureZoom` reports a cumulative scale since the gesture began, while the
|
||||
// controller's `GestureEvent` wants this frame's zoom factor; `ZoomAnchor` is the
|
||||
// fold, and it is the same fold the Qt adapter's `PinchTracker` does -- a
|
||||
// cumulative 1.0 -> 1.25 -> 1.5 spread is a 1.25 frame then a 1.2 frame, so the
|
||||
// zoom the plot applies is the zoom the fingers made.
|
||||
PLOT_TEST(gtk_zoom_anchor_folds_the_cumulative_scale_into_increments) {
|
||||
// A 2x widget: the focal point and its movement are reported in *device* pixels,
|
||||
// like every coordinate the controller sees.
|
||||
const DeviceMapping at_2 = DeviceMapping::from_sizes({400.0f, 300.0f}, {800.0f, 600.0f});
|
||||
plot::gtk4::ZoomAnchor zoom;
|
||||
|
||||
zoom.begin(1.0f, {100.0f, 75.0f});
|
||||
PLOT_CHECK(zoom.active);
|
||||
// The begin itself folds to "nothing happened yet", whenever the first
|
||||
// scale-changed arrives with the scale it began at.
|
||||
const plot::input::GestureEvent started = zoom.update(1.0f, {100.0f, 75.0f}, at_2);
|
||||
PLOT_CHECK_NEAR(started.scale, 1.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(started.dx, 0.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(started.dy, 0.0, 1e-6);
|
||||
|
||||
// Fingers spread to 1.25 about a steady centre: one 1.25 zoom frame, anchored
|
||||
// at the centre in device pixels (100 * 2, 75 * 2).
|
||||
const plot::input::GestureEvent spread = zoom.update(1.25f, {100.0f, 75.0f}, at_2);
|
||||
PLOT_CHECK_NEAR(spread.scale, 1.25, 1e-6);
|
||||
PLOT_CHECK_NEAR(spread.x, 200.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(spread.y, 150.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(spread.dx, 0.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(spread.dy, 0.0, 1e-6);
|
||||
|
||||
// Spread on to 1.5 while the fingers drift 10 logical pixels right: the
|
||||
// increment is 1.2 and the pan is 20 device pixels.
|
||||
const plot::input::GestureEvent drifted = zoom.update(1.5f, {110.0f, 75.0f}, at_2);
|
||||
PLOT_CHECK_NEAR(drifted.scale, 1.2, 1e-6);
|
||||
PLOT_CHECK_NEAR(drifted.dx, 20.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(drifted.dy, 0.0, 1e-6);
|
||||
}
|
||||
|
||||
// The focal point is cached in logical widget pixels, so a factor change between
|
||||
// two updates (the window dragged to another display mid-pinch, GTK re-emitting
|
||||
// `::resize`) cannot read the ratio change as a focal jump; and an update outside
|
||||
// a gesture -- before any begin, or a straggler after the end -- zooms and moves
|
||||
// nothing rather than zooming from a made-up anchor.
|
||||
PLOT_TEST(gtk_zoom_anchor_stays_logical_and_ignores_stragglers) {
|
||||
const DeviceMapping at_1 = DeviceMapping::from_sizes({400.0f, 300.0f}, {400.0f, 300.0f});
|
||||
const DeviceMapping at_125 = DeviceMapping::from_sizes({400.0f, 300.0f}, {500.0f, 375.0f});
|
||||
plot::gtk4::ZoomAnchor zoom;
|
||||
|
||||
// The orphan update, before any begin.
|
||||
const plot::input::GestureEvent orphan = zoom.update(2.0f, {10.0f, 10.0f}, at_1);
|
||||
PLOT_CHECK_NEAR(orphan.scale, 1.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(orphan.dx, 0.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(orphan.dy, 0.0, 1e-9);
|
||||
|
||||
zoom.begin(1.0f, {100.0f, 100.0f});
|
||||
// Four logical pixels of drift across a 1.0 -> 1.25 factor change: 4 * 1.25 =
|
||||
// 5 device pixels, and the anchor for the next frame is the new logical point.
|
||||
const plot::input::GestureEvent rescaled = zoom.update(1.0f, {104.0f, 100.0f}, at_125);
|
||||
PLOT_CHECK_NEAR(rescaled.scale, 1.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(rescaled.dx, 5.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(rescaled.x, 104.0 * 1.25, 1e-6);
|
||||
|
||||
// The gesture ends; a straggler scale-changed does nothing.
|
||||
zoom.end();
|
||||
PLOT_CHECK(!zoom.active);
|
||||
const plot::input::GestureEvent straggler = zoom.update(2.0f, {120.0f, 100.0f}, at_125);
|
||||
PLOT_CHECK_NEAR(straggler.scale, 1.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(straggler.dx, 0.0, 1e-9);
|
||||
}
|
||||
|
||||
// --- the keyboard -----------------------------------------------------------
|
||||
|
||||
// The whole key translation, now that it names arrows and Escape too. The
|
||||
// function keys are matched on the keyval, which for them is layout- and
|
||||
// level-independent; the printable keys on the Unicode the keyval decodes to,
|
||||
// which is what makes `+` and shift+`=` (and the keypad's plus) one key to the
|
||||
// controller. Anything else is `0` -- the caller hands the event back to GTK.
|
||||
PLOT_TEST(gtk_keys_map_by_keyval_first_and_unicode_second) {
|
||||
PLOT_CHECK(key_from(0xFF1Bu, 0u) == plot::input::kKeyEscape); // GDK_KEY_Escape
|
||||
PLOT_CHECK(key_from(0xFF51u, 0u) == plot::input::kKeyLeft); // GDK_KEY_Left
|
||||
PLOT_CHECK(key_from(0xFF53u, 0u) == plot::input::kKeyRight); // GDK_KEY_Right
|
||||
PLOT_CHECK(key_from(0xFF52u, 0u) == plot::input::kKeyUp);
|
||||
PLOT_CHECK(key_from(0xFF54u, 0u) == plot::input::kKeyDown);
|
||||
|
||||
// Printable keys, by code point -- the keyval is irrelevant to this branch.
|
||||
PLOT_CHECK(key_from(0x002Bu, static_cast<u32>('+')) == plot::input::kKeyZoomIn);
|
||||
PLOT_CHECK(key_from(0x003Du, static_cast<u32>('=')) == plot::input::kKeyZoomIn);
|
||||
PLOT_CHECK(key_from(0x002Du, static_cast<u32>('-')) == plot::input::kKeyZoomOut);
|
||||
PLOT_CHECK(key_from(0x005Fu, static_cast<u32>('_')) == plot::input::kKeyZoomOut);
|
||||
PLOT_CHECK(key_from(0x0066u, static_cast<u32>('f')) == plot::input::kKeyFit);
|
||||
PLOT_CHECK(key_from(0x0046u, static_cast<u32>('F')) == plot::input::kKeyFit);
|
||||
|
||||
// The keyval wins when both could match: the order is the seam's contract, not
|
||||
// an accident of GDK's tables.
|
||||
PLOT_CHECK(key_from(0xFF51u, static_cast<u32>('x')) == plot::input::kKeyLeft);
|
||||
|
||||
// And nothing a plot has no gesture for maps to a key: an unmapped keyval with
|
||||
// an unmapped code point is refused, and so is a bare zero.
|
||||
PLOT_CHECK(key_from(0x0078u, static_cast<u32>('x')) == 0);
|
||||
PLOT_CHECK(key_from(0u, 0u) == 0);
|
||||
}
|
||||
@@ -352,7 +352,17 @@ PLOT_TEST(imgui_widget_invalid_position_does_not_move_the_range) {
|
||||
// and not from the sentinel. This is asserted with the drag alive on purpose:
|
||||
// nothing can move a range once the button is up, so the same check after the
|
||||
// release could not fail and would prove nothing.
|
||||
//
|
||||
// The quiet frame before the second press is aged past the double-click window on
|
||||
// purpose: this press lands in the same spot within the window of the first one,
|
||||
// and the widget stamps presses with ImGui's clock, so without the age the two
|
||||
// presses *are* a double-click -- the controller re-fits and begins no drag, which
|
||||
// is the gesture the next test pins, not the sentinel behaviour this one is here
|
||||
// for.
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
io.DeltaTime = 0.4f;
|
||||
f.frame(ImVec2(200.0f, 200.0f), false);
|
||||
io.DeltaTime = 1.0f / 60.0f;
|
||||
f.frame(ImVec2(200.0f, 200.0f), true);
|
||||
const plot::Range second_press = f.x_range();
|
||||
f.frame(ImVec2(-kMaxFloat, -kMaxFloat), true);
|
||||
@@ -492,3 +502,111 @@ PLOT_TEST(imgui_widget_states_the_display_scale_to_the_renderer) {
|
||||
PLOT_CHECK_NEAR(f.renderer.axis_font_px(), cell_1x, 1e-3);
|
||||
PLOT_CHECK_NEAR(f.plot.layout().y_axis_width, strip_1x, 1e-3);
|
||||
}
|
||||
|
||||
// --- keys -------------------------------------------------------------------
|
||||
|
||||
// A held key repeats. This is the adapter's own policy decision, not ImGui's
|
||||
// default: ImGui edge-detects a press once, and `IsKeyPressed` only re-fires
|
||||
// while the key is held when it is asked with repeat = true -- which the widget
|
||||
// now does for every key it maps, because GLFW/Qt/GTK each deliver the OS's
|
||||
// auto-repeat as extra presses and a held `+` or arrow must behave the same here
|
||||
// (docs/embedding.md's shared policy). Held with `repeat = false`, the zoom
|
||||
// happens exactly once; with `repeat = true`, once per repeat ImGui's timer
|
||||
// fires, so the span keeps growing for as long as the key is down.
|
||||
PLOT_TEST(imgui_widget_repeats_a_held_key) {
|
||||
Fixture f;
|
||||
PLOT_CHECK(f.init());
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
// A repeat clock a test can afford: first repeat one frame after the press,
|
||||
// then one per frame, each frame a twentieth of a second of ImGui time.
|
||||
io.DeltaTime = 0.05f;
|
||||
io.KeyRepeatDelay = 0.05f;
|
||||
io.KeyRepeatRate = 0.05f;
|
||||
|
||||
// Hover the plot (the keys are gated on hover) and press `-` once.
|
||||
f.frame(ImVec2(200.0f, 200.0f), false);
|
||||
io.AddKeyEvent(ImGuiKey_Minus, true);
|
||||
f.frame(ImVec2(200.0f, 200.0f), false);
|
||||
const double span_after_press = f.x_range().span();
|
||||
PLOT_CHECK(span_after_press > 20.0); // one zoom-out detent on the {0, 20} fixture
|
||||
|
||||
// Hold it for half a second of ImGui time. Each repeat zooms again, so the
|
||||
// span ends up several detents past the single press -- a single-fire widget
|
||||
// would leave it exactly where the press left it.
|
||||
for (int i = 0; i < 10; ++i) f.frame(ImVec2(200.0f, 200.0f), false);
|
||||
PLOT_CHECK(f.x_range().span() > span_after_press * 1.5);
|
||||
|
||||
io.AddKeyEvent(ImGuiKey_Minus, false);
|
||||
f.frame(ImVec2(200.0f, 200.0f), false);
|
||||
}
|
||||
|
||||
// The arrows pan and Escape cancels a drag, mapped to the controller's
|
||||
// host-translated constants (`kKeyLeft`, `kKeyEscape`): one press of Left moves
|
||||
// every x axis 10% of its span, and Escape hands the axes back the ranges they
|
||||
// had when the drag began.
|
||||
PLOT_TEST(imgui_widget_maps_arrows_and_escape) {
|
||||
Fixture f;
|
||||
PLOT_CHECK(f.init());
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
|
||||
f.frame(ImVec2(200.0f, 200.0f), false);
|
||||
const double span = f.x_range().span();
|
||||
const double min_before = f.x_range().min;
|
||||
|
||||
// One Left press: every x axis slides by 10% of its span, and the span itself
|
||||
// is untouched (a pan, not a zoom).
|
||||
io.AddKeyEvent(ImGuiKey_LeftArrow, true);
|
||||
f.frame(ImVec2(200.0f, 200.0f), false);
|
||||
io.AddKeyEvent(ImGuiKey_LeftArrow, false);
|
||||
f.frame(ImVec2(200.0f, 200.0f), false);
|
||||
PLOT_CHECK_NEAR(f.x_range().span(), span, 1e-9);
|
||||
PLOT_CHECK(std::abs(f.x_range().min - min_before) > 0.05 * span);
|
||||
PLOT_CHECK(std::abs(f.x_range().min - min_before) < 0.15 * span);
|
||||
|
||||
// A drag that has panned, then Escape while the button is still down: the
|
||||
// ranges go back to where they were when the drag began, and the drag is over.
|
||||
const double before_drag = f.x_range().min;
|
||||
f.frame(ImVec2(200.0f, 200.0f), true);
|
||||
PLOT_CHECK(f.panning());
|
||||
f.frame(ImVec2(260.0f, 200.0f), true);
|
||||
PLOT_CHECK(std::abs(f.x_range().min - before_drag) > 1e-6); // the pan moved it
|
||||
io.AddKeyEvent(ImGuiKey_Escape, true);
|
||||
f.frame(ImVec2(260.0f, 200.0f), true);
|
||||
io.AddKeyEvent(ImGuiKey_Escape, false);
|
||||
PLOT_CHECK(std::abs(f.x_range().min - before_drag) < 1e-9);
|
||||
f.frame(ImVec2(260.0f, 200.0f), false); // the release ends nothing: already cancelled
|
||||
PLOT_CHECK(std::abs(f.x_range().min - before_drag) < 1e-9);
|
||||
}
|
||||
|
||||
// The widget stamps every pointer event with ImGui's clock, which is what makes
|
||||
// the double-click -- two presses of the same button within 0.35 s and 4 px --
|
||||
// exist on this host: re-fitting every axis is the controller's gesture, and the
|
||||
// widget is the only supplier of the times it differences. Two quick presses on
|
||||
// the fixture (whose x range was pinned to {0, 20}) must hand the axes back to
|
||||
// the data -- the fit's own bounds -- and the second press must not begin a drag.
|
||||
PLOT_TEST(imgui_widget_stamps_presses_so_a_double_click_can_reset) {
|
||||
Fixture f;
|
||||
PLOT_CHECK(f.init());
|
||||
|
||||
// One quiet hovered frame first. The very first frame's events are not the
|
||||
// widget's yet: ImGui has not decided the hover (and its input queue trickles),
|
||||
// so a press queued on frame 1 is swallowed by the hover gate and the controller
|
||||
// never sees it. Every press this test means to deliver needs the hover live,
|
||||
// which is the same reason the other tests here warm up with a hovered frame.
|
||||
f.frame(ImVec2(200.0f, 200.0f), false);
|
||||
|
||||
f.frame(ImVec2(200.0f, 200.0f), true); // press 1
|
||||
f.frame(ImVec2(200.0f, 200.0f), false); // release
|
||||
// Press 2 in the same spot one frame later: ~1/60 s apart with the default
|
||||
// DeltaTime, well inside the 0.35 s and 4 px window.
|
||||
f.frame(ImVec2(200.0f, 200.0f), true);
|
||||
const double fitted = f.x_range().span();
|
||||
const double fitted_min = f.x_range().min;
|
||||
PLOT_CHECK(fitted > 40.0); // the data's own span, not the fixture's {0, 20}
|
||||
f.frame(ImVec2(220.0f, 200.0f), true); // a move with the second press's button
|
||||
// ... begins no drag: the fit's values stand exactly.
|
||||
PLOT_CHECK_NEAR(f.x_range().span(), fitted, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.x_range().min, fitted_min, 1e-9);
|
||||
|
||||
f.frame(ImVec2(220.0f, 200.0f), false);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#include "harness.hpp"
|
||||
#include <plot/plot/measure.hpp>
|
||||
#include <plot/plot/plot.hpp>
|
||||
#include <plot/data/array_series.hpp>
|
||||
#include <plot/data/detail/cover_count.hpp>
|
||||
#include <algorithm>
|
||||
@@ -65,3 +66,41 @@ PLOT_TEST(empty_and_single_sample_intervals_are_valid_false) {
|
||||
PLOT_CHECK(!plot::Measure::evaluate(s, 0, 0).valid);
|
||||
PLOT_CHECK(!plot::Measure::evaluate(s, 1, 1).valid);
|
||||
}
|
||||
|
||||
PLOT_TEST(a_measure_over_a_ruler_reports_invalid) {
|
||||
// R8: a measurement is read from two *x* coordinates, and a horizontal ruler has
|
||||
// no x -- it is the dead field of its orientation. Evaluating the pair anyway
|
||||
// silently measured from x=0; now the id `add_measure` returns is real but
|
||||
// reports an invalid measurement, and names no band to shade either.
|
||||
plot::Plot p;
|
||||
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
||||
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});
|
||||
std::vector<f32> ys(100);
|
||||
for (u64 i = 0; i < 100u; ++i) ys[static_cast<std::size_t>(i)] = f32(i) * 0.01f;
|
||||
plot::data::ArraySeries series(std::move(ys), 0.0, 1.0);
|
||||
p.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
|
||||
|
||||
const u32 ra = p.add_h_ruler(0.5, plot::Color::hex(0xFFE080));
|
||||
const u32 rb = p.add_h_ruler(-0.5, plot::Color::hex(0xFFE080));
|
||||
const u32 rulers = p.add_measure(ra, rb);
|
||||
PLOT_CHECK(!p.measurement(rulers).valid);
|
||||
PLOT_CHECK(!p.measure_span(rulers).valid); // and no band between dead x's to shade
|
||||
|
||||
// A ruler paired with a real marker is refused for the same reason: one half of
|
||||
// the pair has no x to measure from.
|
||||
const u32 ca = p.add_cursor(10.0, plot::Color::hex(0xFFFFFF));
|
||||
const u32 mixed = p.add_measure(ca, ra);
|
||||
PLOT_CHECK(!p.measurement(mixed).valid);
|
||||
PLOT_CHECK(!p.measure_span(mixed).valid);
|
||||
|
||||
// Sanity: a marker pair over the same series still measures. The extremes are
|
||||
// the stored f32 samples' values, so the expectation is derived from the same
|
||||
// arithmetic the store used, not from the decimal literals.
|
||||
const u32 cb = p.add_cursor(20.0, plot::Color::hex(0xFFFFFF));
|
||||
const u32 markers = p.add_measure(ca, cb);
|
||||
const plot::Measurement& m = p.measurement(markers);
|
||||
PLOT_CHECK(m.valid);
|
||||
PLOT_CHECK_NEAR(m.ymin, static_cast<f64>(f32(10) * 0.01f), 1e-12);
|
||||
PLOT_CHECK_NEAR(m.ymax, static_cast<f64>(f32(20) * 0.01f), 1e-12);
|
||||
}
|
||||
@@ -290,3 +290,55 @@ PLOT_TEST(pyramid_append_cost_is_amortised_constant) {
|
||||
const u64 slid = plot::data::detail::merge_count().load(std::memory_order_relaxed);
|
||||
PLOT_CHECK(slid <= kAppends / 16);
|
||||
}
|
||||
|
||||
// --- B7: non-finite samples are excluded from every aggregate ----------------
|
||||
|
||||
PLOT_TEST(pyramid_excludes_non_finite_samples_from_aggregates) {
|
||||
// NaN at the first, middle and last positions of the window, and a whole
|
||||
// all-NaN bucket in the middle: the aggregates never see them.
|
||||
MinMaxPyramid p;
|
||||
p.append(std::nanf("")); // first
|
||||
for (int i = 0; i < 63; ++i) p.append(1.0f);
|
||||
for (int i = 0; i < kBucket; ++i) p.append(std::nanf("")); // bucket 1: all NaN
|
||||
for (int i = 0; i < 63; ++i) p.append(2.0f);
|
||||
p.append(std::nanf("")); // last
|
||||
PLOT_CHECK(p.size() == 64 + kBucket + 64);
|
||||
// Bucket 0: 63 ones; bucket 1: empty; bucket 2: 63 twos (the final NaN is
|
||||
// the pending bucket's only sample, never a whole bucket).
|
||||
PLOT_CHECK(p.bucket_count(0, 0) == 63);
|
||||
PLOT_CHECK(p.bucket_count(0, 1) == 0);
|
||||
PLOT_CHECK(p.bucket_count(0, 2) == 63);
|
||||
const Range r0 = p.reduce_buckets(0, kBucket);
|
||||
PLOT_CHECK_NEAR(r0.min, 1.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(r0.max, 1.0, 1e-6);
|
||||
PLOT_CHECK(p.reduce_buckets(kBucket, 2 * kBucket).span() == 0.0); // the NaN bucket
|
||||
PLOT_CHECK(p.sum_buckets(kBucket, 2 * kBucket) == 0.0);
|
||||
PLOT_CHECK(p.sumsq_buckets(kBucket, 2 * kBucket) == 0.0);
|
||||
// The merge above an empty child keeps the other child's extremes: level 1
|
||||
// bucket 0 covers buckets 0 and 1, and must report the ones, not a 0.
|
||||
PLOT_CHECK(p.level_size(1) >= 1);
|
||||
const MinMax top = p.bucket(1, p.level_base(1));
|
||||
PLOT_CHECK_NEAR(top.min, 1.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(top.max, 1.0, 1e-6);
|
||||
PLOT_CHECK(p.bucket_count(1, p.level_base(1)) == 63);
|
||||
// A range spanning the finite buckets on both sides of the empty one.
|
||||
const Range all = p.reduce_buckets(0, 3 * kBucket);
|
||||
PLOT_CHECK_NEAR(all.min, 1.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(all.max, 2.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(p.sum_buckets(0, 3 * kBucket), 63.0 + 126.0, 1e-6);
|
||||
// bucket_sum / bucket_count is the mean the Mean gather mode folds.
|
||||
PLOT_CHECK_NEAR(p.bucket_sum(0, 0) / f64(p.bucket_count(0, 0)), 1.0, 1e-9);
|
||||
}
|
||||
|
||||
// --- R3: the deepest level is capped where the span shift still fits ---------
|
||||
|
||||
PLOT_TEST(pyramid_level_cap_keeps_the_span_shift_defined) {
|
||||
// Level 57 is the last whose bucket span (kBucket << 57 == 2^63) fits a u64;
|
||||
// past it the shift would be undefined, so the constant pins the cap and
|
||||
// reserve() never materialises more levels than it.
|
||||
PLOT_CHECK((u64{kBucket} << plot::data::kMaxPyramidLevel) == (u64{1} << 63));
|
||||
MinMaxPyramid p;
|
||||
p.reserve(~u64{0}); // a window no sample count can reach: still just the cap
|
||||
p.append(1.0f);
|
||||
PLOT_CHECK(p.level_count() >= 1);
|
||||
}
|
||||
+292
-2
@@ -1939,10 +1939,11 @@ class CountingSeries final : public plot::data::Series {
|
||||
}
|
||||
plot::Range y_bounds() const override { return {0.0, 99.0}; }
|
||||
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
|
||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out) const override {
|
||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out,
|
||||
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override {
|
||||
++gather_calls_;
|
||||
samples_read_ += end - begin;
|
||||
gather_impl(level, begin, end, out);
|
||||
gather_impl(level, begin, end, out, mode);
|
||||
}
|
||||
|
||||
u64 gather_calls() const noexcept { return gather_calls_; }
|
||||
@@ -2294,3 +2295,292 @@ PLOT_TEST(crosshair_lookup_does_not_scan) {
|
||||
// quarter of a million of them.
|
||||
PLOT_CHECK(series.size() == kSamples);
|
||||
}
|
||||
|
||||
// --- hardening wave: R1, R2, B4, and the Series-virtual routing -------------
|
||||
|
||||
namespace {
|
||||
|
||||
/// A minimal **XY-like** series: the sample positions are this test's own array,
|
||||
/// not a uniform grid, and only the lookups the plot layer routes through the
|
||||
/// `Series` virtuals -- `x_at`/`neighbours`/`snap_index` -- are overridden. Any
|
||||
/// plot-layer answer that assumed `x0() + i*dx()` is wrong here by construction,
|
||||
/// which is what makes the fixture a regression test for the *routing* rather than
|
||||
/// for the series: the real XY series (Stream A) must behave the same way, but
|
||||
/// these tests stand on their own.
|
||||
class XyFixture final : public plot::data::Series {
|
||||
public:
|
||||
XyFixture(std::vector<f64> x, std::vector<f32> y)
|
||||
: x_(std::move(x)), y_(std::move(y)) {
|
||||
pyramid_.reserve(y_.size());
|
||||
for (const f32 v : y_) pyramid_.append(v);
|
||||
}
|
||||
|
||||
u64 size() const override { return y_.size(); }
|
||||
f64 x0() const override { return x_.empty() ? 0.0 : x_.front(); }
|
||||
f64 dx() const override { return 1.0; } // nominal; the real positions are x_
|
||||
plot::Range x_bounds() const override {
|
||||
return x_.empty() ? plot::Range{} : plot::Range{x_.front(), x_.back()};
|
||||
}
|
||||
plot::Range y_bounds() const override { return {0.0, 20.0}; }
|
||||
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
|
||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out,
|
||||
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override {
|
||||
gather_impl(level, begin, end, out, mode);
|
||||
}
|
||||
|
||||
f64 x_at(u64 i) const override { return x_[static_cast<std::size_t>(i)]; }
|
||||
|
||||
bool neighbours(f64 x, u64& i0, u64& i1) const override {
|
||||
if (x_.empty() || x < x_.front() || x > x_.back()) return false;
|
||||
const auto upper = std::lower_bound(x_.begin(), x_.end(), x);
|
||||
const auto first = static_cast<u64>(upper - x_.begin());
|
||||
if (upper != x_.end() && *upper == x) {
|
||||
i0 = i1 = first; // the query landed on a sample
|
||||
return true;
|
||||
}
|
||||
i1 = first;
|
||||
i0 = first == 0u ? 0u : first - 1u;
|
||||
return true;
|
||||
}
|
||||
|
||||
u64 snap_index(f64 x) const override {
|
||||
if (x_.empty()) return 0u;
|
||||
u64 i0 = 0;
|
||||
u64 i1 = 0;
|
||||
if (!neighbours(x, i0, i1)) {
|
||||
return x < x_.front() ? 0u : static_cast<u64>(x_.size() - 1u);
|
||||
}
|
||||
if (i0 == i1) return i0;
|
||||
const f64 before = x - x_at(i0);
|
||||
const f64 after = x_at(i1) - x;
|
||||
return after < before ? i1 : i0;
|
||||
}
|
||||
|
||||
protected:
|
||||
void copy_raw(u64 begin, u64 end, f32* out) const override {
|
||||
for (u64 i = begin; i < end && i < y_.size(); ++i) out[i - begin] = y_[i];
|
||||
}
|
||||
|
||||
private:
|
||||
std::vector<f64> x_;
|
||||
std::vector<f32> y_;
|
||||
plot::data::MinMaxPyramid pyramid_;
|
||||
};
|
||||
|
||||
/// The XY scene: samples at x = 0, 1, 10 (deliberately non-uniform), y = 0, 10, 20,
|
||||
/// and a window wide enough to frame them all.
|
||||
struct XyScene {
|
||||
XyFixture series{{0.0, 1.0, 10.0}, {0.0f, 10.0f, 20.0f}};
|
||||
plot::Plot plot;
|
||||
u32 ax = 0;
|
||||
u32 ay = 0;
|
||||
|
||||
XyScene() {
|
||||
plot.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
||||
ax = plot.add_x_axis({"t", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false});
|
||||
ay = plot.add_y_axis({"v", {0.0, 20.0}, false, plot::AxisPlace::Left, false});
|
||||
}
|
||||
};
|
||||
|
||||
/// A streaming series with decade-wide spacing on a log axis: sample k sits at
|
||||
/// x = 1 + 10k, so feeding samples walks the axis through decades.
|
||||
struct LogLiveFixture {
|
||||
plot::data::StreamSeries series;
|
||||
plot::Plot graph;
|
||||
u32 ax = 0;
|
||||
|
||||
explicit LogLiveFixture(u64 push) : series(256u, 1.0, 10.0) {
|
||||
graph.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
||||
ax = graph.add_x_axis({"t", {1.0, 1000.0}, false, plot::AxisPlace::Bottom, /*log=*/true});
|
||||
graph.add_y_axis({"v", {0.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||
graph.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, ax, 0u});
|
||||
feed(push);
|
||||
graph.set_x_follow(ax, plot::XFollow::Live);
|
||||
graph.auto_fit_x();
|
||||
graph.layout();
|
||||
}
|
||||
|
||||
void feed(u64 n) {
|
||||
for (u64 i = 0; i < n; ++i) series.push(0.5f);
|
||||
series.acquire();
|
||||
}
|
||||
f64 newest() const { return series.x_bounds().max; }
|
||||
/// The visible window in decades -- what a log follow must hold constant.
|
||||
f64 decades() const {
|
||||
const plot::Range r = graph.x_range(ax);
|
||||
return std::log10(r.max) - std::log10(r.min);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
/// R1: the layout clamps are defined even for a hostile viewport. `set_viewport()`
|
||||
/// normalises negative dimensions at the door; the belt-and-braces guard inside
|
||||
/// `layout()` means the two can never drift out of step -- and this pair is what a
|
||||
/// caller that hands the plot a negative rect straight away exercises, in a build
|
||||
/// where the undefined clamp would be visible (asan).
|
||||
PLOT_TEST(layout_survives_a_negative_viewport) {
|
||||
plot::Plot p;
|
||||
p.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
|
||||
p.add_y_axis({"v", {0.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||
p.set_viewport({10.0f, 10.0f, -200.0f, -100.0f}); // normalised to 10x10 zero-size
|
||||
const plot::PlotLayout& lay = p.layout();
|
||||
PLOT_CHECK(lay.axes.w >= 0.0f);
|
||||
PLOT_CHECK(lay.axes.h >= 0.0f);
|
||||
// And a plot laid out at zero size draws nothing but says nothing either: the
|
||||
// same call again on the guarded dimensions stays well-defined.
|
||||
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
||||
PLOT_CHECK(p.layout().axes.w > 0.0f);
|
||||
}
|
||||
|
||||
/// R2: the range setters refuse non-finite endpoints. A stored NaN or infinity
|
||||
/// would hand every transform a garbage mapping *and* clear `auto_scale` -- the
|
||||
/// one way back -- so the call is refused outright: the axis keeps the range it
|
||||
/// had and the auto-scale state it had.
|
||||
PLOT_TEST(range_setters_refuse_non_finite_bounds) {
|
||||
plot::Plot p;
|
||||
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
||||
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});
|
||||
|
||||
// A user-chosen range first, so the auto-scale state the refusal has to preserve
|
||||
// is the "cleared by a gesture" one, which is the state a real caller is in.
|
||||
p.set_x_range(ax, {10.0, 20.0});
|
||||
PLOT_CHECK(!p.x_axis(ax).auto_scale);
|
||||
p.set_x_range(ax, {std::nan(""), 30.0});
|
||||
PLOT_CHECK_NEAR(p.x_range(ax).min, 10.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(p.x_range(ax).max, 20.0, 1e-9);
|
||||
PLOT_CHECK(!p.x_axis(ax).auto_scale); // kept as it was, neither lost nor re-armed
|
||||
p.set_x_range(ax, {5.0, std::numeric_limits<f64>::infinity()});
|
||||
PLOT_CHECK_NEAR(p.x_range(ax).min, 10.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(p.x_range(ax).max, 20.0, 1e-9);
|
||||
|
||||
// The y setter's own refusal, with the same preservation both ways.
|
||||
p.set_y_auto_scale(ay, true);
|
||||
p.set_y_range(ay, {std::nan(""), 0.5});
|
||||
PLOT_CHECK_NEAR(p.y_range(ay).min, -1.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(p.y_range(ay).max, 1.0, 1e-9);
|
||||
PLOT_CHECK(p.y_axis(ay).auto_scale); // still armed: the fit is not lost
|
||||
p.set_y_range(ay, {-1.0, std::numeric_limits<f64>::infinity()});
|
||||
PLOT_CHECK_NEAR(p.y_range(ay).max, 1.0, 1e-9);
|
||||
PLOT_CHECK(p.y_axis(ay).auto_scale);
|
||||
// A finite range still works, and still disarms.
|
||||
p.set_y_range(ay, {-2.0, 2.0});
|
||||
PLOT_CHECK_NEAR(p.y_range(ay).min, -2.0, 1e-9);
|
||||
PLOT_CHECK(!p.y_axis(ay).auto_scale);
|
||||
}
|
||||
|
||||
/// B4, on a log axis: the live window's span and offset are *decades*, so new
|
||||
/// samples slide the window without rescaling it -- the visible decades stay
|
||||
/// exactly what the user zoomed to, whatever the live edge does. (A linear span on
|
||||
/// a log axis is the defect: ten units of span at 1e0 is ten decades, at 1e9 it is
|
||||
/// nothing.)
|
||||
PLOT_TEST(live_log_axis_holds_decades_constant) {
|
||||
LogLiveFixture f(5u); // newest = 41
|
||||
const f64 decades = f.decades();
|
||||
PLOT_CHECK(decades > 0.5);
|
||||
PLOT_CHECK_NEAR(f.graph.x_range(f.ax).max, f.newest(), 1e-9);
|
||||
for (int i = 0; i < 12; ++i) {
|
||||
f.feed(2u); // newest += 20 per round
|
||||
f.graph.layout();
|
||||
PLOT_CHECK_NEAR(f.decades(), decades, 1e-9); // the zoom is preserved
|
||||
PLOT_CHECK_NEAR(f.graph.x_range(f.ax).max, f.newest(), 1e-8); // the edge is too
|
||||
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
|
||||
}
|
||||
}
|
||||
|
||||
/// B4, scrolled back: a window parked behind the live edge keeps its span *in
|
||||
/// decades* as the stream advances, and the distance behind the edge -- the offset,
|
||||
/// also in decades -- is what survives, not an absolute position.
|
||||
PLOT_TEST(scrolled_back_live_window_keeps_its_decades) {
|
||||
LogLiveFixture f(5u); // newest = 41
|
||||
f.graph.set_x_range(f.ax, {1.0, 10.0}); // one decade wide, behind the edge
|
||||
const f64 decades = f.decades();
|
||||
PLOT_CHECK_NEAR(decades, 1.0, 1e-9);
|
||||
const f64 offset = f.graph.x_offset(f.ax);
|
||||
PLOT_CHECK(offset > 0.5); // one decade behind the 41 edge, in log10
|
||||
PLOT_CHECK_NEAR(f.graph.x_range(f.ax).max, 10.0, 1e-9);
|
||||
|
||||
f.feed(6u); // newest = 101: the edge has moved a whole decade
|
||||
f.graph.layout();
|
||||
PLOT_CHECK_NEAR(f.decades(), 1.0, 1e-9); // the span the user chose
|
||||
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), offset, 1e-12); // the scroll the user chose
|
||||
const plot::Range r = f.graph.x_range(f.ax);
|
||||
PLOT_CHECK_NEAR(std::log10(r.max), std::log10(f.newest()) - offset, 1e-9);
|
||||
PLOT_CHECK(r.max < f.newest()); // genuinely parked behind the edge
|
||||
}
|
||||
|
||||
/// The XY routing, crosshair half: the span brackets the samples the *array*
|
||||
/// names, and the interpolation fraction is measured on the samples' own positions
|
||||
/// -- (5-1)/(10-1) over the wide gap, where a uniform-grid guess would have read
|
||||
/// samples that do not exist at the positions it assumed.
|
||||
PLOT_TEST(xy_series_crosshair_brackets_its_own_samples) {
|
||||
XyScene scene;
|
||||
const u32 trace = scene.plot.add_trace(
|
||||
{&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay});
|
||||
|
||||
const plot::CrosshairSpan span = plot::crosshair_span(scene.series, 5.0);
|
||||
PLOT_CHECK(span.valid);
|
||||
PLOT_CHECK(span.begin == 1u);
|
||||
PLOT_CHECK(span.end == 2u);
|
||||
PLOT_CHECK_NEAR(span.t, 4.0 / 9.0, 1e-12);
|
||||
f64 y = 0.0;
|
||||
PLOT_CHECK(scene.plot.crosshair_value(trace, 5.0, y));
|
||||
PLOT_CHECK_NEAR(y, 10.0 + 10.0 * (4.0 / 9.0), 1e-9);
|
||||
|
||||
// On a sample, at both ends of the array's window, and outside it: the same
|
||||
// closed-window rule a uniform series follows, on the array's own coordinates.
|
||||
const plot::CrosshairSpan on = plot::crosshair_span(scene.series, 1.0);
|
||||
PLOT_CHECK(on.valid);
|
||||
PLOT_CHECK(on.begin == 1u);
|
||||
PLOT_CHECK(on.end == 1u);
|
||||
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.0, y));
|
||||
PLOT_CHECK_NEAR(y, 10.0, 1e-9);
|
||||
PLOT_CHECK(scene.plot.crosshair_value(trace, 10.0, y));
|
||||
PLOT_CHECK_NEAR(y, 20.0, 1e-9);
|
||||
PLOT_CHECK(!plot::crosshair_span(scene.series, -1e-9).valid);
|
||||
PLOT_CHECK(!plot::crosshair_span(scene.series, 10.0 + 1e-9).valid);
|
||||
PLOT_CHECK(scene.plot.crosshair_value(trace, 5.5, y));
|
||||
PLOT_CHECK(!scene.plot.crosshair_value(trace, -1.0, y));
|
||||
}
|
||||
|
||||
/// The XY routing, measurement half: a cursor snaps to the sample that is really
|
||||
/// nearest -- 5.0 is nearer sample 1 (distance 4) than sample 2 (distance 5), where
|
||||
/// the old uniform-grid index arithmetic named sample 2 -- and the measurement
|
||||
/// covers exactly the samples the snapped pair brackets.
|
||||
PLOT_TEST(xy_series_measurement_uses_the_true_nearest_samples) {
|
||||
XyScene scene;
|
||||
scene.plot.add_trace({&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay});
|
||||
// Free cursors first: a cursor's stored x is verbatim under the default snap,
|
||||
// and the *measurement* is what maps a cursor x onto samples -- through the
|
||||
// series' own `snap_index()`, so 5.0 measures from the sample that is really
|
||||
// nearest (x = 1, distance 4) and not from where a uniform `dx()` grid guessed.
|
||||
const u32 ca = scene.plot.add_cursor(5.0, plot::Color::hex(0xFFFFFF));
|
||||
const u32 cb = scene.plot.add_cursor(9.0, plot::Color::hex(0xFFFFFF));
|
||||
PLOT_CHECK_NEAR(scene.plot.cursor(ca).x, 5.0, 1e-9); // Free: stored verbatim
|
||||
const u32 measure = scene.plot.add_measure(ca, cb);
|
||||
const plot::Measurement& m = scene.plot.measurement(measure);
|
||||
PLOT_CHECK(m.valid);
|
||||
PLOT_CHECK_NEAR(m.ymin, 10.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(m.ymax, 20.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(m.mean, 15.0, 1e-9);
|
||||
|
||||
// The snap, armed explicitly, lands on the same true nearest sample: 5.0 pulls
|
||||
// onto x = 1 (distance 4 beats 5), 9.0 onto x = 10 (distance 1 beats 9).
|
||||
scene.plot.set_cursor_snap(ca, plot::SnapMode::NearestSample);
|
||||
PLOT_CHECK_NEAR(scene.plot.cursor(ca).x, 1.0, 1e-9);
|
||||
scene.plot.set_cursor_snap(cb, plot::SnapMode::NearestSample);
|
||||
PLOT_CHECK_NEAR(scene.plot.cursor(cb).x, 10.0, 1e-9);
|
||||
|
||||
// Region coverage is the same routing: [0.5, 5.0] covers exactly the sample at
|
||||
// x = 1 -- the sample at x = 10 is not inside the interval, whatever a `dx()`
|
||||
// grid would have guessed. The region is `Free`: a snapping region would land on
|
||||
// a sample and change the interval under test.
|
||||
const u32 region = scene.plot.add_region({0.5, 5.0, plot::RegionSnap::Free});
|
||||
const plot::RegionStats& stats = scene.plot.region_stats(region, 0u);
|
||||
PLOT_CHECK(stats.valid);
|
||||
PLOT_CHECK(stats.count == 1u);
|
||||
PLOT_CHECK_NEAR(stats.mean, 10.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(stats.min, 10.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(stats.max, 10.0, 1e-9);
|
||||
}
|
||||
+41
-14
@@ -536,21 +536,21 @@ PLOT_TEST(text_batch_stages_one_quad_per_glyph) {
|
||||
b.begin();
|
||||
const plot::Vec2 end = b.add_text("12.5 ms", {0.0f, 0.0f}, 1.0f, f);
|
||||
PLOT_CHECK(b.glyph_count() == 7);
|
||||
PLOT_CHECK(b.data().size() == 42); // x,y,u,v,ax,ay per glyph
|
||||
PLOT_CHECK(b.data().size() == 70); // x,y,u,v,ax,ay,r,g,b,a per glyph
|
||||
PLOT_CHECK_NEAR(end.x, f.measure("12.5 ms"), 1e-6);
|
||||
PLOT_CHECK_NEAR(end.y, 0.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(b.data()[0], 0.0, 1e-6); // first cell at the pen
|
||||
PLOT_CHECK_NEAR(b.data()[1], 0.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(b.data()[6], f.glyph('1')->advance, 1e-6); // pen advanced per glyph
|
||||
PLOT_CHECK_NEAR(b.data()[10], f.glyph('1')->advance, 1e-6); // pen advanced per glyph
|
||||
// The staged cell's UV is the glyph's own atlas rect (see `Glyph`), and the
|
||||
// basis of an unrotated run is the screen's +x. '.' is the string's third
|
||||
// glyph, so its instance is the third: floats 12..17.
|
||||
// glyph, so its instance is the third: floats 20..29.
|
||||
const plot::render::Glyph* dot = f.glyph(static_cast<plot::u32>('.'));
|
||||
PLOT_CHECK(dot != nullptr);
|
||||
PLOT_CHECK_NEAR(b.data()[14], dot->u0, 1e-6);
|
||||
PLOT_CHECK_NEAR(b.data()[15], dot->v0, 1e-6);
|
||||
PLOT_CHECK_NEAR(b.data()[16], 1.0, 1e-6); // ax
|
||||
PLOT_CHECK_NEAR(b.data()[17], 0.0, 1e-6); // ay
|
||||
PLOT_CHECK_NEAR(b.data()[22], dot->u0, 1e-6);
|
||||
PLOT_CHECK_NEAR(b.data()[23], dot->v0, 1e-6);
|
||||
PLOT_CHECK_NEAR(b.data()[24], 1.0, 1e-6); // ax
|
||||
PLOT_CHECK_NEAR(b.data()[25], 0.0, 1e-6); // ay
|
||||
// Every glyph a batch stages is read through one cell, and the batch publishes
|
||||
// it for the draw (`u_glyph_uv`).
|
||||
PLOT_CHECK_NEAR(b.glyph_cell_uv().x, dot->u1 - dot->u0, 1e-6);
|
||||
@@ -571,7 +571,7 @@ PLOT_TEST(text_batch_stages_one_quad_per_glyph) {
|
||||
PLOT_CHECK(b.glyph_count() == 2);
|
||||
PLOT_CHECK_NEAR(scaled.x, 10.0f + 2.0f * f.measure("AW"), 1e-6);
|
||||
PLOT_CHECK_NEAR(scaled.y, 4.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(b.data()[6], 10.0f + 2.0f * f.glyph('A')->advance, 1e-6); // second cell's pen
|
||||
PLOT_CHECK_NEAR(b.data()[10], 10.0f + 2.0f * f.glyph('A')->advance, 1e-6); // second cell's pen
|
||||
}
|
||||
|
||||
// The pen follows the basis, not the screen axes. A run staged with `axis` has to
|
||||
@@ -599,13 +599,13 @@ PLOT_TEST(text_batch_advances_along_its_basis) {
|
||||
PLOT_CHECK_NEAR(up.x, pen.x, 1e-6);
|
||||
PLOT_CHECK_NEAR(up.y, pen.y - width, 1e-4);
|
||||
for (plot::u32 i = 0; i < b.glyph_count(); ++i) {
|
||||
PLOT_CHECK_NEAR(b.data()[6u * i + 4u], 0.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(b.data()[6u * i + 5u], -1.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(b.data()[10u * i + 4u], 0.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(b.data()[10u * i + 5u], -1.0, 1e-6);
|
||||
}
|
||||
// The second cell is one advance further up than the pen, which is the same rule
|
||||
// the unrotated case follows in x.
|
||||
PLOT_CHECK_NEAR(b.data()[6], pen.x, 1e-6);
|
||||
PLOT_CHECK_NEAR(b.data()[7], pen.y - f.glyph('1')->advance, 1e-6);
|
||||
PLOT_CHECK_NEAR(b.data()[10], pen.x, 1e-6);
|
||||
PLOT_CHECK_NEAR(b.data()[11], pen.y - f.glyph('1')->advance, 1e-6);
|
||||
|
||||
// A diagonal direction is normalised, so a caller can hand over any direction
|
||||
// and the basis the shader receives is still unit.
|
||||
@@ -616,6 +616,33 @@ PLOT_TEST(text_batch_advances_along_its_basis) {
|
||||
PLOT_CHECK_NEAR(b.data()[5], 0.8, 1e-5);
|
||||
}
|
||||
|
||||
// The tint is per instance (`a_colour`, the last four floats of every glyph),
|
||||
// not a uniform: one batch can carry runs of several colours, which is what the
|
||||
// crosshair readout needs (values in each row's trace colour, labels in the axis
|
||||
// text colour, one draw call). The staged colour is exactly what the caller
|
||||
// passed, straight alpha, on every glyph of the run and nothing else.
|
||||
PLOT_TEST(text_batch_stages_its_colour_per_instance) {
|
||||
plot::render::EmbeddedMonoFont f;
|
||||
plot::render::TextBatch b;
|
||||
const plot::Color tint{0.25f, 0.5f, 0.75f, 0.9f};
|
||||
b.begin();
|
||||
b.add_text("AB", {0.0f, 0.0f}, 1.0f, f, {1.0f, 0.0f}, tint);
|
||||
PLOT_CHECK(b.glyph_count() == 2);
|
||||
for (plot::u32 i = 0; i < 2; ++i) {
|
||||
PLOT_CHECK_NEAR(b.data()[10u * i + 6u], tint.r, 1e-6);
|
||||
PLOT_CHECK_NEAR(b.data()[10u * i + 7u], tint.g, 1e-6);
|
||||
PLOT_CHECK_NEAR(b.data()[10u * i + 8u], tint.b, 1e-6);
|
||||
PLOT_CHECK_NEAR(b.data()[10u * i + 9u], tint.a, 1e-6);
|
||||
}
|
||||
// A second run in another colour keeps its own tint per instance -- the old
|
||||
// uniform-tint pipeline could not express this in one batch.
|
||||
b.add_text("CD", {20.0f, 0.0f}, 1.0f, f, {1.0f, 0.0f},
|
||||
plot::Color{1.0f, 1.0f, 1.0f, 1.0f});
|
||||
PLOT_CHECK(b.glyph_count() == 4);
|
||||
PLOT_CHECK_NEAR(b.data()[26u], 1.0, 1e-6); // 'C' is white where 'A' was not
|
||||
PLOT_CHECK_NEAR(b.data()[6u], 0.25, 1e-6); // ... and 'A' still carries its own
|
||||
}
|
||||
|
||||
/// A backend whose atlas is *not* a fixed-cell grid: it declares the cell its
|
||||
/// pipeline needs and then publishes one glyph trimmed to its ink, which is what
|
||||
/// a naive TrueType packing produces (`stbtt_GetCodepointBitmap` hands back an ink
|
||||
@@ -844,8 +871,8 @@ PLOT_TEST(add_text_skips_a_codepoint_the_backend_cannot_draw) {
|
||||
// glyph of its own, which the second instance's atlas rect says outright.
|
||||
PLOT_CHECK_NEAR(batch.data()[0], pen.x, 1e-6);
|
||||
PLOT_CHECK_NEAR(batch.data()[2], a->u0, 1e-6);
|
||||
PLOT_CHECK_NEAR(batch.data()[6], pen.x + a->advance, 1e-6);
|
||||
PLOT_CHECK_NEAR(batch.data()[8], b->u0, 1e-6);
|
||||
PLOT_CHECK_NEAR(batch.data()[10], pen.x + a->advance, 1e-6);
|
||||
PLOT_CHECK_NEAR(batch.data()[12], b->u0, 1e-6);
|
||||
|
||||
// A run of nothing but undrawable codepoints stages nothing at all and leaves
|
||||
// the pen where it was, rather than drawing at the pen.
|
||||
|
||||
@@ -57,3 +57,93 @@ PLOT_TEST(pointer_anchor_reports_no_movement_before_its_first_sample) {
|
||||
PLOT_CHECK_NEAR(delta.x, 0.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(delta.y, 0.0, 1e-9);
|
||||
}
|
||||
|
||||
// --- the pinch --------------------------------------------------------------
|
||||
|
||||
// Qt reports a pinch as cumulative numbers -- `totalScaleFactor()` since the
|
||||
// gesture began, `centerPoint()` where the fingers are now -- while the
|
||||
// controller's `GestureEvent` wants the increments: this frame's zoom factor
|
||||
// (1.0 = no change) and the focal point's movement since the previous event.
|
||||
// `PinchTracker` is the fold, and the ratios it produces are what a pinch feels
|
||||
// like through: a cumulative 1.0 -> 1.25 -> 1.5 spread is a 1.25 frame followed
|
||||
// by a 1.2 frame, never a 1.25 then a 1.5 (which would zoom twice as far as the
|
||||
// fingers did).
|
||||
PLOT_TEST(pinch_tracker_folds_cumulative_scale_into_incremental_zoom) {
|
||||
plot::qt::PinchTracker pinch;
|
||||
|
||||
// The begin anchors the fold and zooms nothing: a pinch that just started has
|
||||
// moved no scale yet, so the first event the controller could see is 1.0.
|
||||
pinch.begin(1.0f, {200.0f, 150.0f});
|
||||
PLOT_CHECK(pinch.active);
|
||||
const plot::input::GestureEvent started = pinch.update(1.0f, {200.0f, 150.0f}, 1.0);
|
||||
PLOT_CHECK_NEAR(started.scale, 1.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(started.dx, 0.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(started.dy, 0.0, 1e-6);
|
||||
|
||||
// Fingers spread to 1.25 cumulative, focal steady: one 1.25 zoom about the
|
||||
// focal point, and no pan.
|
||||
const plot::input::GestureEvent spread = pinch.update(1.25f, {200.0f, 150.0f}, 1.0);
|
||||
PLOT_CHECK_NEAR(spread.scale, 1.25, 1e-6);
|
||||
PLOT_CHECK_NEAR(spread.dx, 0.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(spread.dy, 0.0, 1e-6);
|
||||
|
||||
// Spread on to 1.5: the *increment* is 1.5 / 1.25, not 1.5 again.
|
||||
const plot::input::GestureEvent more = pinch.update(1.5f, {200.0f, 150.0f}, 1.0);
|
||||
PLOT_CHECK_NEAR(more.scale, 1.2, 1e-6);
|
||||
|
||||
// And a pinch in (0.75 of the cumulative): an incremental shrink.
|
||||
const plot::input::GestureEvent pinch_in = pinch.update(0.75f, {200.0f, 150.0f}, 1.0);
|
||||
PLOT_CHECK_NEAR(pinch_in.scale, 0.5, 1e-6);
|
||||
}
|
||||
|
||||
// The focal point's movement is the two-finger pan, and it is mapped through the
|
||||
// ratio in force *now* from an anchor kept in **logical** pixels -- the same
|
||||
// convention as PointerAnchor above, because the same defect applies: a pinch
|
||||
// that crosses onto a display with another scale factor must not read the ratio
|
||||
// change as a focal jump (which the controller would apply as a pan).
|
||||
PLOT_TEST(pinch_tracker_maps_focal_movement_through_the_current_dpr) {
|
||||
plot::qt::PinchTracker pinch;
|
||||
pinch.begin(1.0f, {200.0f, 150.0f});
|
||||
|
||||
// A plain pan at 1.0: ten logical pixels right, no zoom.
|
||||
const plot::input::GestureEvent moved = pinch.update(1.0f, {210.0f, 150.0f}, 1.0);
|
||||
PLOT_CHECK_NEAR(moved.scale, 1.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(moved.dx, 10.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(moved.dy, 0.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(moved.x, 210.0, 1e-6); // the focal point the zoom anchors on
|
||||
|
||||
// Mid-pinch the window lands on the 1.25 display. The fingers move 4 more
|
||||
// logical pixels: the movement is 4 * 1.25 = 5 device pixels, and the anchor
|
||||
// the next frame is measured from is the new logical point -- not the old one
|
||||
// rescaled, which would read the ratio change itself as a jump.
|
||||
const plot::input::GestureEvent rescaled = pinch.update(1.0f, {214.0f, 150.0f}, 1.25);
|
||||
PLOT_CHECK_NEAR(rescaled.scale, 1.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(rescaled.dx, 5.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(rescaled.dy, 0.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(rescaled.x, 214.0 * 1.25, 1e-6);
|
||||
PLOT_CHECK_NEAR(rescaled.y, 150.0 * 1.25, 1e-6);
|
||||
|
||||
// A diagonal movement, both axes mapped.
|
||||
const plot::input::GestureEvent diagonal = pinch.update(1.0f, {214.0f, 154.0f}, 1.25);
|
||||
PLOT_CHECK_NEAR(diagonal.dx, 0.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(diagonal.dy, 5.0, 1e-6);
|
||||
}
|
||||
|
||||
// An update outside a gesture -- before any begin, or after the end/cancel --
|
||||
// zooms and moves nothing: a host that missed the start (or a straggler event
|
||||
// Qt delivers out of order) must not zoom from a made-up anchor.
|
||||
PLOT_TEST(pinch_tracker_ignores_updates_outside_a_gesture) {
|
||||
plot::qt::PinchTracker pinch;
|
||||
const plot::input::GestureEvent orphan = pinch.update(2.0f, {10.0f, 10.0f}, 1.0);
|
||||
PLOT_CHECK_NEAR(orphan.scale, 1.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(orphan.dx, 0.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(orphan.dy, 0.0, 1e-9);
|
||||
|
||||
pinch.begin(1.0f, {0.0f, 0.0f});
|
||||
pinch.end();
|
||||
PLOT_CHECK(!pinch.active);
|
||||
const plot::input::GestureEvent straggler = pinch.update(2.0f, {10.0f, 10.0f}, 1.0);
|
||||
PLOT_CHECK_NEAR(straggler.scale, 1.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(straggler.dx, 0.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(straggler.dy, 0.0, 1e-9);
|
||||
}
|
||||
+447
-2
@@ -18,6 +18,7 @@
|
||||
#include <plot/render/renderer.hpp>
|
||||
#include <plot/input/controller.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cmath>
|
||||
#include <csignal>
|
||||
@@ -309,7 +310,6 @@ InkBox draw_text_run(plottest::HeadlessContext& ctx, plot::render::TextAtlas& at
|
||||
program.set_glyph_uv(cell);
|
||||
program.set_px_range({cell.x * texels.x * px, cell.y * texels.y * px});
|
||||
program.set_texture_size(texels);
|
||||
program.set_color(text);
|
||||
atlas.bind();
|
||||
vao.bind();
|
||||
plot::gl::glDrawArraysInstanced(plot::gl::GL_TRIANGLE_STRIP, 0, 4,
|
||||
@@ -2315,7 +2315,7 @@ PLOT_TEST(legend_swatch_and_label_are_drawn) {
|
||||
const plot::PlotLayout layout = p.layout();
|
||||
const plot::LegendLayout legend = layout.legend;
|
||||
PLOT_CHECK(legend.rows.size() == 2u);
|
||||
const plot::Legend& cfg = p.legend();
|
||||
const plot::Legend cfg = p.legend(); // copy: set_legend() below swaps the member
|
||||
const plot::u32 swatch_area = static_cast<plot::u32>(cfg.swatch_w) *
|
||||
static_cast<plot::u32>(cfg.swatch_h);
|
||||
|
||||
@@ -5364,3 +5364,448 @@ PLOT_TEST(dash_phase_is_stable_across_frames) {
|
||||
|
||||
ctx.destroy();
|
||||
}
|
||||
|
||||
// ------------------------------------------------- B1: relative-x staging (epoch axes)
|
||||
|
||||
/// The B1 defect: gathering and staging an absolute epoch x as f32 quantised it to
|
||||
/// a grid coarser than a pixel (f32 carries 24 bits -- about 128 s at 1.7e9), so an
|
||||
/// epoch-window trace collapsed onto a few columns while its zero-origin twin drew
|
||||
/// correctly. The fix stages `to_space(x_abs) - to_space(origin)` -- O(visible
|
||||
/// pixels) -- and folds the origin into the offset uniform in f64, so the two
|
||||
/// windows rasterise to the same geometry.
|
||||
///
|
||||
/// Both plots below draw the *same samples* with the same width; the only
|
||||
/// difference is the series' `x0()` and the axis window's offset. The assertion is
|
||||
/// the per-column trace-ink pattern: identical column sets, not merely similar
|
||||
/// counts, which a quantised draw could not satisfy.
|
||||
namespace {
|
||||
|
||||
/// Columns (every x of the axes) that hold at least one trace-coloured pixel.
|
||||
std::vector<bool> trace_column_mask(plottest::HeadlessContext& ctx, const plot::Rect& a) {
|
||||
const plot::u32 first_row = static_cast<plot::u32>(a.y);
|
||||
const plot::u32 rows = static_cast<plot::u32>(a.h);
|
||||
std::vector<bool> mask(a.w > 0.0f ? static_cast<std::size_t>(a.w) : 1u, false);
|
||||
for (plot::u32 x = 0; x < static_cast<plot::u32>(a.w); ++x) {
|
||||
const plot::u32 px_x = static_cast<plot::u32>(a.x) + x;
|
||||
for (plot::u32 yy = 0; yy < rows; ++yy) {
|
||||
plot::u8 px[4] = {};
|
||||
if (ctx.read_pixel(px_x, ctx.height - 1u - (first_row + yy), px) && trace_pixel(px)) {
|
||||
mask[x] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
PLOT_TEST(epoch_window_and_zero_origin_twin_rasterise_identically) {
|
||||
plottest::HeadlessContext ctx(kW, kH);
|
||||
plot::Status st;
|
||||
PLOT_CHECK(ctx.create(&st));
|
||||
PLOT_CHECK(plot::gl::load(nullptr));
|
||||
plot::Renderer r;
|
||||
PLOT_CHECK(r.init(&st));
|
||||
|
||||
// A sparse sine: one raw sample per column, so every stage of the pipeline is
|
||||
// exercised at pixel resolution -- the f32 defect shows on the x coordinates.
|
||||
std::vector<plot::f32> ys(640u);
|
||||
for (plot::u32 i = 0; i < ys.size(); ++i) {
|
||||
ys[i] = 0.8f * std::sin(0.05f * static_cast<plot::f32>(i));
|
||||
}
|
||||
const f64 epoch = 1'700'000'000.0;
|
||||
|
||||
plot::data::ArraySeries zero(ys, 0.0, 1.0);
|
||||
plot::data::ArraySeries shifted(ys, epoch, 1.0);
|
||||
|
||||
plot::Plot p0;
|
||||
p0.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
const plot::u32 ax0 = p0.add_x_axis({"t", {0.0, 639.0}, true, plot::AxisPlace::Bottom, false});
|
||||
const plot::u32 ay0 = p0.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
|
||||
p0.add_trace({&zero, plot::Color::hex(0x40C0FF), 1.5f, ax0, ay0});
|
||||
|
||||
plot::Plot p1;
|
||||
p1.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
const plot::u32 ax1 =
|
||||
p1.add_x_axis({"t", {epoch, epoch + 639.0}, true, plot::AxisPlace::Bottom, false});
|
||||
const plot::u32 ay1 = p1.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
|
||||
p1.add_trace({&shifted, plot::Color::hex(0x40C0FF), 1.5f, ax1, ay1});
|
||||
|
||||
const plot::Rect vp{0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
|
||||
r.begin_frame(kW, kH, vp);
|
||||
r.draw(p0);
|
||||
r.end_frame();
|
||||
const std::vector<bool> columns_zero = trace_column_mask(ctx, p0.layout().axes);
|
||||
|
||||
r.begin_frame(kW, kH, vp);
|
||||
r.draw(p1);
|
||||
r.end_frame();
|
||||
const std::vector<bool> columns_epoch = trace_column_mask(ctx, p1.layout().axes);
|
||||
|
||||
PLOT_CHECK(columns_zero == columns_epoch);
|
||||
// The premise: the twin really drew a trace across the axes (the comparison is
|
||||
// not vacuously true on two empty frames).
|
||||
PLOT_CHECK(std::count(columns_zero.begin(), columns_zero.end(), true) >
|
||||
static_cast<std::size_t>(p0.layout().axes.w) * 9u / 10u);
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
ctx.destroy();
|
||||
}
|
||||
|
||||
// ------------------------------------------------- B6: two text batches
|
||||
|
||||
/// The B6 defect: one text batch, flushed after everything, put the legend's
|
||||
/// names *over* the crosshair's readout panel -- text above the backplate that
|
||||
/// exists to occlude it. The under batch (legend names included) is now flushed
|
||||
/// immediately before the panel, so a legend name the panel covers is occluded.
|
||||
///
|
||||
/// The scene: a legend in the bottom-left corner with three wide names, and a
|
||||
/// crosshair parked just inside that corner, whose readout panel flips up and
|
||||
/// right -- on top of the legend rows. The assertion reads the legend name boxes
|
||||
/// *minus* every rect the readout legitimately paints (its own label and value
|
||||
/// runs, and the crosshair's strokes): what is left of a covered name box must
|
||||
/// hold no text ink with the readout on, and held ink without it.
|
||||
namespace {
|
||||
|
||||
/// Text ink inside `area`, skipping every pixel inside any of `exclude`.
|
||||
plot::u32 text_ink_outside(const std::vector<plot::u8>& frame, const plot::Rect& area,
|
||||
const std::vector<plot::Rect>& exclude) {
|
||||
plot::u32 hit = 0;
|
||||
const plot::u32 x0 = static_cast<plot::u32>(std::max(0.0f, area.x));
|
||||
const plot::u32 y0 = static_cast<plot::u32>(std::max(0.0f, area.y));
|
||||
const plot::u32 x1 = static_cast<plot::u32>(std::min(area.right(), kFrameRect.right()));
|
||||
const plot::u32 y1 = static_cast<plot::u32>(std::min(area.bottom(), kFrameRect.bottom()));
|
||||
for (plot::u32 y = y0; y < y1; ++y) {
|
||||
for (plot::u32 x = x0; x < x1; ++x) {
|
||||
bool skipped = false;
|
||||
for (const plot::Rect& e : exclude) {
|
||||
if (e.contains(static_cast<plot::f32>(x) + 0.5f, static_cast<plot::f32>(y) + 0.5f)) {
|
||||
skipped = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (skipped) continue;
|
||||
if (text_pixel(&frame[(plot::usize(y) * kW + x) * 4u])) ++hit;
|
||||
}
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
PLOT_TEST(readout_panel_occludes_the_legend_names_it_covers) {
|
||||
plottest::HeadlessContext ctx(kW, kH);
|
||||
plot::Status st;
|
||||
PLOT_CHECK(ctx.create(&st));
|
||||
PLOT_CHECK(plot::gl::load(nullptr));
|
||||
plot::Renderer r;
|
||||
PLOT_CHECK(r.init(&st));
|
||||
|
||||
plot::Plot p;
|
||||
const plot::Rect vp{0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
|
||||
p.set_viewport(vp);
|
||||
const plot::u32 ax = p.add_x_axis({"t", {0.0, 639.0}, true, plot::AxisPlace::Bottom, false});
|
||||
const plot::u32 ay = p.add_y_axis({"v", {0.0, 1.0}, true, plot::AxisPlace::Left, false});
|
||||
plot::Legend legend;
|
||||
legend.place = plot::LegendPlace::BottomLeft;
|
||||
p.set_legend(legend);
|
||||
// Flat traces at distinct heights, wide names: three tall legend rows.
|
||||
plot::data::ArraySeries a(std::vector<plot::f32>(640u, 0.7f), 0.0, 1.0);
|
||||
plot::data::ArraySeries b(std::vector<plot::f32>(640u, 0.5f), 0.0, 1.0);
|
||||
plot::data::ArraySeries c(std::vector<plot::f32>(640u, 0.3f), 0.0, 1.0);
|
||||
p.add_trace({&a, plot::Color::hex(0x40C0FF), 1.5f, ax, ay, "channel one"});
|
||||
p.add_trace({&b, plot::Color::hex(0xFF5090), 1.5f, ax, ay, "channel two long name"});
|
||||
p.add_trace({&c, plot::Color::hex(0x50D050), 1.5f, ax, ay, "channel three"});
|
||||
|
||||
// Warm-up frame (installs the text backend), then the legend-only frame.
|
||||
r.begin_frame(kW, kH, vp);
|
||||
r.draw(p);
|
||||
r.end_frame();
|
||||
r.begin_frame(kW, kH, vp);
|
||||
r.draw(p);
|
||||
r.end_frame();
|
||||
const std::vector<plot::u8> legend_only = read_frame(ctx);
|
||||
const plot::PlotLayout layout = p.layout();
|
||||
PLOT_CHECK(layout.legend.rows.size() == 3u);
|
||||
PLOT_CHECK(layout.legend.panel.w > 0.0f);
|
||||
|
||||
// The crosshair just inside the bottom-left corner: its panel flips up and
|
||||
// right, over the legend rows.
|
||||
const plot::Transform tx = p.x_transform(ax);
|
||||
const plot::Transform ty = p.y_transform(ay);
|
||||
p.set_crosshair(tx.to_data_x(layout.axes.x + 30.0f),
|
||||
ty.to_data_y(layout.axes.bottom() - 30.0f), true);
|
||||
r.begin_frame(kW, kH, vp);
|
||||
r.draw(p);
|
||||
r.end_frame();
|
||||
const std::vector<plot::u8> with_readout = read_frame(ctx);
|
||||
|
||||
// Fresh layout: the crosshair's own geometry only exists now that it is placed
|
||||
// (the copy above was taken before `set_crosshair`).
|
||||
const plot::CrosshairLayout crosshair = p.layout().crosshair;
|
||||
PLOT_CHECK(crosshair.active);
|
||||
PLOT_CHECK(crosshair.panel.w > 0.0f);
|
||||
// The exclusions: the readout's own runs and the crosshair's strokes.
|
||||
std::vector<plot::Rect> exclude;
|
||||
for (const plot::CrosshairRow& row : crosshair.rows) {
|
||||
if (row.listed) {
|
||||
exclude.push_back(row.label_box);
|
||||
exclude.push_back(row.value_box);
|
||||
exclude.push_back(row.swatch); // the row's own swatch fill
|
||||
}
|
||||
}
|
||||
exclude.push_back(crosshair.vertical);
|
||||
exclude.push_back(crosshair.horizontal);
|
||||
|
||||
// The premise: the panel really does cover at least one legend name box.
|
||||
bool covers = false;
|
||||
for (const plot::LegendRow& row : layout.legend.rows) {
|
||||
if (row.ellipsis) continue;
|
||||
if (crosshair.panel.x < row.name.right() && row.name.x < crosshair.panel.right() &&
|
||||
crosshair.panel.y < row.name.bottom() && row.name.y < crosshair.panel.bottom()) {
|
||||
covers = true;
|
||||
}
|
||||
}
|
||||
PLOT_CHECK(covers);
|
||||
|
||||
// Each legend name box: ink without the readout; in the part the panel covers,
|
||||
// none of it left (outside the readout's own runs) once the panel is over it.
|
||||
// The uncovered remainder of a partially covered box keeps its ink -- that is
|
||||
// the panel doing its job and nothing more.
|
||||
bool checked = false;
|
||||
for (const plot::LegendRow& row : layout.legend.rows) {
|
||||
const bool overlaps = crosshair.panel.x < row.name.right() &&
|
||||
row.name.x < crosshair.panel.right() &&
|
||||
crosshair.panel.y < row.name.bottom() &&
|
||||
row.name.y < crosshair.panel.bottom();
|
||||
if (row.ellipsis || !overlaps) continue;
|
||||
// Inset past the panel's own border ring: the border is a hairline of the
|
||||
// bright crosshair colour (see draw_readout_panel), which the ink predicate
|
||||
// cannot tell from text -- it is the panel's chrome, not legend ink.
|
||||
const plot::Rect inner{crosshair.panel.x + 2.0f, crosshair.panel.y + 2.0f,
|
||||
crosshair.panel.w - 4.0f, crosshair.panel.h - 4.0f};
|
||||
const plot::Rect covered{
|
||||
std::max(inner.x, row.name.x), std::max(inner.y, row.name.y),
|
||||
std::min(inner.right(), row.name.right()) - std::max(inner.x, row.name.x),
|
||||
std::min(inner.bottom(), row.name.bottom()) - std::max(inner.y, row.name.y)};
|
||||
PLOT_CHECK(covered.w > 0.0f && covered.h > 0.0f);
|
||||
PLOT_CHECK(text_ink_outside(legend_only, covered, {}) > 0u);
|
||||
PLOT_CHECK(text_ink_outside(with_readout, covered, exclude) == 0u);
|
||||
checked = true;
|
||||
}
|
||||
PLOT_CHECK(checked);
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
ctx.destroy();
|
||||
}
|
||||
|
||||
// ------------------------------------------------- markers
|
||||
|
||||
/// Markers are staged as line segments through the trace's own batch: a
|
||||
/// marker'd trace lights pixels a plain line does not (the shapes extend past
|
||||
/// the stroke), and the stride is literal -- `marker_every = 3` stages exactly a
|
||||
/// third of the markers of `marker_every = 1` over the same window.
|
||||
PLOT_TEST(markers_light_more_than_the_line_and_stride_is_literal) {
|
||||
plottest::HeadlessContext ctx(kW, kH);
|
||||
plot::Status st;
|
||||
PLOT_CHECK(ctx.create(&st));
|
||||
PLOT_CHECK(plot::gl::load(nullptr));
|
||||
plot::Renderer r;
|
||||
PLOT_CHECK(r.init(&st));
|
||||
|
||||
// 32 sparse points: the auto stride would be ~1 marker per point anyway, so
|
||||
// `marker_every = 1` vs `3` differ by construction.
|
||||
std::vector<plot::f32> ys(32u);
|
||||
for (plot::u32 i = 0; i < ys.size(); ++i) {
|
||||
ys[i] = 0.5f + 0.4f * std::sin(0.9f * static_cast<plot::f32>(i));
|
||||
}
|
||||
plot::data::ArraySeries s(ys, 0.0, 20.0);
|
||||
|
||||
plot::Plot plain_p;
|
||||
plain_p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
const plot::u32 ax = plain_p.add_x_axis({"t", {0.0, 620.0}, true, plot::AxisPlace::Bottom, false});
|
||||
const plot::u32 ay = plain_p.add_y_axis({"v", {0.0, 1.0}, true, plot::AxisPlace::Left, false});
|
||||
plot::Plot marked_p = plain_p;
|
||||
plot::Plot every3_p = plain_p;
|
||||
marked_p.add_trace({&s, plot::Color::hex(0xFF5090), 1.5f, ax, ay, "", plot::TraceMode::Envelope,
|
||||
0.0f, 0.0f, plot::Marker::Circle, 4.0f, 1u});
|
||||
every3_p.add_trace({&s, plot::Color::hex(0xFF5090), 1.5f, ax, ay, "", plot::TraceMode::Envelope,
|
||||
0.0f, 0.0f, plot::Marker::Circle, 4.0f, 3u});
|
||||
plain_p.add_trace({&s, plot::Color::hex(0xFF5090), 1.5f, ax, ay});
|
||||
|
||||
const plot::Rect vp{0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
|
||||
r.begin_frame(kW, kH, vp);
|
||||
r.draw(plain_p);
|
||||
r.end_frame();
|
||||
const plot::u32 plain_ink = predicate_pixels(read_frame(ctx), kW, plain_p.layout().axes,
|
||||
magenta_pixel);
|
||||
|
||||
r.begin_frame(kW, kH, vp);
|
||||
r.draw(marked_p);
|
||||
r.end_frame();
|
||||
const plot::u32 marked_ink = predicate_pixels(read_frame(ctx), kW, marked_p.layout().axes,
|
||||
magenta_pixel);
|
||||
const plot::u32 marked_count = r.stats().staged_markers;
|
||||
|
||||
r.begin_frame(kW, kH, vp);
|
||||
r.draw(every3_p);
|
||||
r.end_frame();
|
||||
const plot::u32 every3_count = r.stats().staged_markers;
|
||||
|
||||
// The premise: the line drew something; the markers drew strictly more.
|
||||
PLOT_CHECK(plain_ink > 0u);
|
||||
PLOT_CHECK(marked_ink > plain_ink);
|
||||
// The stride is literal: a third of the markers, ±1 for the window's edge
|
||||
// sample count.
|
||||
PLOT_CHECK(marked_count >= 30u);
|
||||
PLOT_CHECK(every3_count * 3u + 2u >= marked_count);
|
||||
PLOT_CHECK(every3_count * 3u <= marked_count + 2u);
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
ctx.destroy();
|
||||
}
|
||||
|
||||
// ------------------------------------------------- text colour
|
||||
|
||||
/// The readout's VALUE column draws in the row trace's own colour (resolved from
|
||||
/// the plot at draw time), the labels in the axis text colour: per-instance
|
||||
/// `a_colour` instead of one text tint per draw.
|
||||
PLOT_TEST(readout_value_draws_in_the_row_trace_colour) {
|
||||
plottest::HeadlessContext ctx(kW, kH);
|
||||
plot::Status st;
|
||||
PLOT_CHECK(ctx.create(&st));
|
||||
PLOT_CHECK(plot::gl::load(nullptr));
|
||||
plot::Renderer r;
|
||||
PLOT_CHECK(r.init(&st));
|
||||
|
||||
plot::Plot p;
|
||||
const plot::Rect vp{0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
|
||||
p.set_viewport(vp);
|
||||
const plot::u32 ax = p.add_x_axis({"t", {0.0, 639.0}, true, plot::AxisPlace::Bottom, false});
|
||||
const plot::u32 ay = p.add_y_axis({"v", {0.0, 1.0}, true, plot::AxisPlace::Left, false});
|
||||
plot::data::ArraySeries s(std::vector<plot::f32>(640u, 0.5f), 0.0, 1.0);
|
||||
const auto magenta = plot::Color::hex(0xFF5090);
|
||||
p.add_trace({&s, magenta, 1.5f, ax, ay, "row"});
|
||||
p.set_crosshair(300.0, 0.5, true);
|
||||
p.set_crosshair_readout(true);
|
||||
|
||||
r.begin_frame(kW, kH, vp);
|
||||
r.draw(p);
|
||||
r.end_frame();
|
||||
const std::vector<plot::u8> frame = read_frame(ctx);
|
||||
const plot::CrosshairLayout& crosshair = p.layout().crosshair;
|
||||
PLOT_CHECK(crosshair.rows.size() == 1u);
|
||||
PLOT_CHECK(crosshair.rows[0].listed);
|
||||
PLOT_CHECK(crosshair.rows[0].value_box.w > 0.0f);
|
||||
|
||||
// The value box holds pixels the row trace's colour explains (magenta
|
||||
// dominance at any blend over the dark panel) -- the old single-tint pipeline
|
||||
// drew every glyph in the axis text grey, so this is the defect's own pixel.
|
||||
plot::u32 coloured = 0;
|
||||
const plot::Rect box = crosshair.rows[0].value_box;
|
||||
for (plot::u32 y = static_cast<plot::u32>(box.y); y < static_cast<plot::u32>(box.bottom()); ++y) {
|
||||
for (plot::u32 x = static_cast<plot::u32>(box.x); x < static_cast<plot::u32>(box.right());
|
||||
++x) {
|
||||
plot::u8 px[4] = {};
|
||||
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, px));
|
||||
if (magenta_pixel(px)) ++coloured;
|
||||
}
|
||||
}
|
||||
PLOT_CHECK(coloured > 0u);
|
||||
// The label box, same frame, is the axis text colour, not the trace's.
|
||||
plot::u32 label_grey = 0;
|
||||
const plot::Rect labels = crosshair.rows[0].label_box;
|
||||
for (plot::u32 y = static_cast<plot::u32>(labels.y); y < static_cast<plot::u32>(labels.bottom());
|
||||
++y) {
|
||||
for (plot::u32 x = static_cast<plot::u32>(labels.x); x < static_cast<plot::u32>(labels.right());
|
||||
++x) {
|
||||
plot::u8 px[4] = {};
|
||||
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, px));
|
||||
if (text_pixel(px)) ++label_grey;
|
||||
}
|
||||
}
|
||||
PLOT_CHECK(label_grey > 0u);
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
ctx.destroy();
|
||||
}
|
||||
|
||||
// ------------------------------------------------- R6: GL state residue and shutdown
|
||||
|
||||
/// `begin_frame()` saves the host's blend function and enable bit; `end_frame()`
|
||||
/// hands both back -- a host that blends its own way before the frame must not
|
||||
/// inherit the frame's `SRC_ALPHA / ONE_MINUS_SRC_ALPHA`.
|
||||
PLOT_TEST(a_frame_hands_the_blend_state_back) {
|
||||
plottest::HeadlessContext ctx(kW, kH);
|
||||
plot::Status st;
|
||||
PLOT_CHECK(ctx.create(&st));
|
||||
PLOT_CHECK(plot::gl::load(nullptr));
|
||||
plot::Renderer r;
|
||||
PLOT_CHECK(r.init(&st));
|
||||
|
||||
plot::Plot p;
|
||||
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
const plot::u32 ax = p.add_x_axis({"t", {0.0, 1.0}, true, plot::AxisPlace::Bottom, false});
|
||||
const plot::u32 ay = p.add_y_axis({"v", {0.0, 1.0}, true, plot::AxisPlace::Left, false});
|
||||
plot::data::ArraySeries s(std::vector<plot::f32>(16u, 0.5f), 0.0, 1.0);
|
||||
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
|
||||
|
||||
// The host's own blend: function ONE/ZERO, enabled.
|
||||
plot::gl::glEnable(plot::gl::GL_BLEND);
|
||||
plot::gl::glBlendFunc(plot::gl::GL_ONE, plot::gl::GL_ZERO);
|
||||
const plot::Rect vp{0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
|
||||
r.begin_frame(kW, kH, vp);
|
||||
r.draw(p);
|
||||
r.end_frame();
|
||||
plot::gl::GLint src = -1;
|
||||
plot::gl::GLint dst = -1;
|
||||
plot::gl::glGetIntegerv(plot::gl::GL_BLEND_SRC_RGB, &src);
|
||||
plot::gl::glGetIntegerv(plot::gl::GL_BLEND_DST_RGB, &dst);
|
||||
PLOT_CHECK(src == static_cast<plot::gl::GLint>(plot::gl::GL_ONE));
|
||||
PLOT_CHECK(dst == static_cast<plot::gl::GLint>(plot::gl::GL_ZERO));
|
||||
PLOT_CHECK(plot::gl::glIsEnabled(plot::gl::GL_BLEND) != plot::gl::GL_FALSE);
|
||||
|
||||
// And with blending off, it comes back off.
|
||||
plot::gl::glDisable(plot::gl::GL_BLEND);
|
||||
r.begin_frame(kW, kH, vp);
|
||||
r.draw(p);
|
||||
r.end_frame();
|
||||
PLOT_CHECK(plot::gl::glIsEnabled(plot::gl::GL_BLEND) == plot::gl::GL_FALSE);
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
ctx.destroy();
|
||||
}
|
||||
|
||||
/// `release_gl()` destroys every GL handle while the context is current: after
|
||||
/// it the renderer is uninitialised, the driver holds no plot objects (a
|
||||
/// re-init and frame still draw, which is the observable half), and the same
|
||||
/// renderer object can be brought back.
|
||||
PLOT_TEST(release_gl_shuts_down_and_the_renderer_can_reinit) {
|
||||
plottest::HeadlessContext ctx(kW, kH);
|
||||
plot::Status st;
|
||||
PLOT_CHECK(ctx.create(&st));
|
||||
PLOT_CHECK(plot::gl::load(nullptr));
|
||||
plot::Renderer r;
|
||||
PLOT_CHECK(r.init(&st));
|
||||
|
||||
plot::Plot p;
|
||||
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
const plot::u32 ax = p.add_x_axis({"t", {0.0, 1.0}, true, plot::AxisPlace::Bottom, false});
|
||||
const plot::u32 ay = p.add_y_axis({"v", {0.0, 1.0}, true, plot::AxisPlace::Left, false});
|
||||
plot::data::ArraySeries s(std::vector<plot::f32>(16u, 0.5f), 0.0, 1.0);
|
||||
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
|
||||
const plot::Rect vp{0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
|
||||
r.begin_frame(kW, kH, vp);
|
||||
r.draw(p);
|
||||
r.end_frame();
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
|
||||
r.release_gl();
|
||||
PLOT_CHECK(!r.ready());
|
||||
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
|
||||
|
||||
// Re-init on the same object draws again -- the handles were released, not
|
||||
// half-destroyed.
|
||||
PLOT_CHECK(r.init(&st));
|
||||
r.begin_frame(kW, kH, vp);
|
||||
r.draw(p);
|
||||
r.end_frame();
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
ctx.destroy();
|
||||
}
|
||||
@@ -20,3 +20,18 @@ PLOT_TEST(ring_clear_resets) {
|
||||
PLOT_CHECK(r.size() == 1);
|
||||
PLOT_CHECK_NEAR(r[0], 42.0);
|
||||
}
|
||||
|
||||
PLOT_TEST(ring_capacity_rounds_up_to_a_power_of_two) {
|
||||
// The capacity is rounded up so slot addressing stays a mask, and capacity()
|
||||
// reports the rounded value -- the one callers must size for (R4).
|
||||
PLOT_CHECK(plot::data::RingBuffer<f32>(100).capacity() == 128);
|
||||
PLOT_CHECK(plot::data::RingBuffer<f32>(1).capacity() == 1);
|
||||
PLOT_CHECK(plot::data::RingBuffer<f32>(0).capacity() == 1);
|
||||
PLOT_CHECK(plot::data::RingBuffer<f32>(64).capacity() == 64); // already a power
|
||||
|
||||
plot::data::RingBuffer<f32> r(100);
|
||||
for (int i = 0; i < 1000; ++i) r.push(f32(i));
|
||||
PLOT_CHECK(r.size() == 128); // size() saturates at the rounded capacity
|
||||
PLOT_CHECK_NEAR(r[0], 872.0); // oldest retained
|
||||
PLOT_CHECK_NEAR(r[127], 999.0); // newest
|
||||
}
|
||||
+429
-39
@@ -26,9 +26,10 @@ PLOT_TEST(array_series_bounds_and_gather) {
|
||||
std::vector<f32> out;
|
||||
s.gather(0, 0, 4, out);
|
||||
PLOT_CHECK(out.size() == 8); // interleaved x,y
|
||||
PLOT_CHECK_NEAR(out[0], 10.0, 1e-6);
|
||||
// x is relative to x0(): sample 0 emits 0, not 10 (the relative-x contract).
|
||||
PLOT_CHECK_NEAR(out[0], 0.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(out[1], 1.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(out[6], 11.5, 1e-6);
|
||||
PLOT_CHECK_NEAR(out[6], 1.5, 1e-6); // sample 3: x_at(3) - x0() = 3 * 0.5
|
||||
}
|
||||
|
||||
PLOT_TEST(stream_series_acquire_publishes_snapshot) {
|
||||
@@ -105,9 +106,11 @@ PLOT_TEST(stream_series_exact_queries_survive_ring_wrap) {
|
||||
std::vector<f32> out;
|
||||
s.gather(0, 0, n, out); // level 0: raw samples
|
||||
PLOT_CHECK(out.size() == 2 * n);
|
||||
PLOT_CHECK_NEAR(out[0], s.x0(), 1e-6);
|
||||
// x is relative to x0(): the oldest retained sample emits 0, the newest
|
||||
// (n - 1) * dx, never the absolute data coordinates (relative-x contract).
|
||||
PLOT_CHECK_NEAR(out[0], 0.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(out[1], f32(kFirstRetained % 97), 1e-6);
|
||||
PLOT_CHECK_NEAR(out[2 * n - 2], s.x0() + f64(n - 1), 1e-6);
|
||||
PLOT_CHECK_NEAR(out[2 * n - 2], f64(n - 1) * 1.0, 1e-6);
|
||||
}
|
||||
|
||||
PLOT_TEST(stream_series_push_and_read_are_race_free) {
|
||||
@@ -250,38 +253,43 @@ PLOT_TEST(stream_series_sliding_window_queries_match_bruteforce) {
|
||||
|
||||
// level-1 gather: the envelope pairs of the retained buckets, bracketed by the
|
||||
// two partial edge buckets as raw samples, so the geometry spans the range
|
||||
// exactly. This is the mapping the renderer draws from.
|
||||
// exactly. This is the mapping the renderer draws from. Bucket selection is
|
||||
// ceil/floor on the range edges (B3): `first` is the first level-1 bucket
|
||||
// wholly at or after window index 0, `last` one past the last wholly inside
|
||||
// the window -- so every emitted bucket's samples lie inside the window.
|
||||
std::vector<f32> out;
|
||||
s.gather(1, 0, n, out);
|
||||
const u64 first = std::max(base / span, lbase);
|
||||
const u64 last = std::min((base + n - 1) / span + 1, lbase + lsize);
|
||||
const u64 first =
|
||||
std::max(base / span + (base % span == 0 ? u64{0} : u64{1}), lbase); // ceil, then clamp
|
||||
const u64 last = std::min((base + n) / span, lbase + lsize);
|
||||
const u64 lead = first * span - base; // raw pairs before the first whole bucket
|
||||
const u64 trail = last * span - base; // first window index after the last one
|
||||
const u64 tail = trail < n ? n - trail : 0;
|
||||
const u64 env = last - first;
|
||||
PLOT_CHECK(out.size() == 2 * (lead + 2 * env + tail)); // each bucket is two pairs
|
||||
for (u64 i = 0; i < lead; ++i) { // the partial leading bucket, raw
|
||||
PLOT_CHECK_NEAR(out[2 * i], f32(f64(base + i) * 1e-6), 1e-9);
|
||||
// x relative to x0(): window index i emits i * dx, not the absolute
|
||||
// (base + i) * dx (relative-x contract).
|
||||
PLOT_CHECK_NEAR(out[2 * i], f32(f64(i) * 1e-6), 1e-9);
|
||||
PLOT_CHECK_NEAR(out[2 * i + 1], value(base + i), 1e-6);
|
||||
}
|
||||
for (u64 j = 0; j < env; ++j) {
|
||||
// Each whole bucket contributes two pairs: (first sample, min) and (last
|
||||
// sample, max). x is in data coordinates -- x0() is the retained window's
|
||||
// first sample -- so window index i plots at (base + i) * dx.
|
||||
// sample, max). x is relative to x0(): window index i plots at i * dx.
|
||||
const u64 bi = first + j;
|
||||
const std::array<f64, 4> want = brute(bi * span - base, (bi + 1) * span - base);
|
||||
const u64 first_sample = bi * span - base;
|
||||
const u64 last_sample = std::min((bi + 1) * span, base + n) - 1 - base;
|
||||
const plot::usize lo = static_cast<plot::usize>(2 * (lead + 2 * j));
|
||||
const plot::usize hi = lo + 2;
|
||||
PLOT_CHECK_NEAR(out[lo], f32(f64(base + first_sample) * 1e-6), 1e-9);
|
||||
PLOT_CHECK_NEAR(out[lo], f32(f64(first_sample) * 1e-6), 1e-9);
|
||||
PLOT_CHECK_NEAR(out[lo + 1], want[0], 1e-6);
|
||||
PLOT_CHECK_NEAR(out[hi], f32(f64(base + last_sample) * 1e-6), 1e-9);
|
||||
PLOT_CHECK_NEAR(out[hi], f32(f64(last_sample) * 1e-6), 1e-9);
|
||||
PLOT_CHECK_NEAR(out[hi + 1], want[1], 1e-6);
|
||||
}
|
||||
for (u64 i = 0; i < tail; ++i) { // the partial trailing bucket: the newest samples
|
||||
const plot::usize k = static_cast<plot::usize>(2 * (lead + 2 * env + i));
|
||||
PLOT_CHECK_NEAR(out[k], f32(f64(base + trail + i) * 1e-6), 1e-9);
|
||||
PLOT_CHECK_NEAR(out[k], f32(f64(trail + i) * 1e-6), 1e-9);
|
||||
PLOT_CHECK_NEAR(out[k + 1], value(base + trail + i), 1e-6);
|
||||
}
|
||||
}
|
||||
@@ -318,29 +326,6 @@ PLOT_TEST(stream_series_steady_state_acquire_is_proportional_to_produced) {
|
||||
PLOT_CHECK(big <= 2 * small + kBucket);
|
||||
}
|
||||
|
||||
PLOT_TEST(stream_series_steady_state_acquire_is_fast) {
|
||||
// Wall-clock backstop for the merge count above: rebuilding the 1M-sample
|
||||
// window measured ~44 ms, so 128 such frames would need ~5.6 s. The sliding
|
||||
// path does the same work in a few milliseconds; the bound is two orders of
|
||||
// magnitude clear of both, so it is not a thin timing margin.
|
||||
plot::data::StreamSeries s(1u << 20, 0.0, 1.0);
|
||||
constexpr u64 kSaturate = 1u << 20;
|
||||
for (u64 i = 0; i < kSaturate; ++i) s.push(f32(i % 977));
|
||||
s.acquire();
|
||||
|
||||
constexpr u64 kFrames = 128;
|
||||
constexpr u64 kFrame = 1u << 13;
|
||||
const auto t0 = std::chrono::steady_clock::now();
|
||||
for (u64 f = 0; f < kFrames; ++f) {
|
||||
for (u64 k = 0; k < kFrame; ++k) s.push(f32((kSaturate + f * kFrame + k) % 977));
|
||||
s.acquire();
|
||||
}
|
||||
const f64 ms =
|
||||
std::chrono::duration<f64>(std::chrono::steady_clock::now() - t0).count() * 1.0e3;
|
||||
PLOT_CHECK(s.size() == 1u << 20);
|
||||
PLOT_CHECK(ms < 250.0);
|
||||
}
|
||||
|
||||
PLOT_TEST(stream_series_pyramid_memory_is_bounded_by_the_window) {
|
||||
// 50M samples through a 1M-sample ring: the pyramid's footprint must track the
|
||||
// retained window (n/64 level-0 buckets plus the levels over them), not the
|
||||
@@ -392,12 +377,13 @@ PLOT_TEST(gather_at_a_lod_level_reaches_both_ends_of_the_window) {
|
||||
s.gather(level, 0, n, out);
|
||||
PLOT_CHECK(out.size() >= 8u);
|
||||
// Oldest: the first emitted pair is the retained sample at index 0, not the
|
||||
// first whole bucket (which starts `lead` samples later).
|
||||
PLOT_CHECK_NEAR(out[0], f32(s.x0()), 1e-9);
|
||||
// first whole bucket (which starts `lead` samples later). x is relative to
|
||||
// x0(), so it emits 0 (relative-x contract).
|
||||
PLOT_CHECK_NEAR(out[0], 0.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(out[1], 25.0, 1e-6);
|
||||
// Newest: the last emitted pair is the sample at index n-1, not the last
|
||||
// complete bucket.
|
||||
PLOT_CHECK_NEAR(out[out.size() - 2], f32(s.x0() + f64(n - 1) * 1e-6), 1e-9);
|
||||
PLOT_CHECK_NEAR(out[out.size() - 2], f64(n - 1) * 1e-6, 1e-9);
|
||||
PLOT_CHECK_NEAR(out[out.size() - 1], -25.0, 1e-6);
|
||||
};
|
||||
PLOT_CHECK(s.pyramid().level_size(1) >= 2);
|
||||
@@ -423,3 +409,407 @@ PLOT_TEST(gather_at_a_lod_level_reaches_both_ends_of_the_window) {
|
||||
PLOT_CHECK_NEAR(out[out.size() - 2], f32(f64(ny - 1) * 1e-6), 1e-9);
|
||||
PLOT_CHECK_NEAR(out[out.size() - 1], -25.0, 1e-6);
|
||||
}
|
||||
|
||||
// --- B3: gather never emits geometry from outside [begin, end) ---------------
|
||||
|
||||
PLOT_TEST(neighbours_exact_hits_collapse_to_one_index) {
|
||||
// The contract says the bracket degenerates to (k, k) when x lands exactly on
|
||||
// a sample -- at the first sample too, where the old floor-only path answered
|
||||
// (0, 1) -- and stays (k, k+1) strictly between two samples.
|
||||
plot::data::ArraySeries s(std::vector<f32>{1, 2, 3, 4, 5}, 10.0, 0.25);
|
||||
u64 i0 = 9, i1 = 9;
|
||||
PLOT_CHECK(s.neighbours(s.x_at(0), i0, i1)); // first: was (0, 1)
|
||||
PLOT_CHECK(i0 == 0 && i1 == 0);
|
||||
PLOT_CHECK(s.neighbours(s.x_at(2), i0, i1)); // middle
|
||||
PLOT_CHECK(i0 == 2 && i1 == 2);
|
||||
PLOT_CHECK(s.neighbours(s.x_at(4), i0, i1)); // last
|
||||
PLOT_CHECK(i0 == 4 && i1 == 4);
|
||||
PLOT_CHECK(s.neighbours(s.x_at(1) + 0.1, i0, i1)); // strictly between
|
||||
PLOT_CHECK(i0 == 1 && i1 == 2);
|
||||
|
||||
// A step whose k*dx does not round-trip through the division: the exact-hit
|
||||
// test must compare samples, not trust t's fraction being an integer.
|
||||
plot::data::ArraySeries t(std::vector<f32>{0, 1, 2, 3, 4, 5, 6, 7}, -1.0, 0.1);
|
||||
for (u64 i = 0; i < 8; ++i) {
|
||||
PLOT_CHECK(t.neighbours(t.x_at(i), i0, i1));
|
||||
PLOT_CHECK(i0 == i && i1 == i);
|
||||
}
|
||||
PLOT_CHECK(t.neighbours((t.x_at(3) + t.x_at(4)) / 2.0, i0, i1));
|
||||
PLOT_CHECK(i0 == 3 && i1 == 4);
|
||||
}
|
||||
|
||||
PLOT_TEST(neighbours_outside_and_empty_are_false) {
|
||||
plot::data::ArraySeries s(std::vector<f32>{1, 2, 3}, 0.0, 1.0);
|
||||
u64 i0 = 7, i1 = 7;
|
||||
PLOT_CHECK(!s.neighbours(-0.5, i0, i1)); // left of the window
|
||||
PLOT_CHECK(!s.neighbours(3.5, i0, i1)); // right of the window
|
||||
PLOT_CHECK(!s.neighbours(std::nan(""), i0, i1));
|
||||
PLOT_CHECK(i0 == 7 && i1 == 7); // untouched on false
|
||||
plot::data::ArraySeries e({}, 0.0, 1.0);
|
||||
PLOT_CHECK(!e.neighbours(1.0, i0, i1));
|
||||
PLOT_CHECK(e.snap_index(1.0) == 0); // empty series snaps to 0
|
||||
}
|
||||
|
||||
PLOT_TEST(gather_envelope_stays_inside_an_unaligned_range) {
|
||||
// The defect this pins: the first bucket was floor-selected and the bucket
|
||||
// containing `end - 1` was emitted whole, so an unaligned range drew
|
||||
// aggregates covering samples outside it. The samples outside [begin, end)
|
||||
// here are extreme on purpose: any envelope value beyond the exact reduce is
|
||||
// geometry that leaked in from outside the range.
|
||||
constexpr u64 kN = 4096;
|
||||
std::vector<f32> y(kN);
|
||||
for (u64 i = 0; i < kN; ++i) {
|
||||
y[i] = i < 1000 || i >= 3000 ? (i % 2 == 0 ? 1000.0f : -1000.0f)
|
||||
: 0.25f * f32(i % 4) - 0.375f;
|
||||
}
|
||||
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
|
||||
const u64 b = 1000 + 37, e = 3000 - 61; // unaligned on every level's grid
|
||||
const plot::Range exact = s.reduce(b, e); // |values| <= ~0.375 inside the range
|
||||
PLOT_CHECK(exact.max < 1.0 && exact.min > -1.0);
|
||||
|
||||
const auto check_range = [&](u32 level) {
|
||||
std::vector<f32> out;
|
||||
s.gather(level, b, e, out);
|
||||
PLOT_CHECK(out.size() >= 4u);
|
||||
for (plot::usize i = 0; i + 1 < out.size(); i += 2) {
|
||||
PLOT_CHECK(f64(out[i]) >= f64(b)); // emitted x inside [begin, end)
|
||||
PLOT_CHECK(f64(out[i]) < f64(e));
|
||||
}
|
||||
for (plot::usize i = 1; i < out.size(); i += 2) {
|
||||
PLOT_CHECK(f64(out[i]) >= exact.min - 1e-6);
|
||||
PLOT_CHECK(f64(out[i]) <= exact.max + 1e-6);
|
||||
}
|
||||
};
|
||||
check_range(1);
|
||||
check_range(2);
|
||||
check_range(3);
|
||||
check_range(6);
|
||||
|
||||
// And the envelope values are not merely *inside* the range: each emitted
|
||||
// (min, max) pair equals the exact reduce over the samples that pair's two x
|
||||
// positions bracket. Walk the layout the contract fixes -- raw lead pairs,
|
||||
// two pairs per complete bucket, raw trail pairs -- so the raw edge spans
|
||||
// are not mistaken for envelope pairs.
|
||||
const u32 level = 2;
|
||||
const u64 span = u64{kBucket} << level;
|
||||
const u64 lend = s.pyramid().level_size(level);
|
||||
const u64 first = std::min(b / span + (b % span == 0 ? u64{0} : u64{1}), lend);
|
||||
const u64 last = std::min(e / span, lend);
|
||||
const u64 lead = first * span;
|
||||
const u64 buckets = last - first;
|
||||
PLOT_CHECK(lead > b && last * span < e); // both edge spans really are partial
|
||||
std::vector<f32> out;
|
||||
s.gather(level, b, e, out);
|
||||
PLOT_CHECK(out.size() == 2 * ((lead - b) + 2 * buckets + (e - last * span)));
|
||||
plot::usize o = 0;
|
||||
for (u64 i = b; i < lead; ++i, o += 2) { // the partial leading bucket, raw
|
||||
PLOT_CHECK_NEAR(out[o], f32(f64(i)), 1e-6);
|
||||
PLOT_CHECK(f64(out[o + 1]) >= exact.min - 1e-6 && f64(out[o + 1]) <= exact.max + 1e-6);
|
||||
}
|
||||
for (u64 j = 0; j < buckets; ++j) {
|
||||
const u64 bi = first + j;
|
||||
const u64 lo = bi * span;
|
||||
const u64 hi = std::min((bi + 1) * span, e) - 1;
|
||||
const plot::Range r = s.reduce(lo, hi + 1);
|
||||
PLOT_CHECK_NEAR(out[o], f32(f64(lo)), 1e-6);
|
||||
PLOT_CHECK_NEAR(out[o + 1], r.min, 1e-6);
|
||||
PLOT_CHECK_NEAR(out[o + 2], f32(f64(hi)), 1e-6);
|
||||
PLOT_CHECK_NEAR(out[o + 3], r.max, 1e-6);
|
||||
o += 4;
|
||||
}
|
||||
for (u64 i = last * span; i < e; ++i, o += 2) { // the partial trailing bucket
|
||||
PLOT_CHECK_NEAR(out[o], f32(f64(i)), 1e-6);
|
||||
PLOT_CHECK(f64(out[o + 1]) >= exact.min - 1e-6 && f64(out[o + 1]) <= exact.max + 1e-6);
|
||||
}
|
||||
PLOT_CHECK(o == out.size());
|
||||
}
|
||||
|
||||
PLOT_TEST(stream_gather_envelope_stays_inside_an_unaligned_range) {
|
||||
// Same invariant on a sliding window whose base is off the bucket grid. The
|
||||
// tame zone covers every sample the queried range reaches -- window [3808,
|
||||
// 12000) queried at window indices [300, n-77), absolute [4108, 11923) -- and
|
||||
// the extremes sit strictly before it, from 3808 to 4000.
|
||||
const auto value = [](u64 i) {
|
||||
return i < 4000 ? (i % 2 == 0 ? 500.0f : -500.0f)
|
||||
: 0.125f * f32(i % 5) - 0.25f;
|
||||
};
|
||||
constexpr u64 kPushes = 12000;
|
||||
plot::data::StreamSeries s(1u << 13, 0.0, 1.0); // window of 8192, base 3816
|
||||
for (u64 i = 0; i < kPushes; ++i) s.push(value(i));
|
||||
s.acquire();
|
||||
const u64 n = s.size();
|
||||
const u64 base = kPushes - n;
|
||||
PLOT_CHECK((base % kBucket) != 0); // window starts mid-bucket
|
||||
const u64 b = 300, e = n - 77; // unaligned inside the window
|
||||
const plot::Range exact = s.reduce(b, e);
|
||||
PLOT_CHECK(exact.max < 1.0 && exact.min > -1.0);
|
||||
std::vector<f32> out;
|
||||
s.gather(1, b, e, out);
|
||||
PLOT_CHECK(out.size() >= 4u);
|
||||
for (plot::usize i = 0; i + 1 < out.size(); i += 2) {
|
||||
PLOT_CHECK(f64(out[i]) >= f64(b)); // relative x inside [begin, end)
|
||||
PLOT_CHECK(f64(out[i]) < f64(e));
|
||||
}
|
||||
for (plot::usize i = 1; i < out.size(); i += 2) {
|
||||
PLOT_CHECK(f64(out[i]) >= exact.min - 1e-6);
|
||||
PLOT_CHECK(f64(out[i]) <= exact.max + 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
// --- B7: non-finite samples are excluded from every aggregate ----------------
|
||||
|
||||
PLOT_TEST(nan_samples_are_excluded_from_bounds_and_sums) {
|
||||
// NaN at the first, middle and last positions: bounds and reduce stay finite,
|
||||
// sums skip the NaN samples entirely.
|
||||
std::vector<f32> y{std::nanf(""), 1.0f, 2.0f, std::nanf(""), 3.0f, 4.0f,
|
||||
std::nanf("")};
|
||||
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
|
||||
PLOT_CHECK_NEAR(s.y_bounds().min, 1.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(s.y_bounds().max, 4.0, 1e-9);
|
||||
const plot::Range r = s.reduce(0, 7);
|
||||
PLOT_CHECK_NEAR(r.min, 1.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(r.max, 4.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(s.sum(0, 7), 10.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(s.sumsq(0, 7), 30.0, 1e-9);
|
||||
// A range that is *all* NaN: zero-span, zero sums.
|
||||
PLOT_CHECK(s.reduce(0, 1).span() == 0.0);
|
||||
PLOT_CHECK(s.sum(0, 1) == 0.0);
|
||||
PLOT_CHECK(s.sumsq(0, 1) == 0.0);
|
||||
// And a NaN-only window reports a zero-span Range, not NaN.
|
||||
PLOT_CHECK(s.reduce(5, 7).span() == 0.0);
|
||||
}
|
||||
|
||||
PLOT_TEST(stream_series_bounds_skip_nan_samples) {
|
||||
// acquire()'s bounds go through the same exact folds, so a stream with NaN
|
||||
// gaps publishes finite bounds -- and NaN at the edges of the window too.
|
||||
plot::data::StreamSeries s(256, 0.0, 1.0);
|
||||
s.push(std::nanf("")); // oldest retained sample
|
||||
for (int i = 0; i < 254; ++i) s.push(i % 2 == 0 ? -3.0f : 7.0f);
|
||||
s.push(std::nanf("")); // newest sample
|
||||
s.acquire();
|
||||
PLOT_CHECK(s.size() == 256);
|
||||
PLOT_CHECK_NEAR(s.y_bounds().min, -3.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(s.y_bounds().max, 7.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(s.reduce(0, s.size()).max, 7.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(s.sum(0, s.size()), 127.0 * 4.0, 1e-3);
|
||||
}
|
||||
|
||||
PLOT_TEST(window_without_finite_samples_aggregates_to_zero) {
|
||||
plot::data::ArraySeries s(std::vector<f32>(8, std::nanf("")), 0.0, 1.0);
|
||||
PLOT_CHECK(s.y_bounds().span() == 0.0);
|
||||
PLOT_CHECK(s.reduce(0, 8).span() == 0.0);
|
||||
PLOT_CHECK(s.sum(0, 8) == 0.0);
|
||||
PLOT_CHECK(s.sumsq(0, 8) == 0.0);
|
||||
// x bounds stay the grid's: the window exists even though nothing is finite.
|
||||
PLOT_CHECK_NEAR(s.x_bounds().min, 0.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(s.x_bounds().max, 7.0, 1e-9);
|
||||
}
|
||||
|
||||
// --- R3: any u32 gather level is safe ----------------------------------------
|
||||
|
||||
PLOT_TEST(gather_level_beyond_the_pyramid_clamps) {
|
||||
// R3: `kBucket << level` used to shift by >= 64 for a public level past 57
|
||||
// (undefined). Now any u32 level is accepted and clamped to the deepest one.
|
||||
std::vector<f32> y(8192);
|
||||
for (u64 i = 0; i < y.size(); ++i) y[i] = f32(i % 13) - 6.0f;
|
||||
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
|
||||
std::vector<f32> deepest;
|
||||
s.gather(plot::data::kMaxPyramidLevel, 0, s.size(), deepest);
|
||||
PLOT_CHECK(!deepest.empty());
|
||||
for (const u32 level : {u32{58}, u32{200}, u32{1000}, ~u32{0}}) {
|
||||
std::vector<f32> out;
|
||||
s.gather(level, 0, s.size(), out);
|
||||
PLOT_CHECK(out == deepest); // clamped to the same deepest geometry
|
||||
}
|
||||
// The cap is where the span shift still fits: level 57 spans exactly 2^63.
|
||||
PLOT_CHECK(plot::data::kMaxPyramidLevel == 57);
|
||||
PLOT_CHECK((u64{kBucket} << plot::data::kMaxPyramidLevel) == (u64{1} << 63));
|
||||
}
|
||||
|
||||
// --- R4: the ring's capacity is the rounded one -------------------------------
|
||||
|
||||
PLOT_TEST(stream_series_capacity_is_rounded_up_and_pre_sized) {
|
||||
// StreamSeries(100) retains 128 samples: the ring rounds up, size() saturates
|
||||
// at the rounded capacity, and the pyramid was reserved for it (a saturated
|
||||
// stream must not grow the level table mid-acquire).
|
||||
plot::data::StreamSeries s(100, 0.0, 1.0);
|
||||
for (int i = 0; i < 1000; ++i) s.push(f32(i));
|
||||
s.acquire();
|
||||
PLOT_CHECK(s.size() == 128);
|
||||
PLOT_CHECK_NEAR(s.y_bounds().max, 999.0, 1e-4);
|
||||
PLOT_CHECK_NEAR(s.x_bounds().max, s.x0() + 127.0, 1e-9);
|
||||
const plot::Range r = s.reduce(0, s.size());
|
||||
PLOT_CHECK_NEAR(r.min, 872.0, 1e-4); // the oldest retained sample
|
||||
}
|
||||
|
||||
// --- GatherMode: Envelope / Nearest / Mean ------------------------------------
|
||||
|
||||
PLOT_TEST(gather_modes_envelope_nearest_mean) {
|
||||
// 4096 samples of a sawtooth, level 2 (256-sample buckets, 16 of them).
|
||||
constexpr u64 kN = 4096;
|
||||
const auto value = [](u64 i) { return f32(i % 7) - 3.0f; };
|
||||
std::vector<f32> y(kN);
|
||||
for (u64 i = 0; i < kN; ++i) y[i] = value(i);
|
||||
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
|
||||
const u32 level = 2;
|
||||
const u64 span = u64{kBucket} << level;
|
||||
const u64 buckets = kN / span;
|
||||
PLOT_CHECK(buckets == 16);
|
||||
|
||||
std::vector<f32> env, near, mean;
|
||||
s.gather(level, 0, kN, env, plot::data::GatherMode::Envelope);
|
||||
s.gather(level, 0, kN, near, plot::data::GatherMode::Nearest);
|
||||
s.gather(level, 0, kN, mean, plot::data::GatherMode::Mean);
|
||||
|
||||
// Envelope: two points per bucket, min at the bucket's first sample, max at
|
||||
// its last (this sawtooth's per-bucket min is its first sample's value and
|
||||
// its max its last sample's value).
|
||||
PLOT_CHECK(env.size() == 4 * buckets);
|
||||
// Nearest and Mean: one point per bucket, x at the bucket's centre sample.
|
||||
PLOT_CHECK(near.size() == 2 * buckets);
|
||||
PLOT_CHECK(mean.size() == 2 * buckets);
|
||||
for (u64 j = 0; j < buckets; ++j) {
|
||||
const u64 lo = j * span;
|
||||
const u64 centre = lo + span / 2;
|
||||
const f64 centre_rel = f64(centre); // dx == 1, relative to x0() == 0
|
||||
|
||||
// x positions are relative and land where the mode says.
|
||||
PLOT_CHECK_NEAR(env[4 * j], f64(lo), 1e-6);
|
||||
PLOT_CHECK_NEAR(env[4 * j + 2], f64(lo + span - 1), 1e-6);
|
||||
PLOT_CHECK_NEAR(near[2 * j], centre_rel, 1e-6);
|
||||
PLOT_CHECK_NEAR(mean[2 * j], centre_rel, 1e-6);
|
||||
|
||||
// Envelope y is the exact per-bucket min/max.
|
||||
const plot::Range r = s.reduce(lo, lo + span);
|
||||
PLOT_CHECK_NEAR(env[4 * j + 1], r.min, 1e-6);
|
||||
PLOT_CHECK_NEAR(env[4 * j + 3], r.max, 1e-6);
|
||||
// Nearest y is the raw centre sample.
|
||||
PLOT_CHECK_NEAR(near[2 * j + 1], value(centre), 1e-6);
|
||||
// Mean y is the bucket's mean.
|
||||
const f64 want_mean = s.sum(lo, lo + span) / f64(span);
|
||||
PLOT_CHECK_NEAR(mean[2 * j + 1], want_mean, 1e-6);
|
||||
}
|
||||
|
||||
// Level 0 and sub-bucket ranges are the raw samples for every mode.
|
||||
std::vector<f32> raw;
|
||||
s.gather(0, 5, 300, raw, plot::data::GatherMode::Mean);
|
||||
PLOT_CHECK(raw.size() == 2 * (300 - 5));
|
||||
PLOT_CHECK_NEAR(raw[0], 5.0, 1e-9);
|
||||
}
|
||||
|
||||
PLOT_TEST(gather_skips_all_nan_buckets) {
|
||||
// Level-1 bucket 1 (samples [128, 256)) is entirely NaN: the exact queries
|
||||
// treat it as absent, the gathered geometry emits nothing for it in Envelope
|
||||
// and Mean, and Nearest draws its centre sample raw -- NaN, as level 0 would.
|
||||
std::vector<f32> y(384, 1.0f);
|
||||
for (u64 i = 128; i < 256; ++i) y[i] = std::nanf("");
|
||||
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
|
||||
PLOT_CHECK(s.reduce(128, 256).span() == 0.0);
|
||||
PLOT_CHECK(s.sum(128, 256) == 0.0);
|
||||
const plot::Range r = s.reduce(0, 384);
|
||||
PLOT_CHECK_NEAR(r.min, 1.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(r.max, 1.0, 1e-9);
|
||||
|
||||
// first = 0, last = 256/128 = 2: buckets 0 and 1, no lead/trail raw spans.
|
||||
std::vector<f32> env, mean, near;
|
||||
s.gather(1, 0, 256, env, plot::data::GatherMode::Envelope);
|
||||
PLOT_CHECK(env.size() == 4); // bucket 0's two pairs only
|
||||
PLOT_CHECK_NEAR(env[0], 0.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(env[1], 1.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(env[2], 127.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(env[3], 1.0, 1e-9);
|
||||
s.gather(1, 0, 256, mean, plot::data::GatherMode::Mean);
|
||||
PLOT_CHECK(mean.size() == 2); // bucket 1 has no finite mean: skipped
|
||||
PLOT_CHECK_NEAR(mean[0], 64.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(mean[1], 1.0, 1e-9);
|
||||
s.gather(1, 0, 256, near, plot::data::GatherMode::Nearest);
|
||||
PLOT_CHECK(near.size() == 4); // every bucket still has a centre sample
|
||||
PLOT_CHECK_NEAR(near[0], 64.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(near[1], 1.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(near[2], 192.0, 1e-9); // bucket 1's centre, drawn raw
|
||||
PLOT_CHECK(std::isnan(near[3]));
|
||||
}
|
||||
|
||||
// --- Snapshot: StreamSeries::freeze() -----------------------------------------
|
||||
|
||||
PLOT_TEST(stream_series_freeze_copies_the_last_acquire_window) {
|
||||
plot::data::StreamSeries s(1024, 5.0, 0.5);
|
||||
for (u64 i = 0; i < 400; ++i) s.push(f32(i % 31));
|
||||
s.acquire();
|
||||
const u64 n = s.size();
|
||||
const plot::Range bounds = s.y_bounds();
|
||||
std::vector<f32> gathered;
|
||||
s.gather(1, 0, n, gathered);
|
||||
const plot::Range reduced = s.reduce(0, n);
|
||||
|
||||
// Freeze now, while the ring still holds exactly the published window.
|
||||
const plot::data::ArraySeries frozen = s.freeze();
|
||||
PLOT_CHECK(frozen.size() == n);
|
||||
PLOT_CHECK_NEAR(frozen.x0(), 5.0, 1e-12);
|
||||
PLOT_CHECK_NEAR(frozen.dx(), 0.5, 1e-12);
|
||||
PLOT_CHECK_NEAR(frozen.y_bounds().min, bounds.min, 1e-6);
|
||||
PLOT_CHECK_NEAR(frozen.y_bounds().max, bounds.max, 1e-6);
|
||||
const plot::Range fr = frozen.reduce(0, n);
|
||||
PLOT_CHECK_NEAR(fr.min, reduced.min, 1e-6);
|
||||
PLOT_CHECK_NEAR(fr.max, reduced.max, 1e-6);
|
||||
PLOT_CHECK_NEAR(frozen.sum(0, n), s.sum(0, n), 1e-3);
|
||||
std::vector<f32> frozen_gathered;
|
||||
frozen.gather(1, 0, n, frozen_gathered);
|
||||
PLOT_CHECK(frozen_gathered == gathered); // same geometry, byte for byte
|
||||
|
||||
// The producer now laps the ring (700 more pushes overwrite the oldest
|
||||
// slots) and a later acquire() grows the published window -- the frozen copy
|
||||
// answers from its own samples and never moves.
|
||||
for (u64 i = 0; i < 700; ++i) s.push(-1000.0f);
|
||||
s.acquire();
|
||||
PLOT_CHECK(s.size() == 1024);
|
||||
PLOT_CHECK(frozen.size() == n);
|
||||
PLOT_CHECK_NEAR(frozen.y_bounds().max, bounds.max, 1e-6);
|
||||
PLOT_CHECK_NEAR(frozen.y_bounds().min, bounds.min, 1e-6);
|
||||
PLOT_CHECK(frozen.reduce(0, n).max == reduced.max);
|
||||
}
|
||||
|
||||
// --- Incremental append: ArraySeries::push() ----------------------------------
|
||||
|
||||
PLOT_TEST(array_series_push_matches_commit) {
|
||||
// The incremental path must land exactly where the wholesale path does:
|
||||
// bounds, x bounds, exact queries and the pyramid's buckets themselves.
|
||||
std::vector<f32> y;
|
||||
y.reserve(1000);
|
||||
for (u64 i = 0; i < 1000; ++i) {
|
||||
y.push_back(i % 97 == 0 ? std::nanf("") : f32((i * 37) % 251) - 125.0f);
|
||||
}
|
||||
plot::data::ArraySeries pushed({}, 10.0, 0.25);
|
||||
for (const f32 v : y) pushed.push(v);
|
||||
const plot::data::ArraySeries committed(y, 10.0, 0.25);
|
||||
|
||||
PLOT_CHECK(pushed.size() == committed.size());
|
||||
PLOT_CHECK_NEAR(pushed.x_bounds().min, committed.x_bounds().min, 1e-12);
|
||||
PLOT_CHECK_NEAR(pushed.x_bounds().max, committed.x_bounds().max, 1e-12);
|
||||
PLOT_CHECK_NEAR(pushed.y_bounds().min, committed.y_bounds().min, 1e-6);
|
||||
PLOT_CHECK_NEAR(pushed.y_bounds().max, committed.y_bounds().max, 1e-6);
|
||||
for (u64 a = 0; a < 1000; a += 173) {
|
||||
for (u64 b = a + 1; b <= 1000; b += 331) {
|
||||
const plot::Range ra = pushed.reduce(a, b);
|
||||
const plot::Range rb = committed.reduce(a, b);
|
||||
PLOT_CHECK_NEAR(ra.min, rb.min, 1e-9);
|
||||
PLOT_CHECK_NEAR(ra.max, rb.max, 1e-9);
|
||||
PLOT_CHECK_NEAR(pushed.sum(a, b), committed.sum(a, b), 1e-9);
|
||||
PLOT_CHECK_NEAR(pushed.sumsq(a, b), committed.sumsq(a, b), 1e-9);
|
||||
}
|
||||
}
|
||||
for (u32 l = 0; l < 3; ++l) {
|
||||
PLOT_CHECK(pushed.pyramid().level_base(l) == committed.pyramid().level_base(l));
|
||||
PLOT_CHECK(pushed.pyramid().level_size(l) == committed.pyramid().level_size(l));
|
||||
for (u64 b = pushed.pyramid().level_base(l);
|
||||
b < pushed.pyramid().level_base(l) + pushed.pyramid().level_size(l); ++b) {
|
||||
const plot::data::MinMax ma = pushed.pyramid().bucket(l, b);
|
||||
const plot::data::MinMax mb = committed.pyramid().bucket(l, b);
|
||||
PLOT_CHECK_NEAR(ma.min, mb.min, 1e-9);
|
||||
PLOT_CHECK_NEAR(ma.max, mb.max, 1e-9);
|
||||
PLOT_CHECK_NEAR(pushed.pyramid().bucket_sum(l, b), committed.pyramid().bucket_sum(l, b),
|
||||
1e-9);
|
||||
PLOT_CHECK(pushed.pyramid().bucket_count(l, b) == committed.pyramid().bucket_count(l, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
-3
@@ -97,10 +97,65 @@ PLOT_TEST(minor_ticks_subdivide_and_the_total_is_capped) {
|
||||
PLOT_CHECK(std::fabs(tick.value * scale - std::round(tick.value * scale)) < 1e-9);
|
||||
PLOT_CHECK(tick.major ? !tick.label.empty() : tick.label.empty());
|
||||
}
|
||||
// A range dense enough to want millions of minors stops at the hard cap.
|
||||
}
|
||||
|
||||
/// R9: the cap counts **minors only**. A range dense enough to want more minors
|
||||
/// than fit is truncated in its subdivisions and never in its labels: every major
|
||||
/// of the span is present, labelled, and the far end of the axis keeps its number.
|
||||
PLOT_TEST(minor_cap_spares_the_majors) {
|
||||
std::vector<plot::Tick> capped;
|
||||
plot::generate_ticks({0.0, 1e9}, 1e6f, 1.0f, capped, /*include_minors=*/true);
|
||||
PLOT_CHECK(capped.size() == plot::kMaxTicks);
|
||||
// Step 1000 over [0, 65000] (65 major intervals): 260 minors want in, more than
|
||||
// the 256 the cap holds, and every one of the 66 majors still counts.
|
||||
plot::generate_ticks({0.0, 65000.0}, 65000.0f, 1000.0f, capped, /*include_minors=*/true);
|
||||
plot::usize majors = 0;
|
||||
plot::usize minors = 0;
|
||||
for (const plot::Tick& tick : capped) {
|
||||
if (tick.major) {
|
||||
++majors;
|
||||
} else {
|
||||
++minors;
|
||||
}
|
||||
}
|
||||
PLOT_CHECK(minors == plot::kMaxTicks); // minors capped exactly as before
|
||||
PLOT_CHECK(majors == 66u); // every major of the span kept
|
||||
for (plot::usize i = 1; i < capped.size(); ++i) PLOT_CHECK(capped[i].value > capped[i - 1].value);
|
||||
// The far end of the axis still carries its label.
|
||||
PLOT_CHECK(capped.back().major);
|
||||
PLOT_CHECK(!capped.back().label.empty());
|
||||
PLOT_CHECK_NEAR(capped.back().value, 65000.0, 1e-9);
|
||||
}
|
||||
|
||||
/// B2: the major/minor split is an integer-index test, so an epoch origin with a
|
||||
/// small step no longer fights a ULP-sized residue: majors are classified exactly,
|
||||
/// whatever the origin's magnitude. The +/-300 s window is the reported defect's
|
||||
/// shape; the sub-second window is where the old float-tolerance test actually
|
||||
/// misclassified (step 0.01 at 1.75e9 carries an error far above step*1e-9).
|
||||
PLOT_TEST(epoch_window_yields_labelled_majors) {
|
||||
std::vector<plot::Tick> t;
|
||||
plot::generate_ticks({1.75e9 - 300.0, 1.75e9 + 300.0}, 800.0f, 60.0f, t,
|
||||
/*include_minors=*/true);
|
||||
plot::usize majors = 0;
|
||||
for (const plot::Tick& tick : t) {
|
||||
if (!tick.major) continue;
|
||||
++majors;
|
||||
PLOT_CHECK(!tick.label.empty());
|
||||
PLOT_CHECK(tick.value >= 1.75e9 - 300.0);
|
||||
PLOT_CHECK(tick.value <= 1.75e9 + 300.0);
|
||||
}
|
||||
// Step 50 s over a 600 s window: the labelled ticks span it.
|
||||
PLOT_CHECK(majors >= 11);
|
||||
|
||||
std::vector<plot::Tick> deep;
|
||||
plot::generate_ticks({1.75e9 - 0.05, 1.75e9 + 0.05}, 800.0f, 60.0f, deep,
|
||||
/*include_minors=*/true);
|
||||
plot::usize deep_majors = 0;
|
||||
for (const plot::Tick& tick : deep) {
|
||||
if (!tick.major) continue;
|
||||
++deep_majors;
|
||||
PLOT_CHECK(!tick.label.empty());
|
||||
}
|
||||
// Step 0.01 s over a 0.1 s window: again a full set of labelled majors.
|
||||
PLOT_CHECK(deep_majors >= 9);
|
||||
}
|
||||
|
||||
PLOT_TEST(si_labels_are_utf8_and_never_overrun) {
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include "harness.hpp"
|
||||
#include <plot/data/array_series.hpp>
|
||||
#include <plot/data/xy_series.hpp>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <random>
|
||||
#include <vector>
|
||||
|
||||
using plot::data::kBucket;
|
||||
|
||||
namespace {
|
||||
|
||||
/// Monotone but decidedly non-uniform x: early samples a millisecond apart,
|
||||
/// later ones seconds apart. Exact in f64, no fixed dx anywhere.
|
||||
std::vector<f64> spread_x(u64 n) {
|
||||
std::vector<f64> x;
|
||||
x.reserve(static_cast<plot::usize>(n));
|
||||
f64 v = 0.0;
|
||||
for (u64 i = 0; i < n; ++i) {
|
||||
x.push_back(v);
|
||||
v += 0.001 + f64(i) * 0.01;
|
||||
}
|
||||
return x;
|
||||
}
|
||||
|
||||
std::vector<f32> saw_y(u64 n) {
|
||||
std::vector<f32> y;
|
||||
y.reserve(static_cast<plot::usize>(n));
|
||||
for (u64 i = 0; i < n; ++i) y.push_back(f32((i * 13) % 97) - 48.0f);
|
||||
return y;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
PLOT_TEST(xy_series_reports_its_own_grid) {
|
||||
auto x = spread_x(500);
|
||||
const std::vector<f64> xs = x; // keep an un-moved copy for the checks
|
||||
plot::data::XYSeries s(std::move(x), saw_y(500));
|
||||
PLOT_CHECK(s.size() == 500);
|
||||
PLOT_CHECK_NEAR(s.x0(), xs.front(), 1e-12);
|
||||
PLOT_CHECK(s.dx() == 0.0); // meaningless by contract; nothing reads it
|
||||
for (const u64 i : {u64{0}, u64{1}, u64{250}, u64{499}}) {
|
||||
PLOT_CHECK_NEAR(s.x_at(i), xs[static_cast<plot::usize>(i)], 1e-12);
|
||||
}
|
||||
// x_bounds come from the array's ends, wherever they sit on the number line.
|
||||
PLOT_CHECK_NEAR(s.x_bounds().min, xs.front(), 1e-12);
|
||||
PLOT_CHECK_NEAR(s.x_bounds().max, xs.back(), 1e-12);
|
||||
PLOT_CHECK_NEAR(s.y_bounds().min, -48.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(s.y_bounds().max, 48.0, 1e-6);
|
||||
|
||||
// Epoch-style offset: the same shape shifted by 1.7e9 keeps exact bounds, and
|
||||
// gather() emits x relative to x0() -- small numbers, not quantised epochs.
|
||||
std::vector<f64> epoch = spread_x(500);
|
||||
const std::vector<f64> es = epoch; // un-moved copy for the expectations
|
||||
for (auto& v : epoch) v += 1.7e9;
|
||||
plot::data::XYSeries e(std::move(epoch), saw_y(500));
|
||||
PLOT_CHECK_NEAR(e.x_bounds().min, 1.7e9, 1e-3);
|
||||
PLOT_CHECK_NEAR(e.x_bounds().max, 1.7e9 + (xs.back() - xs.front()), 1e-3);
|
||||
std::vector<f32> out;
|
||||
e.gather(0, 0, 500, out);
|
||||
PLOT_CHECK(out.size() == 1000);
|
||||
// x is relative to x0(): x_at(i) - x0(), the array's own offsets -- small
|
||||
// values even though the absolute epoch is 1.7e9. The expectations come from
|
||||
// the epoch array itself, since that is the subtraction the contract does.
|
||||
PLOT_CHECK_NEAR(out[0], 0.0, 1e-9); // x_at(0) - x0()
|
||||
PLOT_CHECK_NEAR(out[2], f32(es[1] - es[0]), 1e-6);
|
||||
PLOT_CHECK_NEAR(out[998], f32(es[499] - es[0]), 1e-3); // span, not 1.7e9
|
||||
PLOT_CHECK(out[998] < 2000.0f);
|
||||
}
|
||||
|
||||
PLOT_TEST(xy_series_neighbour_lookups_crosshair_style) {
|
||||
// The crosshair asks neighbours() at an arbitrary x and interpolates between
|
||||
// the two samples it gets back. On a non-uniform grid that needs real
|
||||
// searches: a uniform-grid formula would land megabytes off here.
|
||||
auto x = spread_x(1000);
|
||||
const std::vector<f64> xs = x;
|
||||
plot::data::XYSeries s(std::move(x), saw_y(1000));
|
||||
const auto at = [&](u64 i) { return xs[static_cast<plot::usize>(i)]; };
|
||||
|
||||
u64 i0 = 7, i1 = 7;
|
||||
// Midway between samples 100 and 101: brackets those two.
|
||||
const f64 mid = (at(100) + at(101)) / 2.0;
|
||||
PLOT_CHECK(s.neighbours(mid, i0, i1));
|
||||
PLOT_CHECK(i0 == 100);
|
||||
PLOT_CHECK(i1 == 101);
|
||||
// Exactly on a sample: both indices are that sample.
|
||||
PLOT_CHECK(s.neighbours(at(250), i0, i1));
|
||||
PLOT_CHECK(i0 == 250 && i1 == 250);
|
||||
// Just left of a sample: still bracketed from below.
|
||||
PLOT_CHECK(s.neighbours(std::nextafter(at(250), 0.0), i0, i1));
|
||||
PLOT_CHECK(i0 == 249 && i1 == 250);
|
||||
// Edges are inclusive.
|
||||
PLOT_CHECK(s.neighbours(at(0), i0, i1));
|
||||
PLOT_CHECK(i0 == 0 && i1 == 0);
|
||||
PLOT_CHECK(s.neighbours(at(999), i0, i1));
|
||||
PLOT_CHECK(i0 == 999 && i1 == 999); // exact hit on the last sample
|
||||
// Outside the bounds (and NaN): false, indices untouched.
|
||||
const u64 keep = 42;
|
||||
i0 = i1 = keep;
|
||||
PLOT_CHECK(!s.neighbours(at(0) - 1.0, i0, i1));
|
||||
PLOT_CHECK(!s.neighbours(at(999) + 1.0, i0, i1));
|
||||
PLOT_CHECK(!s.neighbours(std::nan(""), i0, i1));
|
||||
PLOT_CHECK(i0 == keep && i1 == keep);
|
||||
// Clustered duplicates: the contract is "last index <= x" and "first index
|
||||
// >= x", so a run of equal x brackets the run's ends.
|
||||
std::vector<f64> dup{1.0, 2.0, 2.0, 2.0, 3.0};
|
||||
plot::data::XYSeries d(dup, {1.0f, 2.0f, 3.0f, 4.0f, 5.0f});
|
||||
PLOT_CHECK(d.neighbours(2.0, i0, i1));
|
||||
PLOT_CHECK(i0 == 3 && i1 == 1); // last <= 2 is index 3, first >= 2 is index 1
|
||||
}
|
||||
|
||||
PLOT_TEST(xy_series_snap_index_finds_the_nearest_sample) {
|
||||
auto x = spread_x(1000);
|
||||
const std::vector<f64> xs = x;
|
||||
plot::data::XYSeries s(std::move(x), saw_y(1000));
|
||||
const auto at = [&](u64 i) { return xs[static_cast<plot::usize>(i)]; };
|
||||
|
||||
PLOT_CHECK(s.snap_index(at(400)) == 400); // exact hit
|
||||
PLOT_CHECK(s.snap_index((at(400) + at(401)) / 2.0 + 1e-9) == 401);
|
||||
PLOT_CHECK(s.snap_index((at(400) + at(401)) / 2.0 - 1e-9) == 400);
|
||||
PLOT_CHECK(s.snap_index(at(0) - 100.0) == 0); // clamped
|
||||
PLOT_CHECK(s.snap_index(at(999) + 100.0) == 999); // clamped
|
||||
PLOT_CHECK(s.snap_index(std::nan("")) == 0); // NaN lands on 0
|
||||
// A tie goes to the lower index (midpoints are impossible on this x, so make
|
||||
// one explicitly).
|
||||
std::vector<f64> tie{0.0, 10.0};
|
||||
plot::data::XYSeries t(tie, {1.0f, 2.0f});
|
||||
PLOT_CHECK(t.snap_index(5.0) == 0);
|
||||
PLOT_CHECK(t.snap_index(5.0 + 1e-9) == 1);
|
||||
}
|
||||
|
||||
PLOT_TEST(xy_series_pyramid_matches_array_series_over_the_same_y) {
|
||||
// The pyramid buckets by sample index, exactly as for a uniform series, so an
|
||||
// XYSeries over x = i*dx must answer every exact query identically to the
|
||||
// ArraySeries over the same y -- and the bucket aggregates must agree too.
|
||||
constexpr u64 kN = 3000;
|
||||
std::mt19937 rng(11);
|
||||
std::uniform_real_distribution<float> d(-50.0f, 50.0f);
|
||||
std::vector<f32> y(kN);
|
||||
for (auto& v : y) v = d(rng);
|
||||
std::vector<f64> x(kN);
|
||||
for (u64 i = 0; i < kN; ++i) x[i] = f64(i) * 0.5;
|
||||
const plot::data::XYSeries xy(x, y);
|
||||
const plot::data::ArraySeries uni(y, 0.0, 0.5);
|
||||
|
||||
PLOT_CHECK_NEAR(xy.x0(), uni.x0(), 1e-12);
|
||||
for (u64 a = 0; a < kN; a += 211) {
|
||||
for (u64 b = a + 1; b <= kN; b += 499) {
|
||||
const plot::Range rx = xy.reduce(a, b);
|
||||
const plot::Range ru = uni.reduce(a, b);
|
||||
PLOT_CHECK_NEAR(rx.min, ru.min, 1e-6);
|
||||
PLOT_CHECK_NEAR(rx.max, ru.max, 1e-6);
|
||||
PLOT_CHECK_NEAR(xy.sum(a, b), uni.sum(a, b), 1e-6);
|
||||
PLOT_CHECK_NEAR(xy.sumsq(a, b), uni.sumsq(a, b), 1e-6);
|
||||
}
|
||||
}
|
||||
for (u32 l = 0; l < 3; ++l) {
|
||||
PLOT_CHECK(xy.pyramid().level_base(l) == uni.pyramid().level_base(l));
|
||||
PLOT_CHECK(xy.pyramid().level_size(l) == uni.pyramid().level_size(l));
|
||||
for (u64 b = xy.pyramid().level_base(l);
|
||||
b < xy.pyramid().level_base(l) + xy.pyramid().level_size(l); ++b) {
|
||||
PLOT_CHECK_NEAR(xy.pyramid().bucket(l, b).min, uni.pyramid().bucket(l, b).min, 1e-6);
|
||||
PLOT_CHECK_NEAR(xy.pyramid().bucket(l, b).max, uni.pyramid().bucket(l, b).max, 1e-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PLOT_TEST(xy_series_gather_envelope_matches_exact_reduces) {
|
||||
// gather() buckets by index with the same B3 ceil/floor selection, but the x
|
||||
// of every emitted point is the array's own coordinate relative to x0() --
|
||||
// and each envelope pair equals the exact reduce over the samples its two x
|
||||
// positions bracket.
|
||||
constexpr u64 kN = 4096;
|
||||
auto x = spread_x(kN);
|
||||
const std::vector<f64> xs = x;
|
||||
const std::vector<f32> ys = saw_y(kN);
|
||||
plot::data::XYSeries s(std::move(x), ys);
|
||||
const auto at = [&](u64 i) { return xs[static_cast<plot::usize>(i)]; };
|
||||
const auto val = [&](u64 i) { return ys[static_cast<plot::usize>(i)]; };
|
||||
const f64 x0 = at(0);
|
||||
|
||||
const u32 level = 2;
|
||||
const u64 span = u64{kBucket} << level;
|
||||
const u64 b = 500 + 33, e = 3500 - 57; // unaligned on the bucket grid
|
||||
std::vector<f32> out;
|
||||
s.gather(level, b, e, out, plot::data::GatherMode::Envelope);
|
||||
PLOT_CHECK(out.size() >= 8u);
|
||||
// Emitted x stays inside [x_at(begin), x_at(end)) and the geometry reaches
|
||||
// both the range's extremes (the lead/trail raw spans carry them).
|
||||
const plot::Range exact = s.reduce(b, e);
|
||||
f64 emitted_min = 1e30, emitted_max = -1e30;
|
||||
for (plot::usize i = 0; i + 1 < out.size(); i += 2) {
|
||||
const f64 rel = f64(out[i]);
|
||||
// The emitted x is f32(x_at(i) - x0()), so allow the f32 cast's slack
|
||||
// (relative positions here reach ~1.4e4, where f32 ULP is ~1e-3).
|
||||
PLOT_CHECK(rel >= at(b) - x0 - 1e-3);
|
||||
PLOT_CHECK(rel <= at(e - 1) - x0 + 1e-3);
|
||||
emitted_min = std::min(emitted_min, f64(out[i + 1]));
|
||||
emitted_max = std::max(emitted_max, f64(out[i + 1]));
|
||||
}
|
||||
PLOT_CHECK_NEAR(emitted_min, exact.min, 1e-6);
|
||||
PLOT_CHECK_NEAR(emitted_max, exact.max, 1e-6);
|
||||
// Envelope pairs == exact reduce over their own spans. Walk the layout the
|
||||
// gather contract fixes -- raw lead pairs, then two pairs per complete
|
||||
// bucket, then raw trail pairs -- and read each pair's span off its x
|
||||
// positions (all saw_y values are finite, so no bucket is skipped).
|
||||
const u64 lend = s.pyramid().level_size(level);
|
||||
const u64 first = std::min(b / span + (b % span == 0 ? u64{0} : u64{1}), lend);
|
||||
const u64 last = std::min(e / span, lend);
|
||||
const u64 lead = first * span;
|
||||
const u64 buckets = last - first;
|
||||
PLOT_CHECK(out.size() ==
|
||||
2 * (lead - b + 2 * buckets + (e - last * span)));
|
||||
plot::usize o = 0;
|
||||
for (u64 i = b; i < lead; ++i, o += 2) { // the partial leading bucket, raw
|
||||
PLOT_CHECK_NEAR(out[o], f32(at(i) - x0), 1e-3);
|
||||
PLOT_CHECK_NEAR(out[o + 1], val(i), 1e-6);
|
||||
}
|
||||
for (u64 j = 0; j < buckets; ++j) {
|
||||
const u64 bi = first + j;
|
||||
const u64 lo = bi * span;
|
||||
const u64 hi = std::min((bi + 1) * span, e) - 1;
|
||||
const plot::Range r = s.reduce(lo, hi + 1);
|
||||
PLOT_CHECK_NEAR(out[o], f32(at(lo) - x0), 1e-3);
|
||||
PLOT_CHECK_NEAR(out[o + 1], r.min, 1e-6);
|
||||
PLOT_CHECK_NEAR(out[o + 2], f32(at(hi) - x0), 1e-3);
|
||||
PLOT_CHECK_NEAR(out[o + 3], r.max, 1e-6);
|
||||
o += 4;
|
||||
}
|
||||
for (u64 i = last * span; i < e; ++i, o += 2) { // the partial trailing bucket
|
||||
PLOT_CHECK_NEAR(out[o], f32(at(i) - x0), 1e-3);
|
||||
PLOT_CHECK_NEAR(out[o + 1], val(i), 1e-6);
|
||||
}
|
||||
PLOT_CHECK(o == out.size());
|
||||
}
|
||||
|
||||
PLOT_TEST(xy_series_nan_policy_matches_the_other_series) {
|
||||
// Non-finite y: excluded from bounds and sums; an all-NaN window aggregates
|
||||
// to zero-span / zero.
|
||||
std::vector<f64> x{0.0, 1.0, 2.0, 3.0, 4.0, 5.0};
|
||||
plot::data::XYSeries s(x, {std::nanf(""), 1.0f, 2.0f, std::nanf(""), 3.0f, std::nanf("")});
|
||||
PLOT_CHECK_NEAR(s.y_bounds().min, 1.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(s.y_bounds().max, 3.0, 1e-9);
|
||||
const plot::Range r = s.reduce(0, 6);
|
||||
PLOT_CHECK_NEAR(r.min, 1.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(r.max, 3.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(s.sum(0, 6), 6.0, 1e-9);
|
||||
PLOT_CHECK(s.reduce(4, 5).span() == 0.0);
|
||||
plot::data::XYSeries all(x, std::vector<f32>(6, std::nanf("")));
|
||||
PLOT_CHECK(all.y_bounds().span() == 0.0);
|
||||
PLOT_CHECK(all.sum(0, 6) == 0.0);
|
||||
PLOT_CHECK_NEAR(all.x_bounds().min, 0.0, 1e-12); // x bounds are the array's
|
||||
PLOT_CHECK_NEAR(all.x_bounds().max, 5.0, 1e-12);
|
||||
}
|
||||
|
||||
PLOT_TEST(xy_series_commit_rebuilds_and_reset_replaces) {
|
||||
auto x = spread_x(256);
|
||||
plot::data::XYSeries s(std::move(x), saw_y(256));
|
||||
const plot::Range before = s.y_bounds();
|
||||
std::vector<f64> x2{0.0, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0};
|
||||
s.reset(std::move(x2), {5.0f, 3.0f, 1.0f, 4.0f, 4.0f, 4.0f, 4.0f, 4.0f});
|
||||
PLOT_CHECK(s.size() == 8);
|
||||
PLOT_CHECK_NEAR(s.y_bounds().min, 1.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(s.y_bounds().max, 5.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(s.x_bounds().max, 32.0, 1e-12);
|
||||
PLOT_CHECK(s.y_bounds().max != before.max);
|
||||
// reset() re-asserts the precondition too: the arrays above are sorted, so
|
||||
// this passes; unsorted x is a debug-abort by contract (see the header) and
|
||||
// is deliberately not exercised here.
|
||||
}
|
||||
Reference in new issue
Block a user