From 38ef459897e708942fb1c2c3e322f2567fb7d1cc Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Wed, 30 Sep 2026 17:32:06 +0200 Subject: [PATCH] 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. --- .github/workflows/ci.yml | 92 ++++ CMakeLists.txt | 115 +++-- README.md | 64 ++- adapters/glfw/glfw_host.cpp | 72 ++- adapters/glfw/glfw_host.hpp | 63 ++- adapters/gtk4/gtk_input.hpp | 111 +++++ adapters/gtk4/gtk_plot_area.cpp | 109 +++- adapters/gtk4/gtk_plot_area.hpp | 31 +- adapters/imgui/imgui_plot.cpp | 32 +- adapters/qt6/qt_plot_widget.cpp | 116 ++++- adapters/qt6/qt_plot_widget.hpp | 29 +- adapters/qt6/qt_pointer.hpp | 66 +++ cmake/plot_options.cmake | 41 +- cmake/plot_umbrella_check.cmake | 43 +- docs/building-and-testing.md | 44 +- docs/coverage.md | 29 +- docs/data-and-performance.md | 86 +++- docs/embedding.md | 111 ++++- docs/getting-started.md | 28 +- ...9-29-plot-hardening-and-features-design.md | 305 ++++++++++++ include/plot/data/array_series.hpp | 12 +- include/plot/data/minmax_pyramid.hpp | 47 +- include/plot/data/ring_buffer.hpp | 9 +- include/plot/data/series.hpp | 76 ++- include/plot/data/stream_series.hpp | 20 +- include/plot/data/xy_series.hpp | 87 ++++ include/plot/gl/api.hpp | 8 + include/plot/gl/handle.hpp | 10 +- include/plot/input/controller.hpp | 84 +++- include/plot/input/events.hpp | 31 ++ include/plot/plot.hpp | 7 + include/plot/plot/axis.hpp | 24 +- include/plot/plot/plot.hpp | 12 +- include/plot/plot/ticks.hpp | 6 +- include/plot/plot/trace.hpp | 37 +- include/plot/render/buffer.hpp | 23 +- include/plot/render/primitives.hpp | 18 +- include/plot/render/program.hpp | 7 +- include/plot/render/renderer.hpp | 92 +++- include/plot/render/shaders.hpp | 9 +- include/plot/render/style.hpp | 6 + include/plot/render/vertex_array.hpp | 11 +- include/plot/version.hpp.in | 28 ++ plot.pc.in | 26 + readme_main.cpp | 30 ++ src/data/array_series.cpp | 42 +- src/data/minmax_pyramid.cpp | 63 ++- src/data/series.cpp | 162 ++++-- src/data/stream_series.cpp | 21 +- src/data/xy_series.cpp | 201 ++++++++ src/gl/handle.cpp | 6 + src/input/controller.cpp | 336 +++++++++++-- src/plot/figure.cpp | 62 ++- src/plot/plot.cpp | 269 ++++++---- src/plot/ticks.cpp | 44 +- src/render/primitives.cpp | 14 +- src/render/program.cpp | 4 +- src/render/renderer.cpp | 390 +++++++++++---- src/render/shaders.cpp | 26 +- src/render/vertex_array.cpp | 10 +- tests/CMakeLists.txt | 26 +- tests/test_controller.cpp | 376 +++++++++++++- tests/test_figure.cpp | 76 +++ tests/test_gl_context.cpp | 12 +- tests/test_glfw_host.cpp | 127 +++++ tests/test_gtk_input.cpp | 105 ++++ tests/test_imgui_widget.cpp | 118 +++++ tests/test_measure.cpp | 39 ++ tests/test_minmax_pyramid.cpp | 52 ++ tests/test_plot.cpp | 294 ++++++++++- tests/test_primitives.cpp | 55 +- tests/test_qt_pointer.cpp | 90 ++++ tests/test_render_smoke.cpp | 449 ++++++++++++++++- tests/test_ring_buffer.cpp | 15 + tests/test_series.cpp | 468 ++++++++++++++++-- tests/test_ticks.cpp | 61 ++- tests/test_xy_series.cpp | 273 ++++++++++ 77 files changed, 5905 insertions(+), 688 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 docs/superpowers/specs/2026-09-29-plot-hardening-and-features-design.md create mode 100644 include/plot/data/xy_series.hpp create mode 100644 include/plot/version.hpp.in create mode 100644 plot.pc.in create mode 100644 readme_main.cpp create mode 100644 src/data/xy_series.cpp create mode 100644 tests/test_xy_series.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..59a44c1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,92 @@ +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2026 Martino Ferrari +# +# The three jobs docs/building-and-testing.md describes: the plain debug build +# with the whole suite and every adapter, the reproducible coverage report, and +# the AddressSanitizer run. Each drives the project through its own preset, so +# CI configures exactly what a reader following the docs configures. The test +# steps run under xvfb: the GL tests create their own headless EGL context, but +# a display costs nothing and keeps the run honest about where a frame would +# go. The ImGui adapter fetches its Dear ImGui sources at configure time, which +# needs the network a GitHub runner has. + +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + debug: + name: debug (tests + all adapters) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + ninja-build python3-fonttools \ + libglfw3-dev qt6-base-dev libgtk-4-dev \ + libegl1 libglvnd-dev xvfb + + - name: Configure (linux-debug) + run: cmake --preset linux-debug + + - name: Build + run: cmake --build --preset linux-debug + + - name: Test + run: xvfb-run -a ctest --test-dir build/debug --output-on-failure + + coverage: + name: coverage (floor + measured table) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + ninja-build lcov python3-fonttools \ + libegl1 libglvnd-dev xvfb + + - name: Measure (tools/coverage.sh) + # The script configures build/cov from the linux-coverage preset, runs + # the suite, captures src/** with lcov, checks the floors and rewrites + # the measured table in docs/coverage.md. Non-zero exit fails the job. + run: xvfb-run -a tools/coverage.sh + + - name: Upload the measured table + uses: actions/upload-artifact@v4 + with: + name: coverage + path: docs/coverage.md + + asan: + name: asan (AddressSanitizer) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + ninja-build python3-fonttools \ + libglfw3-dev qt6-base-dev libgtk-4-dev \ + libegl1 libglvnd-dev xvfb + + - name: Configure (linux-asan) + run: cmake --preset linux-asan + + - name: Build + run: cmake --build --preset linux-asan + + - name: Test + run: xvfb-run -a ctest --test-dir build/asan --output-on-failure diff --git a/CMakeLists.txt b/CMakeLists.txt index cf2ef2b..e9a2480 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,8 +1,19 @@ cmake_minimum_required(VERSION 3.24) -project(plot VERSION 0.1.0 LANGUAGES CXX) +project(plot VERSION 0.1.0 + DESCRIPTION "An embeddable OpenGL time-series plotting library for real-time, high-sample-rate data" + LANGUAGES CXX) list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") include(plot_options) +include(GNUInstallDirs) + +# The version header: generated into the build tree at configure time, from the +# template beside it. It is a public header -- the umbrella names it, the +# self-containment and umbrella checks in tests/ hold it to the same rules, and +# the install rules place it under include/plot/ with the rest of the surface -- +# so a consumer can ask what it compiled against (see include/plot/version.hpp.in). +configure_file(include/plot/version.hpp.in + "${CMAKE_CURRENT_BINARY_DIR}/include/plot/version.hpp" @ONLY) add_library(plot_core src/core/status.cpp @@ -11,6 +22,7 @@ add_library(plot_core src/data/series.cpp src/data/array_series.cpp src/data/stream_series.cpp + src/data/xy_series.cpp src/gl/loader.cpp src/gl/handle.cpp src/render/shaders.cpp @@ -36,36 +48,32 @@ add_library(plot::core ALIAS plot_core) # and every consumer, and this project's own comments -- says plot::core. set_target_properties(plot_core PROPERTIES EXPORT_NAME core) +# The shared-library case names its file versions: the dynamic loader follows +# the SONAME (libplot_core.so., the major component), so an +# ABI-breaking release moves it by bumping project()'s major. The static +# archive -- the default, and the only shape the presets build -- carries no +# such name, and setting the properties on it would be noise. +if(BUILD_SHARED_LIBS) + set_target_properties(plot_core PROPERTIES + VERSION ${PROJECT_VERSION} + SOVERSION ${PROJECT_VERSION_MAJOR}) +endif() + target_include_directories(plot_core PUBLIC $ + # The generated version header lives here, not under the source root (see + # configure_file above); everything that compiles against plot::core reaches + # it through this interface, including the header checks in tests/. + $ $) target_compile_features(plot_core PUBLIC cxx_std_20) target_link_libraries(plot_core PRIVATE ${CMAKE_DL_LIBS}) plot_apply_warnings(plot_core) -# The optional runtime-font backend: plot::text::TrueTypeFont, which bakes a real -# TrueType/OpenType file into a fixed-cell atlas at run time (see -# include/plot/text/true_type_font.hpp). It is the one target that compiles the -# vendored third_party/stb_truetype.h, so it is opt-in and separable -- the core -# library, its public headers and its tests build without it -- and its default -# follows the vendored header's presence: a tree that has the file wants the -# feature, and one that does not cannot have it. Asking for it explicitly without -# the header is an error rather than a silent downgrade, because the option is -# then the only thing that would have said so. -if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/third_party/stb_truetype.h") - set(PLOT_VENDORED_STB_TRUETYPE TRUE) -else() - set(PLOT_VENDORED_STB_TRUETYPE FALSE) -endif() -option(PLOT_BUILD_TTF_FONT - "Build the optional plot::text::TrueTypeFont backend (vendored stb_truetype)" - ${PLOT_VENDORED_STB_TRUETYPE}) -if(PLOT_BUILD_TTF_FONT AND NOT PLOT_VENDORED_STB_TRUETYPE) - message(FATAL_ERROR - "plot: PLOT_BUILD_TTF_FONT=ON needs third_party/stb_truetype.h, which this tree does " - "not have. Restore it -- include/plot/text/true_type_font.hpp records the version and " - "the source URL it came from -- or configure with -DPLOT_BUILD_TTF_FONT=OFF.") -endif() +# The optional runtime-font backend: plot::text::TrueTypeFont. The option and +# its guard live in cmake/plot_options.cmake with every other PLOT_* option; +# what is left here is the one thing an option cannot do for itself: adding the +# target. See the option's comment there for why it is opt-in and separable. if(PLOT_BUILD_TTF_FONT) add_subdirectory(src/text) endif() @@ -162,35 +170,70 @@ if(PLOT_INSTALL) # asking for it twice puts the same directory in the export twice. install(TARGETS plot_core ${PLOT_ADAPTER_EXPORT_TARGETS} EXPORT plotTargets - ARCHIVE DESTINATION lib - LIBRARY DESTINATION lib - RUNTIME DESTINATION bin) - # The optional backend's header goes in only when that backend was built: an - # installed header with no library behind it is a promise the prefix cannot keep, - # and which configuration a prefix came from is not something a consumer can see. + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) + # The public headers, and the generated version header with them. The + # directory rule takes the source tree (FILES_MATCHING, so the version.hpp.in + # template -- a .in, not a .hpp -- cannot travel); the generated + # include/plot/version.hpp is not under the source root, so it goes in by + # name. The optional backend's header goes in only when that backend was + # built: an installed header with no library behind it is a promise the + # prefix cannot keep, and which configuration a prefix came from is not + # something a consumer can see. if(PLOT_BUILD_TTF_FONT) - install(DIRECTORY include/plot DESTINATION include) + install(DIRECTORY include/plot + DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} + FILES_MATCHING PATTERN "*.hpp") else() - install(DIRECTORY include/plot DESTINATION include + install(DIRECTORY include/plot + DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} + FILES_MATCHING PATTERN "*.hpp" PATTERN "true_type_font.hpp" EXCLUDE) endif() + install(FILES "${CMAKE_CURRENT_BINARY_DIR}/include/plot/version.hpp" + DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/plot) + + # The licences that travel with the installed files: this library's MIT text, + # and the OFL 1.1 text for the embedded font the headers bake in, so a + # redistributor of the prefix holds both grants without a second checkout. + install(FILES LICENSE licences/OFL-1.1.txt + DESTINATION ${CMAKE_INSTALL_DATADIR}/licenses/plot) + + # pkg-config metadata, for consumers whose build is not CMake. find_package() + # and the package config below remain the primary interface; see plot.pc.in. + set(PLOT_PC_LIBDIR "${CMAKE_INSTALL_LIBDIR}") + set(PLOT_PC_INCLUDEDIR "${CMAKE_INSTALL_INCLUDEDIR}") + set(PLOT_PC_LIB "-lplot_core") + set(PLOT_PC_LIBS_PRIVATE "") + foreach(plot_pc_lib IN LISTS CMAKE_DL_LIBS) + list(APPEND PLOT_PC_LIBS_PRIVATE "-l${plot_pc_lib}") + endforeach() + string(REPLACE ";" " " PLOT_PC_LIBS_PRIVATE "${PLOT_PC_LIBS_PRIVATE}") + configure_file(plot.pc.in "${CMAKE_CURRENT_BINARY_DIR}/plot.pc" @ONLY) + install(FILES "${CMAKE_CURRENT_BINARY_DIR}/plot.pc" + DESTINATION ${CMAKE_INSTALL_LIBDIR}/pkgconfig) install(EXPORT plotTargets NAMESPACE plot:: FILE plotTargets.cmake - DESTINATION lib/cmake/plot) + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/plot) configure_package_config_file( cmake/plotConfig.cmake.in "${CMAKE_CURRENT_BINARY_DIR}/plotConfig.cmake" - INSTALL_DESTINATION lib/cmake/plot) + INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/plot) + # SameMinorVersion, deliberately: the project is 0.x, where the major + # component carries no ABI promise and the minor is what moves when one + # breaks, so a consumer asking for 0.1 gets 0.1.x and nothing newer. It goes + # up to SameMajorVersion when the version leaves 0. write_basic_package_version_file( "${CMAKE_CURRENT_BINARY_DIR}/plotConfigVersion.cmake" VERSION ${PROJECT_VERSION} - COMPATIBILITY SameMajorVersion) + COMPATIBILITY SameMinorVersion) install(FILES "${CMAKE_CURRENT_BINARY_DIR}/plotConfig.cmake" "${CMAKE_CURRENT_BINARY_DIR}/plotConfigVersion.cmake" - DESTINATION lib/cmake/plot) + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/plot) endif() diff --git a/README.md b/README.md index e439b7b..0880a95 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ built on top of it. Per-frame rendering cost is O(visible pixels + samples produced since the last frame), not O(samples): a trace denser than one sample per pixel is drawn from a min/max LOD -pyramid rather than from raw samples. The `plot_multiplot_demo` run stages **18,651 GPU +pyramid rather than from raw samples. The `plot_multiplot_demo` run stages **20,901 GPU segments** to draw **~4.5 million visible samples** across six traces — the frame is bounded by the pixels it lights, not by the sample count behind them. That figure is from a `linux-release` build under `xvfb-run` on **llvmpipe** (Mesa's software rasteriser); no @@ -33,6 +33,21 @@ run touched a GPU, so read it as an upper bound and a record of the software pat - a hover crosshair with a per-signal, interpolated readout; - a multi-plot `Figure` of stacked rows sharing one synchronised x axis; - live follow (`XFollow::Live`) with relative (multiplicative) zoom and mouse panning; +- XY series for irregularly sampled data (`plot/data/xy_series.hpp`): sorted-ascending x + with binary-searched crosshair, measurement and index snapping; +- per-trace draw modes: dash patterns (`Trace::dash_on`/`dash_off`, logical px, 0 = solid) + and point markers (`Trace::marker`: cross, circle, square) with a manual + `marker_every` stride or an automatic one; `width == 0` draws the markers alone + (scatter); +- three downsample policies per trace (`Trace::mode`, `GatherMode`): `Envelope` + (min/max pairs — the default), `Nearest` (one raw sample per bucket) and `Mean` + (one averaged point per bucket); +- readout text colour: crosshair value rows render in their row's trace colour, labels in + the axis text colour; +- input parity across hosts: pinch-zoom and pan gestures on Qt6 and GTK4 drive the same + controller paths as mouse and wheel (touch parity), with keyboard panning + (arrow keys), Escape cancelling a drag or box-zoom, double-click re-fitting the axes, + and modifier-masked box zoom (Shift = both axes, Ctrl = x only, Alt = y only); - DPI-consistent sizing: metrics are logical units converted once through `Style::ui_scale`; - an anti-aliased embedded monospaced font, plus an optional runtime TrueType backend @@ -49,20 +64,32 @@ cmake --build build/release cmake --install build/release --prefix /where/you/want ``` -Install is on by default (`PLOT_INSTALL=ON`) and installs the `plot_core` archive, the -public headers under `include/plot/`, and a CMake package config under `lib/cmake/plot/`. -A consumer then needs only: +Install is on by default (`PLOT_INSTALL=ON`) and, via `GNUInstallDirs`, installs the +`plot_core` archive under `lib/`, the public headers under `include/plot/` — including the +generated `plot/version.hpp` — a CMake package config under `lib/cmake/plot/`, a pkg-config +file under `lib/pkgconfig/plot.pc`, and the licences under `share/licenses/plot/`. A +consumer then needs only: ```cmake find_package(plot 0.1 REQUIRED) target_link_libraries(my_app PRIVATE plot::core) ``` +**Versioning.** The package config carries **SameMinorVersion** compatibility: at 0.x the +major component promises nothing and the minor is what moves when the surface breaks, so +`find_package(plot 0.1 REQUIRED)` accepts 0.1.x and refuses anything newer. A shared build +(`BUILD_SHARED_LIBS=ON`) names its ABI on the file itself — `libplot_core.so.0`, the +SOVERSION being the major component. What a header set *is* is in its generated +``: `plot::version::major`/`minor`/`patch`/`string`, or the +`PLOT_VERSION_*` macros for a preprocessor test. The full layout is tabled in +[docs/building-and-testing.md](docs/building-and-testing.md). + ## Embedding A host owns the window, the GL context and the event loop; `plot` owns none of them. This is the whole of a minimal GLFW program — the adapter opens the window, runs the loop and -feeds the controller, and the callback is where live data would be pushed: +feeds the controller, and the callback is where live data would be pushed. It is committed +at the repository root as `readme_main.cpp`: ```cpp #include // the optional GLFW adapter @@ -90,10 +117,12 @@ int main() { ``` It compiles and links against a built tree (see [docs/getting-started.md](docs/getting-started.md) -for the same program written against `plot::core` directly, with no adapter): +for the same program written against `plot::core` directly, with no adapter). The one +non-obvious flag is `-I build/debug/include`: that is where CMake generates +`plot/version.hpp`, which the umbrella pulls in: ```sh -g++ -std=c++20 -I include -I adapters readme_main.cpp \ +g++ -std=c++20 -I include -I adapters -I build/debug/include readme_main.cpp \ build/debug/adapters/glfw/libplot_glfw.a build/debug/libplot_core.a \ $(pkg-config --libs glfw3) -ldl -lpthread -o plot_readme ``` @@ -124,18 +153,18 @@ documentation. ```sh cmake --preset linux-debug cmake --build build/debug -ctest --test-dir build/debug --output-on-failure # 283 cases, 0 failed +ctest --test-dir build/debug --output-on-failure # 350 cases, 0 failed tools/coverage.sh # configure+build+run+capture+floor check ``` -The `linux-debug` suite runs **283 cases**; the `linux-coverage` preset builds **272** of -them, because its adapters are off and 11 cases need an adapter target (8 in -`test_imgui_widget.cpp`, 3 in `test_glfw_host.cpp`). Coverage is enforced by +The `linux-debug` suite runs **350 cases**; the `linux-coverage` preset builds **333** of +them, because its adapters are off and 17 cases need an adapter target (11 in +`test_imgui_widget.cpp`, 6 in `test_glfw_host.cpp`). Coverage is enforced by `tools/coverage.sh` against `tools/coverage_floor.py`: **80%** per file under `src/` and -**90%** aggregate. The current measurement is an aggregate **95.3% (3284/3447 lines)** over -23 files, every file at or above 80%; the per-file table is in -[docs/coverage.md](docs/coverage.md), which the command regenerates. +**90%** aggregate. The measured aggregate and per-file table live in +[docs/coverage.md](docs/coverage.md), which the command regenerates — it is the single +source for those numbers, so this page carries none. ## Licence and third-party provenance @@ -164,7 +193,12 @@ them, because its adapters are off and 11 cases need an adapter target (8 in - **macOS and Windows are untested.** Development and CI are Linux/EGL + X11 only. - **Fractional-scale compositing and touchpad scroll calibration are unverified** — the `ui_scale` sources are correct per host, but no run exercised a real fractional DPI or a - calibrated scroll device. + calibrated scroll device. The new gesture paths (Qt6 and GTK4 pinch, the keyboard and + wheel paths behind the parity list above) are tested at the toolkit-free seams and + headless; no run has touched real touch hardware. +- **Figure row rects are one frame stale during a resize.** The hosts cache + `Figure::layout()`'s rows and refresh them once per frame, so an event arriving mid-resize + routes against the previous frame's rects (see [docs/embedding.md](docs/embedding.md)). - **Untrusted font files are out of scope** for the runtime `plot::font_ttf` backend: stb_truetype does no range checking, so `from_file()` must only be handed files the host trusts. diff --git a/adapters/glfw/glfw_host.cpp b/adapters/glfw/glfw_host.cpp index 001cebf..6e7d6e4 100644 --- a/adapters/glfw/glfw_host.cpp +++ b/adapters/glfw/glfw_host.cpp @@ -111,6 +111,10 @@ void Host::close() noexcept { // hands each row its rect every frame). Dropping them would silently stop routing // gestures to the rows on the second run. have_pointer_ = false; + // The cached rows described a figure's geometry; the next run() re-reads them from + // scratch, and until then `controller_at()` answers the single-plot controller + // rather than rects from a window that no longer exists. + row_rects_.clear(); // A closed host reports no size: a second run() must not see the previous // window's framebuffer size before its first refresh_sizes(). fb_width_ = 0; @@ -210,13 +214,12 @@ bool Host::run(const RunConfig& cfg, const FrameFn& frame, Status* st) { // that hands each row its rect (and so its own `Plot` its viewport). The single // controller is untouched -- each row has its own, see controller_at(). figure_->set_viewport(viewport); - // Every row's controller gets that row's rect, so a gesture -- routed by - // pointer_target() or injected by a scripted run -- is hit-tested against the - // geometry the last frame drew, and the two readers of a row's geometry cannot - // disagree about it. The rows come off the one recomputing layout of this frame -- - // calling `layout()` again here, or in `controller_at()`, would lay every row out a - // second and a third time for the same frame. - const std::vector& rows = figure_->layout().rows; + // The frame's one layout of the figure, into the cache every event handler + // between now and the next frame reads (R7): the rows below, and the routing of + // whatever the next `glfwPollEvents()` batch delivers, all take *these* rects + // rather than each paying for another layout. + refresh_row_rects(); + const std::vector& rows = row_rects_; for (u32 i = 0; i < row_controllers_.size(); ++i) { if (i < rows.size()) row_controllers_[i]->set_viewport(rows[i]); } @@ -353,6 +356,7 @@ void Host::on_cursor_pos(f64 wx, f64 wy) noexcept { have_pointer_ = true; e.mods = mods_now(window_); e.down = any_button_down(window_); + e.time_s = glfwGetTime(); // GLFW's monotonic clock; only differences are read feed_pointer_event(e); } @@ -380,6 +384,7 @@ void Host::on_mouse_button(int button, int action, int mods) noexcept { e.pressed = action == GLFW_PRESS; e.released = action == GLFW_RELEASE; e.down = any_button_down(window_); + e.time_s = glfwGetTime(); // the double-click detector reads differences of these feed_pointer_event(e); } @@ -406,9 +411,16 @@ void Host::on_key(int key, int action, int mods) noexcept { case GLFW_KEY_MINUS: case GLFW_KEY_KP_SUBTRACT: mapped = input::kKeyZoomOut; break; case GLFW_KEY_F: mapped = input::kKeyFit; break; - case GLFW_KEY_ESCAPE: - if (action == GLFW_PRESS) glfwSetWindowShouldClose(window_, GLFW_TRUE); - return; + // Arrows pan (10% of the span per press, repeats included) and Escape cancels + // the active drag: translated, exactly like the ASCII set above, and the + // controller decides what each key does. Escape no longer closes the window -- + // the window manager's close button and Alt+F4 are the way to quit, and a key + // the controller has a gesture for must reach it. + case GLFW_KEY_ESCAPE: mapped = input::kKeyEscape; break; + case GLFW_KEY_LEFT: mapped = input::kKeyLeft; break; + case GLFW_KEY_RIGHT: mapped = input::kKeyRight; break; + case GLFW_KEY_UP: mapped = input::kKeyUp; break; + case GLFW_KEY_DOWN: mapped = input::kKeyDown; break; default: return; } input::KeyEvent e; @@ -438,12 +450,27 @@ void Host::set_figure(Figure* figure) noexcept { spread_y_ = 0.0; captured_ = false; captured_row_ = 0u; - if (figure_ == nullptr) return; + captured_button_ = input::Button::Left; + if (figure_ == nullptr) { + row_rects_.clear(); // no figure, no rows: the handlers fall back to `controller_` + return; + } const u32 rows = figure_->row_count(); row_controllers_.reserve(rows); for (u32 i = 0; i < rows; ++i) { row_controllers_.push_back(std::make_unique(figure_->plot(i))); } + // The new figure's rects go into the cache now, not at the next frame: an event + // arriving between set_figure() and the first frame -- a scripted run, the headless + // tests -- must be routed against the figure it was just handed, and the next + // `refresh_row_rects()` re-reads them once per frame as usual. + refresh_row_rects(); +} + +void Host::refresh_row_rects() noexcept { + // The one `Figure::layout()` per call: every event handler reads `row_rects_` + // instead of paying this again, which is the whole point of the cache (R7). + row_rects_ = figure_ != nullptr ? figure_->layout().rows : std::vector{}; } input::Controller* Host::row_controller(u32 row) noexcept { @@ -452,10 +479,12 @@ input::Controller* Host::row_controller(u32 row) noexcept { input::Controller& Host::controller_at(f32 x, f32 y) noexcept { if (figure_ == nullptr || row_controllers_.empty()) return controller_; - // The figure's one layout for this call: both readers below take the row rects it produced, so an - // event lays the figure out once and not once per reader -- this is the hottest path a multi-row - // figure has, and a layout now rebuilds every row's ticks, grid and crosshair geometry. - const std::vector& rows = figure_->layout().rows; + // The cached rects `refresh_row_rects()` filled -- the same ones the last frame + // drew -- and not a fresh `Figure::layout()`: an event pays no layout, which is + // what made a burst of pointer moves on a multi-row figure re-lay every row out + // per move (R7). Between a resize and the loop's next refresh the cache is one + // frame stale, which is the documented worst case. + const std::vector& rows = row_rects_; if (rows.empty() || rows.size() != row_controllers_.size()) return controller_; (void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in() const u32 row = row_in(rows, y); @@ -472,13 +501,14 @@ void Host::feed_pointer_event(const input::PointerEvent& e) noexcept { controller_.on_pointer(e); return; } - pointer_target(e.x, e.y, e.pressed, e.released).on_pointer(e); + pointer_target(e.x, e.y, e.button, e.pressed, e.released).on_pointer(e); } -input::Controller& Host::pointer_target(f32 x, f32 y, bool pressed, bool released) noexcept { +input::Controller& Host::pointer_target(f32 x, f32 y, input::Button button, bool pressed, + bool released) noexcept { (void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in() if (figure_ == nullptr || row_controllers_.empty()) return controller_; - const std::vector& rows = figure_->layout().rows; + const std::vector& rows = row_rects_; if (rows.empty() || rows.size() != row_controllers_.size()) return controller_; u32 row = 0u; @@ -488,12 +518,16 @@ input::Controller& Host::pointer_target(f32 x, f32 y, bool pressed, bool release // to a controller that never saw the press, which would leave the owner's drag open // for ever (and let a later gesture resume it from the old anchor). row = captured_row_; - if (released) captured_ = false; + // Only the owning button's release ends the capture (R5): a second button's + // release -- pressed meanwhile as a chord -- must not strand the first button's + // drag, and its press must not steal the capture to the row it landed in. + if (released && button == captured_button_) captured_ = false; } else { row = row_in(rows, y); if (pressed) { captured_ = true; captured_row_ = row; + captured_button_ = button; } } // The captured row is re-read against this frame's rects: the figure can be resized diff --git a/adapters/glfw/glfw_host.hpp b/adapters/glfw/glfw_host.hpp index 77f4b00..266202b 100644 --- a/adapters/glfw/glfw_host.hpp +++ b/adapters/glfw/glfw_host.hpp @@ -128,9 +128,10 @@ class Host { input::Controller* row_controller(u32 row) noexcept; /// The controller an event at framebuffer pixel (x, y) belongs to: `controller_` -- /// the single-plot loop -- while no figure is set, and the controller of the figure - /// row that pixel lands in otherwise. The row rects come from `Figure::layout()`, the - /// same ones the renderer draws, so a gesture lands on the row it was aimed at; a - /// pixel inside no row -- the row gap a host asked for with `Figure::set_row_gap()`, + /// row that pixel lands in otherwise. The row rects come from the cache + /// `refresh_row_rects()` fills -- the same ones the renderer draws -- so a gesture + /// lands on the row it was aimed at and no event lays the figure out; a pixel + /// inside no row -- the row gap a host asked for with `Figure::set_row_gap()`, /// or a margin -- is the *nearest* row rather than dropped or defaulted, so a drag /// that leaves the data area still ends where it should. /// @@ -147,13 +148,38 @@ class Host { /// /// The routing is the row the event lands in, with one exception that is the whole /// point of it: a press **captures** the row it landed in, and every motion and the - /// release until that button comes up go to the captured row. A drag that crosses + /// release until *that button* comes up go to the captured row. A drag that crosses /// into a neighbouring figure row is therefore still the drag it began as -- the /// owning controller sees the motion and the release that end it -- rather than /// being handed to a controller whose `mode_ == None`, which would leave the owner's /// drag open for ever and let a later gesture resume it from the old anchor. + /// + /// Single-gesture policy: a figure routes one button-down gesture at a time. The + /// first button to press claims the capture; a second button pressed meanwhile -- + /// a chord, or a trackpad that reports a second finger as a button -- neither + /// steals the capture to another row nor ends it, and only the owning button's + /// release ends it. Whatever the second button's events do to the controllers is + /// the controllers' business; the host only guarantees they all arrive at the row + /// the first press claimed. void feed_pointer_event(const input::PointerEvent& e) noexcept; + /// Re-reads the figure's row rects into the cache every event handler below + /// reads -- the one `Figure::layout()` the routing costs between two frames. The + /// run loop calls this once per frame, right after the figure's viewport is set, + /// so the rects the handlers answer from are the rects that frame draws; the + /// handlers themselves never lay the figure out. + /// + /// A driver that injects events without running the loop -- a scripted run, the + /// headless tests -- calls this after re-configuring the figure (a new viewport, + /// a new row gap, `set_figure()`): it is the scripted equivalent of the frame the + /// run loop would have staged. Until it runs, the handlers answer from the last + /// refreshed rects, which is also what happens across a window resize: GLFW + /// delivers the resize and any pointer events queued behind it in one batch, the + /// events before the resize see the rows the previous frame drew, and the loop's + /// next `refresh_row_rects()` brings the cache onto the new geometry -- rows are + /// at most one frame stale, never wrong by a second layout. + void refresh_row_rects() noexcept; + /// The gesture controller the host feeds. Valid for the host's whole life. input::Controller& controller() noexcept { return controller_; } @@ -198,11 +224,12 @@ class Host { static u32 row_in(const std::vector& rows, f32 y) noexcept; /// The controller a pointer event at (x, y) belongs to, applying the button capture: - /// a press claims the row it landed in (`captured_row_`) and every motion and the - /// release until the button comes up are routed to it; `controller_at()` is the pure - /// query that never captures. Also hands the target its row's viewport, so the - /// hit-test geometry is the last frame's. - input::Controller& pointer_target(f32 x, f32 y, bool pressed, bool released) noexcept; + /// a press claims the row it landed in (`captured_row_` + `captured_button_`) and + /// every motion and the release until *that button* comes up are routed to it; + /// `controller_at()` is the pure query that never captures. Also hands the target + /// its row's viewport, so the hit-test geometry is the last frame's. + input::Controller& pointer_target(f32 x, f32 y, input::Button button, bool pressed, + bool released) noexcept; /// The row the pointer's events currently go to: the captured row while a drag owns /// the button, the row under the pointer otherwise. The run loop uses it to clear @@ -254,11 +281,23 @@ class Host { f64 spread_y_ = 0.0; bool spread_active_ = false; - /// The row a button-down drag has captured: the row its press landed in, which - /// owns its motions and its release until the button comes up. `captured_` is - /// false between gestures, and cleared when another figure is set. + /// The row a button-down drag has captured, and the button that owns the capture: + /// the row its press landed in owns that button's motions and its release until + /// the button comes up. `captured_` is false between gestures, and cleared when + /// another figure is set. The button is recorded because only the owning button's + /// release ends the capture (R5): a second button pressed while the drag runs is + /// routed to the same row and must neither steal the capture nor end it, or its + /// release would strand the first button's drag. bool captured_ = false; u32 captured_row_ = 0u; + input::Button captured_button_ = input::Button::Left; + + /// The figure's row rects as of the last `refresh_row_rects()` -- the cache the + /// event routing reads instead of laying the figure out per event (R7). Filled by + /// `set_figure()` and once per frame by the run loop; empty while no figure is + /// set, which is the state `controller_at()`/`pointer_target()` fall back to the + /// single-plot controller on. + std::vector row_rects_; u32 fb_width_ = 0; ///< framebuffer size of the last refresh_sizes() u32 fb_height_ = 0; diff --git a/adapters/gtk4/gtk_input.hpp b/adapters/gtk4/gtk_input.hpp index 54e0c06..a8e69e5 100644 --- a/adapters/gtk4/gtk_input.hpp +++ b/adapters/gtk4/gtk_input.hpp @@ -229,4 +229,115 @@ inline f64 scroll_notches(f64 dy, ScrollUnit unit) noexcept { return -dy; } +/// The keyvals this adapter maps, as GDK's own numbers, mirrored here so the +/// mapping below -- and a test that drives it -- needs no GTK include directory. +/// `gtk_plot_area.cpp` static-asserts each against GDK's own macro. +/// +/// These are the *function* keyvals (the `0xFF00` page), fixed by X11's keysym +/// convention GDK inherits; a keypad arrow (a keypad key with Num Lock off) is a +/// different keyval and deliberately unmapped, exactly as the GLFW adapter maps +/// the main-row arrows and not `GLFW_KEY_KP_4`. +constexpr u32 kGdkKeyEscape = 0xFF1Bu; +constexpr u32 kGdkKeyLeft = 0xFF51u; +constexpr u32 kGdkKeyUp = 0xFF52u; +constexpr u32 kGdkKeyRight = 0xFF53u; +constexpr u32 kGdkKeyDown = 0xFF54u; + +/// One key-press translation, by keyval first and Unicode fallback second. +/// +/// GTK names a key by its keyval, which is layout- and level-dependent: `+` is +/// usually shift+`=` and arrives as a different keyval, but both decode to the +/// same Unicode code point, which is why the printable keys go through +/// `gdk_keyval_to_unicode()`. The function keys decode to no code point at all, +/// so they are matched on the keyval itself, before that fallback. `0` means "no +/// plot key": the caller returns the event to GTK unhandled. +/// +/// The keypad's plus and minus decode to the same code points as the main row's, +/// so they come along for free -- the same set the GLFW adapter maps with +/// `GLFW_KEY_KP_ADD`/`GLFW_KEY_KP_SUBTRACT`. +inline int key_from(u32 keyval, u32 unicode) noexcept { + switch (keyval) { + case kGdkKeyEscape: return input::kKeyEscape; + case kGdkKeyLeft: return input::kKeyLeft; + case kGdkKeyRight: return input::kKeyRight; + case kGdkKeyUp: return input::kKeyUp; + case kGdkKeyDown: return input::kKeyDown; + default: break; + } + switch (unicode) { + case static_cast(input::kKeyZoomIn): + case static_cast('='): return input::kKeyZoomIn; + case static_cast(input::kKeyZoomOut): + case static_cast('_'): return input::kKeyZoomOut; + case static_cast(input::kKeyFit): + case static_cast(input::kKeyFitUpper): return input::kKeyFit; + default: return 0; + } +} + +/// The state one `GtkGestureZoom` is folded into `input::GestureEvent`s from. +/// +/// `GtkGestureZoom` reports a **cumulative** scale (`gtk_gesture_zoom_get_scale_delta()` +/// and the `scale-changed` argument are the zoom since the gesture began, 1.0 at +/// the start) and knows the focal point only as the bounding box of the pointer +/// the gesture holds (`gtk_gesture_bounding_box_center()`), also cumulative in the +/// sense that it is where the fingers are *now*. The controller wants the +/// increments -- `GestureEvent::scale` is this frame's zoom factor about the focal +/// point, 1.0 = no change, and `(dx, dy)` the focal point's movement since the +/// previous gesture event -- so the area owns one of these and folds each +/// `scale-changed` exactly like the Qt adapter folds its pinch (`PinchTracker`). +/// +/// The focal point is cached in **logical** widget pixels, like `DragAnchor` above, +/// for the same reason: the mapping's factor is re-derived on every `::resize`, +/// and a cache in device pixels would compare two points taken at different +/// factors. The difference of two logical points, scaled by the factor in force +/// now, is the movement the fingers actually made. +/// +/// A plain value with no toolkit dependency, like every other type here: the area +/// owns one, and the tests drive it directly. +struct ZoomAnchor { + bool active = false; ///< true between the gesture's begin and its end + f32 last_scale = 1.0f; ///< the cumulative scale the previous update folded + f32 x = 0.0f; ///< last focal point, logical widget pixels + f32 y = 0.0f; + + /// Starts a gesture at its first cumulative `scale` and logical focal point. + /// Records only: a zoom that just began has zoomed and moved nothing yet. + void begin(f32 scale, Vec2 logical_center) noexcept { + active = true; + last_scale = scale; + x = logical_center.x; + y = logical_center.y; + } + + /// Folds one `scale-changed` into an incremental `GestureEvent`, in device + /// pixels at `mapping`: the zoom about the focal point, and the focal point's + /// movement since the previous update. A no-op before `begin()`, so a + /// `scale-changed` that arrives without a begin -- a host that missed the start + /// -- zooms nothing rather than zooming from a made-up anchor. + input::GestureEvent update(f32 scale, Vec2 logical_center, + const DeviceMapping& mapping) noexcept { + input::GestureEvent g; + if (!active) return g; + // This frame's zoom: the cumulative ratio over the one last folded. The first + // update after begin() divides the scale by itself -- 1.0, no zoom, as it + // should be when the fingers have not moved apart yet. + g.scale = last_scale != 0.0f ? scale / last_scale : 1.0f; + const Vec2 device = mapping.to_device(logical_center.x, logical_center.y); + g.x = device.x; + g.y = device.y; + const Vec2 moved{logical_center.x - x, logical_center.y - y}; + const Vec2 device_moved = mapping.to_device(moved.x, moved.y); + g.dx = device_moved.x; + g.dy = device_moved.y; + last_scale = scale; + x = logical_center.x; + y = logical_center.y; + return g; + } + + /// Ends the gesture: a straggler update after this zooms nothing. + void end() noexcept { active = false; } +}; + } // namespace plot::gtk4 diff --git a/adapters/gtk4/gtk_plot_area.cpp b/adapters/gtk4/gtk_plot_area.cpp index 2f7ebb0..9e0ee08 100644 --- a/adapters/gtk4/gtk_plot_area.cpp +++ b/adapters/gtk4/gtk_plot_area.cpp @@ -31,6 +31,11 @@ static_assert((1u << 2) == GDK_CONTROL_MASK, "mods_from's control bit must match static_assert((1u << 3) == GDK_ALT_MASK, "mods_from's alt bit must match GDK's"); static_assert((1u << 26) == GDK_SUPER_MASK, "mods_from's super bit must match GDK's"); static_assert((1u << 28) == GDK_META_MASK, "mods_from's meta bit must match GDK's"); +static_assert(plot::gtk4::kGdkKeyEscape == GDK_KEY_Escape, "the mirrored Escape keyval must match GDK's"); +static_assert(plot::gtk4::kGdkKeyLeft == GDK_KEY_Left, "the mirrored Left keyval must match GDK's"); +static_assert(plot::gtk4::kGdkKeyUp == GDK_KEY_Up, "the mirrored Up keyval must match GDK's"); +static_assert(plot::gtk4::kGdkKeyRight == GDK_KEY_Right, "the mirrored Right keyval must match GDK's"); +static_assert(plot::gtk4::kGdkKeyDown == GDK_KEY_Down, "the mirrored Down keyval must match GDK's"); namespace plot::gtk4 { namespace { @@ -158,6 +163,18 @@ PlotArea* PlotArea::create(Plot& plot) noexcept { g_signal_connect(drag, "drag-end", G_CALLBACK(&PlotArea::on_drag_end), self); gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(drag)); + // The two-finger pinch: `scale-changed` carries the cumulative zoom since the + // gesture began, and the gesture's bounding-box centre is the focal point -- the + // pair `ZoomAnchor` folds into the incremental `GestureEvent` the controller + // takes (zoom about the focal point, focal movement as the pan). No button: + // unlike the click/drag pair this gesture is touch-only by nature, and a mouse + // never produces one. + GtkGesture* zoom = gtk_gesture_zoom_new(); + g_signal_connect(zoom, "begin", G_CALLBACK(&PlotArea::on_zoom_begin), self); + g_signal_connect(zoom, "scale-changed", G_CALLBACK(&PlotArea::on_zoom_scale_changed), self); + g_signal_connect(zoom, "end", G_CALLBACK(&PlotArea::on_zoom_end), self); + gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(zoom)); + // Vertical only, as the plan asks: a purely horizontal scroll is not a zoom, and // because this controller never sees one it cannot be mistaken for one. GTK // reports the unit per event, so a discrete wheel arrives in notches and a @@ -279,6 +296,7 @@ void PlotArea::unrealize() noexcept { renderer_.reset(); press_open_ = false; drag_.have = false; + zoom_.end(); } void PlotArea::render() noexcept { @@ -364,7 +382,8 @@ void PlotArea::on_resize(GtkGLArea*, int width, int height, void* self) noexcept // --- pointer input -------------------------------------------------------- -void PlotArea::pointer_press(input::Button button, u32 mods, double x, double y) noexcept { +void PlotArea::pointer_press(input::Button button, u32 mods, double x, double y, + u32 time_ms) noexcept { if (press_open_) return; // one press per button-down sequence: see drag_begin press_open_ = true; press_button_ = button; @@ -383,11 +402,12 @@ void PlotArea::pointer_press(input::Button button, u32 mods, double x, double y) e.mods = mods; e.down = true; e.pressed = true; + e.time_s = pointer_time_s(time_ms); // the double-click detector differences these controller_.on_pointer(e); gtk_gl_area_queue_render(GTK_GL_AREA(widget_)); } -void PlotArea::pointer_release(Vec2 position) noexcept { +void PlotArea::pointer_release(Vec2 position, u32 time_ms) noexcept { if (!press_open_) return; press_open_ = false; @@ -400,6 +420,7 @@ void PlotArea::pointer_release(Vec2 position) noexcept { e.mods = press_mods_; e.down = false; e.released = true; + e.time_s = pointer_time_s(time_ms); controller_.on_pointer(e); gtk_gl_area_queue_render(GTK_GL_AREA(widget_)); } @@ -412,7 +433,8 @@ void PlotArea::on_click_pressed(GtkGestureClick* gesture, int, double x, double if (!button) return; // a button the controller has no gesture for // The modifiers come off the *event*, not the gesture: GTK reports the state as // it was before the press, which is what the controller's gesture table reads. - area->pointer_press(*button, gesture_mods(GTK_EVENT_CONTROLLER(gesture)), x, y); + area->pointer_press(*button, gesture_mods(GTK_EVENT_CONTROLLER(gesture)), x, y, + gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture))); } void PlotArea::on_click_released(GtkGestureClick* gesture, int, double x, double y, @@ -421,11 +443,11 @@ void PlotArea::on_click_released(GtkGestureClick* gesture, int, double x, double auto* area = static_cast(self); // The first release closes the gesture; a drag's own `drag-end` may already have // done it (GTK emits both, `drag-end` first), and this is then a no-op. - area->pointer_release(area->to_device(x, y)); + area->pointer_release(area->to_device(x, y), + gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture))); } void PlotArea::on_click_stopped(GtkGestureClick* gesture, void* self) noexcept { - (void)gesture; auto* area = static_cast(self); // A sequence can be cancelled rather than released -- GTK stops the click // gesture as soon as the drag gesture claims the same sequence, which happens on @@ -434,7 +456,11 @@ void PlotArea::on_click_stopped(GtkGestureClick* gesture, void* self) noexcept { // drag in progress (an exotic cancellation by another widget or a device) closes // the press, which it has to, or the controller would stay in whatever mode the // press began. - if (area->press_open_ && !area->drag_.have) area->pointer_release(area->pointer_); + if (area->press_open_ && !area->drag_.have) { + area->pointer_release( + area->pointer_, + gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture))); + } } void PlotArea::on_drag_begin(GtkGestureDrag* gesture, double start_x, double start_y, @@ -453,7 +479,8 @@ void PlotArea::on_drag_begin(GtkGestureDrag* gesture, double start_x, double sta const std::optional button = button_from(gtk_gesture_single_get_current_button(GTK_GESTURE_SINGLE(gesture))); if (button) { - area->pointer_press(*button, gesture_mods(GTK_EVENT_CONTROLLER(gesture)), start_x, start_y); + area->pointer_press(*button, gesture_mods(GTK_EVENT_CONTROLLER(gesture)), start_x, start_y, + gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture))); } } @@ -482,13 +509,14 @@ void PlotArea::on_drag_update(GtkGestureDrag* gesture, double offset_x, double o e.button = area->press_button_; e.mods = area->press_mods_; e.down = true; + e.time_s = area->pointer_time_s( + gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture))); area->controller_.on_pointer(e); gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_)); } void PlotArea::on_drag_end(GtkGestureDrag* gesture, double offset_x, double offset_y, void* self) noexcept { - (void)gesture; auto* area = static_cast(self); // The release carries the authoritative end of the gesture: the controller reads // its position to finish a box zoom's selection, so this is the press point plus @@ -496,7 +524,46 @@ void PlotArea::on_drag_end(GtkGestureDrag* gesture, double offset_x, double offs // and not the last update's position. const Vec2 position = area->drag_.end(offset_x, offset_y, area->mapping_); area->pointer_ = position; - area->pointer_release(position); + area->pointer_release( + position, gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture))); +} + +// --- the pinch ------------------------------------------------------------ + +void PlotArea::on_zoom_begin(GtkGestureZoom* gesture, GdkEventSequence*, void* self) noexcept { + auto* area = static_cast(self); + // The focal point is the centre of the bounding box around the pointers the + // gesture holds, in logical widget pixels -- the same space every other + // gesture coordinate here arrives in, and what `to_device()` maps. + double cx = 0.0; + double cy = 0.0; + gtk_gesture_get_bounding_box_center(GTK_GESTURE(gesture), &cx, &cy); + area->zoom_.begin(static_cast(gtk_gesture_zoom_get_scale_delta(gesture)), + Vec2{static_cast(cx), static_cast(cy)}); +} + +void PlotArea::on_zoom_scale_changed(GtkGestureZoom* gesture, double scale, + void* self) noexcept { + auto* area = static_cast(self); + double cx = 0.0; + double cy = 0.0; + gtk_gesture_get_bounding_box_center(GTK_GESTURE(gesture), &cx, &cy); + // The `scale` argument and `gtk_gesture_zoom_get_scale_delta()` are the same + // cumulative number -- the zoom since `::begin` -- so the folded event is this + // *frame's* zoom factor about the fingers' centre, plus the centre's movement, + // which is the two-finger pan. An update without a begin folds to a no-op (see + // `ZoomAnchor::update`), so a gesture whose start the host never saw zooms + // nothing rather than zooming from a made-up anchor. + const input::GestureEvent g = area->zoom_.update( + static_cast(scale), Vec2{static_cast(cx), static_cast(cy)}, area->mapping_); + area->controller_.on_gesture(g); + gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_)); +} + +void PlotArea::on_zoom_end(GtkGestureZoom*, GdkEventSequence*, void* self) noexcept { + // No trailing event: the last `scale-changed` carried the gesture's final zoom, + // and `end` only closes the folding so a straggler update zooms nothing. + static_cast(self)->zoom_.end(); } int PlotArea::on_scroll(GtkEventControllerScroll* scroll, double dx, double dy, @@ -550,6 +617,8 @@ void PlotArea::on_motion(GtkEventControllerMotion* motion, double x, double y, v e.dx = 0.0f; e.dy = 0.0f; e.down = false; + e.time_s = PlotArea::pointer_time_s( + gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(motion))); area->controller_.on_pointer(e); } @@ -560,20 +629,14 @@ int PlotArea::on_key_pressed(GtkEventControllerKey* keys, unsigned int keyval, (void)keycode; (void)keys; auto* area = static_cast(self); - // The keys are named by their *symbol*: GTK reports a layout's keyval, and the - // keypad's plus and minus are the same symbol as the main row's. `'='` is mapped - // with `'+'` so both spellings of "zoom in" work (on most layouts `+` *is* - // shift+`=`), the pair the GLFW and Qt adapters map. - int key = 0; - switch (gdk_keyval_to_unicode(keyval)) { - case static_cast(input::kKeyZoomIn): - case static_cast('='): key = input::kKeyZoomIn; break; - case static_cast(input::kKeyZoomOut): - case static_cast('_'): key = input::kKeyZoomOut; break; - case static_cast(input::kKeyFit): - case static_cast(input::kKeyFitUpper): key = input::kKeyFit; break; - default: return FALSE; // not a plot key: GTK and the window keep it - } + // The whole translation lives in gtk_input.hpp (see `key_from`): the function + // keys -- arrows, Escape -- are matched on the *keyval*, which is layout- and + // level-independent for them, and the printable ones on the Unicode they decode + // to, which is what makes `+` and shift+`=` (usually the same physical key) both + // zoom in, and the keypad's plus and minus come along for free. `0` is "no plot + // key": GTK and the window keep it. + const int key = key_from(static_cast(keyval), static_cast(gdk_keyval_to_unicode(keyval))); + if (key == 0) return FALSE; input::KeyEvent e; e.key = key; diff --git a/adapters/gtk4/gtk_plot_area.hpp b/adapters/gtk4/gtk_plot_area.hpp index a937398..a353a38 100644 --- a/adapters/gtk4/gtk_plot_area.hpp +++ b/adapters/gtk4/gtk_plot_area.hpp @@ -32,10 +32,12 @@ typedef struct _GtkGLArea GtkGLArea; typedef struct _GtkGesture GtkGesture; typedef struct _GtkGestureClick GtkGestureClick; typedef struct _GtkGestureDrag GtkGestureDrag; +typedef struct _GtkGestureZoom GtkGestureZoom; typedef struct _GtkEventControllerScroll GtkEventControllerScroll; typedef struct _GtkEventControllerKey GtkEventControllerKey; typedef struct _GtkEventControllerMotion GtkEventControllerMotion; typedef struct _GdkGLContext GdkGLContext; +typedef struct _GdkEventSequence GdkEventSequence; namespace plot::gtk4 { @@ -134,6 +136,10 @@ using FrameFn = std::function; /// release, with the button and the modifier state, exactly as the Qt /// widget's `mousePressEvent`/`mouseReleaseEvent` do. It is also what begins /// a cursor move: a ctrl+press on a cursor opens `DragMode::MoveCursor` there. +/// * `GtkGestureZoom` supplies the two-finger pinch: each `scale-changed` is +/// folded by `ZoomAnchor` (`gtk_input.hpp`) into one `GestureEvent` -- this +/// frame's zoom about the fingers' centre, and the centre's movement, which is +/// the two-finger pan a trackpad produces alongside it. /// * `GtkEventControllerMotion` supplies hover (the cursor hit test reads the /// last position even with no button down). Its positions are forwarded with /// `down == false` on purpose: a motion event that claimed a button was down @@ -271,9 +277,16 @@ class PlotArea { void refresh_viewport() noexcept; /// One pointer event, from whichever gesture saw it first. Owns the - /// press/release bookkeeping the two gestures share. - void pointer_press(input::Button button, u32 mods, double x, double y) noexcept; - void pointer_release(Vec2 position) noexcept; + /// press/release bookkeeping the two gestures share. `time_ms` is the event's + /// own GDK stamp, in milliseconds, forwarded as the `PointerEvent::time_s` the + /// double-click detector differences (see `pointer_time_s()`). + void pointer_press(input::Button button, u32 mods, double x, double y, + u32 time_ms) noexcept; + void pointer_release(Vec2 position, u32 time_ms) noexcept; + /// The gesture event's stamp, in the seconds `PointerEvent::time_s` speaks: GDK + /// counts milliseconds since an arbitrary epoch, and only differences are read, + /// so the epoch does not matter -- the unit does. + static f64 pointer_time_s(u32 time_ms) noexcept { return static_cast(time_ms) * 1.0e-3; } /// Translates the gesture-reported position/delta in *logical* widget pixels /// into device pixels. The one place that mapping exists. Vec2 to_device(double x, double y) const noexcept; @@ -289,6 +302,15 @@ class PlotArea { static void on_click_released(GtkGestureClick* gesture, int n_press, double x, double y, void* self) noexcept; static void on_click_stopped(GtkGestureClick* gesture, void* self) noexcept; + /// The pinch: `begin` anchors the folding, `scale-changed` folds one cumulative + /// scale into an incremental `GestureEvent`, and `end` closes it. `begin`/`end` + /// carry the sequence, which a one-active-pinch adapter never needs. + static void on_zoom_begin(GtkGestureZoom* gesture, GdkEventSequence* sequence, + void* self) noexcept; + static void on_zoom_scale_changed(GtkGestureZoom* gesture, double scale, + void* self) noexcept; + static void on_zoom_end(GtkGestureZoom* gesture, GdkEventSequence* sequence, + void* self) noexcept; static void on_drag_begin(GtkGestureDrag* gesture, double start_x, double start_y, void* self) noexcept; static void on_drag_update(GtkGestureDrag* gesture, double offset_x, double offset_y, @@ -324,6 +346,9 @@ class PlotArea { /// cached rather than re-read per event, so a `drag-update` reports the movement /// since the previous one, and `have` is whether a drag is recognised. DragAnchor drag_; + /// The pinch gesture's folding state (see `ZoomAnchor`): one per area, alive + /// between `::begin` and `::end` of the one zoom the gesture recognises. + ZoomAnchor zoom_; /// The last pointer position seen, in device pixels: the wheel's anchor when a /// scroll event carries no position of its own. Vec2 pointer_{}; diff --git a/adapters/imgui/imgui_plot.cpp b/adapters/imgui/imgui_plot.cpp index 1ad5c8c..c107ab6 100644 --- a/adapters/imgui/imgui_plot.cpp +++ b/adapters/imgui/imgui_plot.cpp @@ -201,6 +201,11 @@ void PlotWidget::forward_input(bool hovered) { e.down = any_down; e.pressed = pressed; e.released = released; + // ImGui's own clock: monotonic seconds since the context was created. The + // double-click detector differences two presses' stamps, so only the unit + // matters -- and ImGui's backend-driven time keeps running while the pointer + // is off-window, which is exactly what a wall clock should do. + e.time_s = ImGui::GetTime(); controller_->on_pointer(e); }; @@ -231,6 +236,15 @@ void PlotWidget::forward_input(bool hovered) { // Keyboard gestures, on the same hover gate as the pointer: a key pressed // while the plot is under the pointer is aimed at the plot. `+` on most // layouts is shift+`=`, and either spelling zooms in, as in the GLFW adapter. + // + // Every mapped key is asked with repeat = true. This is the adapter's whole key + // -repeat policy, and the odd one out among the hosts: GLFW, Qt and GTK each + // deliver the OS's auto-repeat as extra press events, while ImGui edge-detects + // presses once and only re-fires `IsKeyPressed` while the key is *held* if it is + // asked to. Holding `+` or an arrow on a plot is a zoom/pan the user expects to + // keep going (docs/embedding.md's shared policy: a held key repeats in every + // adapter), so the repeat is requested here. Escape repeats harmlessly -- + // cancelling an already-cancelled drag is a no-op. if (hovered && !io.WantTextInput) { const auto key = [&](int code) { input::KeyEvent e; @@ -239,15 +253,15 @@ void PlotWidget::forward_input(bool hovered) { e.down = true; // ImGui already edge-detected the press controller_->on_key(e); }; - if (ImGui::IsKeyPressed(ImGuiKey_Equal, false) || - ImGui::IsKeyPressed(ImGuiKey_KeypadAdd, false)) { - key(input::kKeyZoomIn); - } - if (ImGui::IsKeyPressed(ImGuiKey_Minus, false) || - ImGui::IsKeyPressed(ImGuiKey_KeypadSubtract, false)) { - key(input::kKeyZoomOut); - } - if (ImGui::IsKeyPressed(ImGuiKey_F, false)) key(input::kKeyFit); + const auto pressed = [](ImGuiKey k) { return ImGui::IsKeyPressed(k, true); }; + if (pressed(ImGuiKey_Equal) || pressed(ImGuiKey_KeypadAdd)) key(input::kKeyZoomIn); + if (pressed(ImGuiKey_Minus) || pressed(ImGuiKey_KeypadSubtract)) key(input::kKeyZoomOut); + if (pressed(ImGuiKey_F)) key(input::kKeyFit); + if (pressed(ImGuiKey_LeftArrow)) key(input::kKeyLeft); + if (pressed(ImGuiKey_RightArrow)) key(input::kKeyRight); + if (pressed(ImGuiKey_UpArrow)) key(input::kKeyUp); + if (pressed(ImGuiKey_DownArrow)) key(input::kKeyDown); + if (pressed(ImGuiKey_Escape)) key(input::kKeyEscape); } prev_down_[0] = down[0]; diff --git a/adapters/qt6/qt_plot_widget.cpp b/adapters/qt6/qt_plot_widget.cpp index 5511068..011be0a 100644 --- a/adapters/qt6/qt_plot_widget.cpp +++ b/adapters/qt6/qt_plot_widget.cpp @@ -11,6 +11,9 @@ #include #include #include +#include +#include +#include #include #include @@ -93,6 +96,10 @@ PlotWidget::PlotWidget(Plot& plot, QWidget* parent) // The controller's keys (+, - and f) are input to the plot, and a widget without // a focus policy never receives a key event. setFocusPolicy(Qt::StrongFocus); + // Two-finger zoom/pan arrives as one gesture, which no mouse-event handler sees: + // grabbing it routes QEvent::Gesture here, and `event()` folds it into the + // controller's `GestureEvent`s (zoom about the focal point, focal pan). + grabGesture(Qt::PinchGesture); } PlotWidget::~PlotWidget() { @@ -243,6 +250,10 @@ void PlotWidget::forward_pointer(QMouseEvent* event, bool pressed, bool released e.down = any_plot_button(event->buttons()); e.pressed = pressed; e.released = released; + // Qt's input-event stamp is milliseconds since an arbitrary epoch (the system + // monotonic clock, in practice); the controller only ever differences them, for + // the double-click detector, so the epoch does not matter -- the unit does. + e.time_s = static_cast(event->timestamp()) * 1.0e-3; controller_.on_pointer(e); event->accept(); update(); @@ -256,16 +267,26 @@ void PlotWidget::mouseReleaseEvent(QMouseEvent* event) { forward_pointer(event, void PlotWidget::mouseDoubleClickEvent(QMouseEvent* event) { forward_pointer(event, true, false); } void PlotWidget::wheelEvent(QWheelEvent* event) { - // Qt reports eighths of a degree, 120 to a notch; the controller takes notches, - // positive = zoom in, which is Qt's positive angleDelta().y() as well (the wheel - // pushed away from the user). Passing the raw delta would zoom 120x too far. - const f32 notches = static_cast(event->angleDelta().y()) / 120.0f; - if (notches == 0.0f) { - // A horizontal-only wheel is not a zoom, and it must not reach the controller: - // a zero-notch zoom changes no range but still runs the range setters, which - // clear auto scale -- so a stray horizontal scroll would silently stop a live - // axis from following its stream. Ignored, so an enclosing scroll area may - // still act on it. + // Two units arrive, one per axis: Qt's `angleDelta()` is in eighths of a degree, + // 120 to a notch, while `pixelDelta()` is in (high-resolution) scroll pixels and + // is the only non-zero delta a device that reports pixels produces. A pixel + // delta has no notch size Qt defines, so the divisor is this adapter's + // **calibration**, not a conversion: 40 scroll pixels are taken to be one notch, + // the order of a deliberate one- or two-finger scroll gesture, so such a gesture + // zooms about one detent instead of a fraction of one (the same reasoning, and + // the same role, as the GTK adapter's kSurfacePixelsPerNotch; a value measured + // on real hardware should replace it). + const QPoint angle = event->angleDelta(); + const QPoint pixels = event->pixelDelta(); + const bool pixel_scrolled = angle == QPoint(0, 0) && !pixels.isNull(); + const f32 notches = pixel_scrolled ? static_cast(pixels.y()) / 40.0f + : static_cast(angle.y()) / 120.0f; + const f32 notches_x = pixel_scrolled ? static_cast(pixels.x()) / 40.0f + : static_cast(angle.x()) / 120.0f; + if (notches == 0.0f && notches_x == 0.0f) { + // No zoom and no pan: the event moves nothing, so it must not reach the + // controller -- a zero-delta event would still run the range setters and clear + // auto scale. Ignored, so an enclosing scroll area may still act on it. event->ignore(); return; } @@ -273,7 +294,10 @@ void PlotWidget::wheelEvent(QWheelEvent* event) { input::WheelEvent e; e.x = p.x; e.y = p.y; - e.dx = static_cast(event->angleDelta().x()) / 120.0f; + // The horizontal delta is forwarded: the controller pans x by it (5% of the span + // a notch) instead of zooming, which is what a sideways scroll on a trackpad + // means -- and what every other adapter's events already let it do. + e.dx = notches_x; e.dy = notches; e.mods = mods_from(event->modifiers()); controller_.on_wheel(e); @@ -292,6 +316,16 @@ void PlotWidget::keyPressEvent(QKeyEvent* event) { case Qt::Key_Minus: case Qt::Key_Underscore: mapped = input::kKeyZoomOut; break; case Qt::Key_F: mapped = input::kKeyFit; break; + // Arrows pan (10% of the span per press, repeats included) and Escape cancels + // the active drag -- translated, like the ASCII set above, and the controller + // decides what each does. Not offered to the parent widget when handled: an + // Escape that reaches a dialog's reject button would cancel the *window*, not + // the plot's drag, because the widget has focus. + case Qt::Key_Escape: mapped = input::kKeyEscape; break; + case Qt::Key_Left: mapped = input::kKeyLeft; break; + case Qt::Key_Right: mapped = input::kKeyRight; break; + case Qt::Key_Up: mapped = input::kKeyUp; break; + case Qt::Key_Down: mapped = input::kKeyDown; break; default: QOpenGLWidget::keyPressEvent(event); // not a plot key: nobody else wants it return; @@ -307,4 +341,64 @@ void PlotWidget::keyPressEvent(QKeyEvent* event) { update(); } +bool PlotWidget::event(QEvent* event) { + if (event->type() == QEvent::DevicePixelRatioChange) { + // The window moved to a display with another scale factor (or the scale of the + // screen it is on changed). Everything the ratio decides -- the device + // viewport the controller hit-tests against, and the `ui_scale` the next + // frame's paint re-reads -- is refreshed here, before any event queued behind + // this one maps a gesture through the old geometry. `paintGL()` would catch up + // one frame later; a drag mid-gesture would pan by the wrong distance in + // exactly that frame. + refresh_viewport(); + } + if (event->type() == QEvent::Gesture) { + auto* gesture_event = static_cast(event); + // The widget grabbed Qt::PinchGesture, so its pinch events arrive here; a + // gesture this widget does not know is left for whoever else may want it. + if (QPinchGesture* pinch = + static_cast(gesture_event->gesture(Qt::PinchGesture)); + pinch != nullptr) { + forward_pinch(pinch, gesture_event); + } + } + return QOpenGLWidget::event(event); +} + +void PlotWidget::forward_pinch(QPinchGesture* gesture, QGestureEvent* event) { + // The gesture reports cumulative values; `PinchTracker` folds them into the + // incremental zoom and focal movement the controller's `GestureEvent` wants. + // Positions are logical widget pixels here, exactly like a mouse event's, and + // go through the same `devicePixelRatioF()` mapping `to_device()` applies. + const QPointF logical_center = gesture->centerPoint(); + const f64 dpr = static_cast(devicePixelRatioF()); + const Vec2 logical{static_cast(logical_center.x()), static_cast(logical_center.y())}; + + switch (gesture->state()) { + case Qt::GestureStarted: + // The begin records the anchor only: a pinch that just started has zoomed + // and moved nothing yet. + pinch_.begin(static_cast(gesture->totalScaleFactor()), logical); + break; + case Qt::GestureUpdated: + case Qt::GestureFinished: { + // The finished state's last update is the gesture's final zoom: folded like + // an update, so nothing the fingers did is dropped for ending. + const input::GestureEvent g = + pinch_.update(static_cast(gesture->totalScaleFactor()), logical, dpr); + controller_.on_gesture(g); + break; + } + default: + // Canceled: the fingers left the surface mid-gesture. No event -- whatever + // zoom the updates delivered stands, and the next pinch begins fresh. + break; + } + if (gesture->state() == Qt::GestureFinished || gesture->state() == Qt::GestureCanceled) { + pinch_.end(); + } + event->accept(gesture); // consumed: the plot zoomed/panned, no parent gets it too + update(); +} + } // namespace plot::qt diff --git a/adapters/qt6/qt_plot_widget.hpp b/adapters/qt6/qt_plot_widget.hpp index 376dd73..e63f37b 100644 --- a/adapters/qt6/qt_plot_widget.hpp +++ b/adapters/qt6/qt_plot_widget.hpp @@ -34,6 +34,9 @@ class QKeyEvent; class QMouseEvent; class QPointF; class QWheelEvent; +class QEvent; +class QGestureEvent; +class QPinchGesture; namespace plot::qt { @@ -106,10 +109,15 @@ namespace plot::qt { /// reinterpretation rather than the edge synthesis the ImGui adapter needs. /// Wheel input is the one unit conversion: Qt's `angleDelta()` is in eighths of a /// degree, 120 to a notch, while `Controller::on_wheel()` takes notches, positive -/// = zoom in -- which is Qt's positive `angleDelta().y()` too. A wheel event with -/// no vertical component is refused before it reaches the controller, because a -/// zero-notch zoom changes no range but still runs the range setters, which clear -/// auto scale: a horizontal scroll must not silently stop a live axis following. +/// = zoom in -- which is Qt's positive `angleDelta().y()` too. A wheel event whose +/// deltas both vanish -- no angle and no pixels -- is refused before it reaches the +/// controller, because a zero-notch, zero-pan event changes nothing and must not +/// clear auto scale; anything with a vertical *or* horizontal component is +/// forwarded, and the controller decides what each does (zoom on `dy`, pan on +/// `dx`). The widget is also a pinch source: `Qt::PinchGesture` is grabbed in the +/// constructor and folded into `input::GestureEvent`s by `PinchTracker` +/// (`qt_pointer.hpp`), so a two-finger zoom-about-focal-point and a two-finger pan +/// reach the controller like the equivalent wheel + drag pair would. /// /// ## Threading /// @@ -170,6 +178,12 @@ class PlotWidget : public QOpenGLWidget, protected QOpenGLFunctions { void initializeGL() override; void resizeGL(int w, int h) override; void paintGL() override; + /// The one virtual that sees what no dedicated handler does: the device pixel + /// ratio change (`QEvent::DevicePixelRatioChange`) and the grabbed pinch + /// (`QEvent::Gesture`). Both refresh or feed the controller before the next + /// paint, so a gesture arriving between the change and the frame maps against + /// the geometry the ratio now implies. + bool event(QEvent* event) override; void mousePressEvent(QMouseEvent* event) override; void mouseMoveEvent(QMouseEvent* event) override; @@ -196,6 +210,10 @@ class PlotWidget : public QOpenGLWidget, protected QOpenGLFunctions { /// Sets the plot's viewport to the current device rect (through the controller, /// which refreshes the layout with it). void refresh_viewport(); + /// Folds one pinch update into a `GestureEvent` through `PinchTracker` and hands + /// it to the controller. `event` is accepted for the pinch gesture, so Qt does + /// not offer it to parents too. + void forward_pinch(QPinchGesture* gesture, QGestureEvent* event); Plot* plot_ = nullptr; ///< declared first: the controller is built over it input::Controller controller_; @@ -207,6 +225,9 @@ class PlotWidget : public QOpenGLWidget, protected QOpenGLFunctions { /// reports no delta of its own, and the ratio can change between two events /// (see `PointerAnchor` for why the anchor must not be device pixels). PointerAnchor anchor_; + /// The pinch gesture's folding state (see `PinchTracker`): one per widget, alive + /// between `Qt::GestureStarted` and `Finished`/`Canceled`. + PinchTracker pinch_; u64 frames_ = 0; f32 last_frame_ms_ = 0.0f; diff --git a/adapters/qt6/qt_pointer.hpp b/adapters/qt6/qt_pointer.hpp index eedc2e6..9b812da 100644 --- a/adapters/qt6/qt_pointer.hpp +++ b/adapters/qt6/qt_pointer.hpp @@ -4,6 +4,7 @@ #pragma once #include +#include #include namespace plot::qt { @@ -49,4 +50,69 @@ struct PointerAnchor { } }; +/// The state one pinch gesture is folded into `input::GestureEvent`s from. +/// +/// Qt reports a pinch as **cumulative** values with no delta of its own: +/// `QPinchGesture::totalScaleFactor()` is the zoom since the gesture began, and +/// `centerPoint()` the focal point now, both absolute in the gesture's life. The +/// controller wants the opposite of both -- `GestureEvent::scale` is *this frame's* +/// zoom factor (1.0 = no change) and `(dx, dy)` the focal point's movement since the +/// previous gesture event -- so the widget owns one of these and folds each update: +/// the zoom arrives as the ratio of the two cumulative scales, the pan as the +/// difference of two focal points. +/// +/// The focal point is cached in **logical** widget pixels, like `PointerAnchor` +/// above, for the same reason: `devicePixelRatioF()` can change mid-gesture (the +/// window dragged onto another display), and a cache in device pixels would compare +/// a point taken at the old ratio with one taken at the new one -- a phantom zoom +/// anchor movement, delivered to the controller as a pan. The difference of two +/// logical points, scaled by the ratio in force now, is the movement the fingers +/// actually made. +/// +/// A plain value with no toolkit dependency: the widget owns one, and this is the +/// part of the adapter's pinch translation a test can drive directly (see +/// tests/test_qt_pointer.cpp). +struct PinchTracker { + bool active = false; ///< true between the gesture's begin and its end/cancel + f32 last_scale = 1.0f; ///< the cumulative scale the previous update folded + f32 x = 0.0f; ///< last focal point, logical widget pixels + f32 y = 0.0f; + + /// Starts a gesture at Qt's first cumulative `scale` and logical focal point. + /// The first event must carry no zoom and no movement -- a pinch that begins has + /// zoomed nothing yet -- so this only records where the folding starts from. + void begin(f32 scale, Vec2 logical_center) noexcept { + active = true; + last_scale = scale; + x = logical_center.x; + y = logical_center.y; + } + + /// Folds one update into an incremental `GestureEvent`, in device pixels at + /// `dpr`: the zoom about the focal point, and the focal point's movement since + /// the previous update. Zero zoom and zero movement before `begin()`, so an + /// update that arrives without a begin -- a host that missed the start -- is a + /// no-op rather than a zoom from a made-up anchor. + input::GestureEvent update(f32 scale, Vec2 logical_center, f64 dpr) noexcept { + input::GestureEvent g; + if (!active) return g; + // The frame's zoom factor: Qt's cumulative ratio over the one this tracker + // last folded. The first update after begin() divides the scale by itself, + // which is 1.0 -- a pinch that has not moved has zoomed nothing. + g.scale = last_scale != 0.0f ? scale / last_scale : 1.0f; + g.x = static_cast(static_cast(logical_center.x) * dpr); + g.y = static_cast(static_cast(logical_center.y) * dpr); + g.dx = static_cast((static_cast(logical_center.x) - static_cast(x)) * dpr); + g.dy = static_cast((static_cast(logical_center.y) - static_cast(y)) * dpr); + last_scale = scale; + x = logical_center.x; + y = logical_center.y; + return g; + } + + /// Ends the gesture: an update after this (a straggler `GestureUpdated` Qt + /// delivers out of order, a cancel that still reports) is again a no-op. + void end() noexcept { active = false; } +}; + } // namespace plot::qt diff --git a/cmake/plot_options.cmake b/cmake/plot_options.cmake index 418fe3b..f1fd8c6 100644 --- a/cmake/plot_options.cmake +++ b/cmake/plot_options.cmake @@ -16,6 +16,30 @@ option(PLOT_BUILD_GLFW "Build the GLFW adapter and demo" OFF) option(PLOT_BUILD_IMGUI "Build the Dear ImGui adapter and demo" OFF) option(PLOT_BUILD_QT6 "Build the Qt6 adapter and demo" OFF) option(PLOT_BUILD_GTK4 "Build the GTK4 adapter and demo" OFF) +# The optional runtime-font backend: plot::text::TrueTypeFont, which bakes a real +# TrueType/OpenType file into a fixed-cell atlas at run time (see +# include/plot/text/true_type_font.hpp). It is the one target that compiles the +# vendored third_party/stb_truetype.h, so it is opt-in and separable -- the core +# library, its public headers and its tests build without it -- and its default +# follows the vendored header's presence: a tree that has the file wants the +# feature, and one that does not cannot have it. Asking for it explicitly without +# the header is an error rather than a silent downgrade, because the option is +# then the only thing that would have said so. The top-level CMakeLists.txt adds +# the target itself; this file only decides whether it exists. +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/third_party/stb_truetype.h") + set(PLOT_VENDORED_STB_TRUETYPE TRUE) +else() + set(PLOT_VENDORED_STB_TRUETYPE FALSE) +endif() +option(PLOT_BUILD_TTF_FONT + "Build the optional plot::text::TrueTypeFont backend (vendored stb_truetype)" + ${PLOT_VENDORED_STB_TRUETYPE}) +if(PLOT_BUILD_TTF_FONT AND NOT PLOT_VENDORED_STB_TRUETYPE) + message(FATAL_ERROR + "plot: PLOT_BUILD_TTF_FONT=ON needs third_party/stb_truetype.h, which this tree does " + "not have. Restore it -- include/plot/text/true_type_font.hpp records the version and " + "the source URL it came from -- or configure with -DPLOT_BUILD_TTF_FONT=OFF.") +endif() option(PLOT_INSTALL "Generate install and export rules (find_package(plot))" ON) set(PLOT_SANITIZE "" CACHE STRING "Sanitizers to build with, as the list a -fsanitize= flag takes (e.g. address); empty for none") @@ -27,9 +51,22 @@ set(PLOT_SANITIZE "" CACHE STRING # presets leave it off, so their target lists, configure time and build product # are exactly what they were before this option existed. option(PLOT_COVERAGE "Add the `coverage` target (needs a --coverage build tree)" OFF) -if(PLOT_COVERAGE AND NOT CMAKE_CXX_FLAGS MATCHES "--coverage") +# The instrumented tree is one whose *effective* flags carry --coverage, and the +# base CMAKE_CXX_FLAGS is only part of those: the active configuration's own +# profile (CMAKE_CXX_FLAGS_) is applied on top, and --coverage is +# equally at home in either. The linux-coverage preset sets the base; a user who +# sets it on the Debug profile instead gets exactly the same instrumented tree, +# which the old base-only string match rejected. Matching the two together is +# still just an option check on the configuration profile -- the guard does not +# try to read the compiler's mind, only the flags this build will pass it. +string(TOUPPER "${CMAKE_BUILD_TYPE}" plot_coverage_config) +set(plot_coverage_flags + "${CMAKE_CXX_FLAGS} ${CMAKE_CXX_FLAGS_${plot_coverage_config}}") +if(PLOT_COVERAGE AND NOT plot_coverage_flags MATCHES "--coverage") message(FATAL_ERROR - "plot: PLOT_COVERAGE=ON needs a --coverage build (CMAKE_CXX_FLAGS is \"${CMAKE_CXX_FLAGS}\"). " + "plot: PLOT_COVERAGE=ON needs a --coverage build (CMAKE_CXX_FLAGS is " + "\"${CMAKE_CXX_FLAGS}\" and CMAKE_CXX_FLAGS_${plot_coverage_config} is " + "\"${CMAKE_CXX_FLAGS_${plot_coverage_config}}\"). " "Configure with --preset linux-coverage, or drop -DPLOT_COVERAGE=ON.") endif() if(PLOT_COVERAGE AND NOT PLOT_BUILD_TESTS) diff --git a/cmake/plot_umbrella_check.cmake b/cmake/plot_umbrella_check.cmake index af11cd7..f87f9cf 100644 --- a/cmake/plot_umbrella_check.cmake +++ b/cmake/plot_umbrella_check.cmake @@ -32,6 +32,21 @@ # to the list below; a caller may override it, and # emptying it is how a test proves the exemption is what # keeps a header out of the "missing" report. +# PLOT_EXTRA_PUBLIC_HEADERS generated public headers, relative to +# PLOT_INCLUDE_DIR, that the glob below cannot see +# because configure_file() writes them into the build +# tree (plot/version.hpp). They are checked like any +# other public header: the umbrella has to name them, +# and the dangling-include check cannot demand them +# under PLOT_INCLUDE_DIR, because their existence is +# pinned by the configure that generated them. Defaults +# to the project's own list, on the same terms as the +# optional list: exact paths, because a hole should be +# a list of files. +# PLOT_GENERATED_INCLUDE_DIR the build-tree directory those headers live in, +# consulted when one of them is not under +# PLOT_INCLUDE_DIR. Unset in a tree with no generated +# headers. if(NOT DEFINED PLOT_OPTIONAL_HEADERS) set(PLOT_OPTIONAL_HEADERS @@ -42,6 +57,18 @@ if(NOT DEFINED PLOT_OPTIONAL_HEADERS) "plot/text/true_type_font.hpp") endif() +if(NOT DEFINED PLOT_EXTRA_PUBLIC_HEADERS) + set(PLOT_EXTRA_PUBLIC_HEADERS + # , generated by configure_file() from + # include/plot/version.hpp.in into the build tree and installed with the + # public headers. The glob cannot see it -- it is not under the source + # include root -- and neither can the dangling-include check, so the + # default list declares it: the umbrella must still carry its include, and + # a script run without the caller's -D inputs (the exemption test, a bare + # -P invocation) judges the umbrella exactly as the build does. + "plot/version.hpp") +endif() + if(NOT DEFINED PLOT_INCLUDE_DIR) message(FATAL_ERROR "plot: PLOT_INCLUDE_DIR is not set") endif() @@ -52,6 +79,9 @@ endif() file(GLOB_RECURSE plot_umbrella_headers RELATIVE "${PLOT_INCLUDE_DIR}" "${PLOT_INCLUDE_DIR}/plot/*.hpp") +if(PLOT_EXTRA_PUBLIC_HEADERS) + list(APPEND plot_umbrella_headers ${PLOT_EXTRA_PUBLIC_HEADERS}) +endif() list(SORT plot_umbrella_headers) file(READ "${PLOT_UMBRELLA}" plot_umbrella_text) @@ -100,8 +130,19 @@ string(REGEX MATCHALL "#include ]+>" plot_umbrella_includes "${plot_umb set(plot_umbrella_dangling "") foreach(plot_umbrella_include IN LISTS plot_umbrella_includes) string(REGEX REPLACE "#include <([^>]+)>" "\\1" plot_umbrella_header "${plot_umbrella_include}") + # A declared generated header is exempt from the existence demand: it lives + # in the build tree by construction -- the configure that produced this + # umbrella also produced the header -- so a run without + # PLOT_GENERATED_INCLUDE_DIR set (a bare -P invocation) judges it on the + # umbrella naming it, which the required-set loop above already checked. + if(plot_umbrella_header IN_LIST PLOT_EXTRA_PUBLIC_HEADERS) + continue() + endif() if(NOT EXISTS "${PLOT_INCLUDE_DIR}/${plot_umbrella_header}") - list(APPEND plot_umbrella_dangling "${plot_umbrella_header}") + if(NOT DEFINED PLOT_GENERATED_INCLUDE_DIR OR + NOT EXISTS "${PLOT_GENERATED_INCLUDE_DIR}/${plot_umbrella_header}") + list(APPEND plot_umbrella_dangling "${plot_umbrella_header}") + endif() endif() endforeach() diff --git a/docs/building-and-testing.md b/docs/building-and-testing.md index 66278b6..b2968b4 100644 --- a/docs/building-and-testing.md +++ b/docs/building-and-testing.md @@ -41,9 +41,27 @@ ctest --test-dir build/debug --output-on-failure ctest registers four tests: `plot_tests` (the test binary), the two header/umbrella build checks (`plot_headers_self_contained`, `plot_umbrella_covers_public_headers`) and -`plot_umbrella_check`. The `plot_tests` binary itself runs **283 cases** in the `linux-debug` -preset; its harness prints one line per case and a final `283 cases, 0 failed` summary line. -The case counts per file are the `PLOT_TEST(...)` declarations under `tests/`. +`plot_umbrella_check`. The `plot_tests` binary itself runs **350 cases** in the `linux-debug` +preset; its harness prints one line per case and a final `350 cases, 0 failed` summary line. +The case counts per file are the `PLOT_TEST(...)` declarations under `tests/` — recount them +(`grep -c '^PLOT_TEST(' tests/test_*.cpp`) after adding or removing cases. + +## Install layout + +`cmake --install` follows `GNUInstallDirs`, so every path honours the standard cache +variables (`CMAKE_INSTALL_LIBDIR`, `CMAKE_INSTALL_INCLUDEDIR`, …) and their distribution +defaults — plain `lib`, `include` and `share` unless a packager overrides them: + +| prefix-relative path | what | +|---|---| +| `lib/` | the `plot_core` archive (and `libplot_core.so` with its version and SONAME symlinks when built with `BUILD_SHARED_LIBS=ON`) | +| `include/plot/` | every public header, including the generated `plot/version.hpp` (`plot::version::major/minor/patch/string` and the `PLOT_VERSION_*` macros) | +| `lib/cmake/plot/` | the CMake package: `plotConfig.cmake`, `plotConfigVersion.cmake` (Compatibility **SameMinorVersion**, so `find_package(plot 0.1 …)` accepts 0.1.x only), `plotTargets.cmake` | +| `lib/pkgconfig/plot.pc` | pkg-config metadata for consumers whose build is not CMake | +| `share/licenses/plot/` | `LICENSE` (MIT) and `licences/OFL-1.1.txt` (the embedded font) | + +Adapters that were built and exported install themselves alongside the core (their headers +under `include//`, their targets in the same package). ## Headless demo modes @@ -95,15 +113,15 @@ The floors are enforced by `tools/coverage_floor.py`: | every file under `src/` | **80%** of its instrumented lines | | `src/` as a whole | **90%** of instrumented lines | -Branch coverage is deliberately off. The current measurement (from `build/cov/coverage.info`, -re-read with `python3 tools/coverage_floor.py build/cov/coverage.info`) is an **aggregate -95.3% (3284/3447 lines)** over 23 files, every file at or above 80% — the floor is satisfied. -The method, the full per-file table and the reproducibility notes are in -[docs/coverage.md](coverage.md); re-run the command for the live numbers. +Branch coverage is deliberately off. This page carries no measured totals — a number written +here would be stale the moment `src/` moved, and the report is reproducible: the method, the +per-file table and the floor verdict live in [docs/coverage.md](coverage.md), which +`tools/coverage.sh` rewrites from the very run that measured them. Re-run the command for the +live numbers. -**One thing to know about the coverage preset:** it builds **272 of the 283 cases**. The 11 -that are missing are the adapter cases that need an adapter target to exist — 8 in -`test_imgui_widget.cpp` and 3 in `test_glfw_host.cpp` — and the coverage preset turns the -adapters off. The toolkit-free adapter seams still run there: `test_qt_pointer.cpp` (2 cases) -and `test_gtk_input.cpp` (8 cases) compile without Qt or GTK. So the coverage number measures +**One thing to know about the coverage preset:** it builds **333 of the 350 cases**. The 17 +that are missing are the adapter cases that need an adapter target to exist — 11 in +`test_imgui_widget.cpp` and 6 in `test_glfw_host.cpp` — and the coverage preset turns the +adapters off. The toolkit-free adapter seams still run there: `test_qt_pointer.cpp` (5 cases) +and `test_gtk_input.cpp` (11 cases) compile without Qt or GTK. So the coverage number measures the library plus its unit suite, not the adapter bodies (which the headless demo runs cover). diff --git a/docs/coverage.md b/docs/coverage.md index 8f1773e..7aa6084 100644 --- a/docs/coverage.md +++ b/docs/coverage.md @@ -43,35 +43,36 @@ when one is missing. ``` -coverage_floor: ok -- 23 files under src/, aggregate 95.2% lines (3302/3469), every file at or above 80%, aggregate target 90% +coverage_floor: ok -- 24 files under src/, aggregate 93.9% lines (3730/3971), every file at or above 80%, aggregate target 90% ``` | File | Lines | Line rate | Functions | | --- | ---: | ---: | ---: | -| `src/data/array_series.cpp` | 43/43 | 100.0% | 100.0% | -| `src/data/minmax_pyramid.cpp` | 163/163 | 100.0% | 100.0% | -| `src/data/series.cpp` | 105/107 | 98.1% | 100.0% | -| `src/data/stream_series.cpp` | 39/39 | 100.0% | 100.0% | -| `src/gl/handle.cpp` | 256/256 | 100.0% | 100.0% | +| `src/data/array_series.cpp` | 56/56 | 100.0% | 100.0% | +| `src/data/minmax_pyramid.cpp` | 170/171 | 99.4% | 96.4% | +| `src/data/series.cpp` | 161/164 | 98.2% | 100.0% | +| `src/data/stream_series.cpp` | 45/45 | 100.0% | 100.0% | +| `src/data/xy_series.cpp` | 102/120 | 85.0% | 100.0% | +| `src/gl/handle.cpp` | 259/259 | 100.0% | 100.0% | | `src/gl/loader.cpp` | 81/81 | 100.0% | 100.0% | -| `src/input/controller.cpp` | 329/343 | 95.9% | 100.0% | +| `src/input/controller.cpp` | 430/465 | 92.5% | 97.4% | | `src/math/transform.cpp` | 28/28 | 100.0% | 100.0% | | `src/plot/axis.cpp` | 12/12 | 100.0% | 100.0% | -| `src/plot/figure.cpp` | 218/223 | 97.8% | 95.5% | +| `src/plot/figure.cpp` | 229/234 | 97.9% | 95.8% | | `src/plot/grid.cpp` | 15/16 | 93.8% | 100.0% | | `src/plot/measure.cpp` | 21/21 | 100.0% | 100.0% | -| `src/plot/plot.cpp` | 743/763 | 97.4% | 98.9% | -| `src/plot/ticks.cpp` | 93/99 | 93.9% | 100.0% | +| `src/plot/plot.cpp` | 767/793 | 96.7% | 98.9% | +| `src/plot/ticks.cpp` | 105/111 | 94.6% | 100.0% | | `src/render/buffer.cpp` | 88/98 | 89.8% | 100.0% | | `src/render/embedded_mono_font.cpp` | 45/45 | 100.0% | 100.0% | -| `src/render/primitives.cpp` | 71/75 | 94.7% | 100.0% | +| `src/render/primitives.cpp` | 75/79 | 94.9% | 100.0% | | `src/render/program.cpp` | 26/27 | 96.3% | 100.0% | -| `src/render/renderer.cpp` | 530/609 | 87.0% | 93.7% | +| `src/render/renderer.cpp` | 616/722 | 85.3% | 93.9% | | `src/render/text.cpp` | 27/30 | 90.0% | 100.0% | | `src/render/text_atlas.cpp` | 40/41 | 97.6% | 100.0% | -| `src/render/vertex_array.cpp` | 59/59 | 100.0% | 100.0% | +| `src/render/vertex_array.cpp` | 62/62 | 100.0% | 100.0% | | `src/text/true_type_font.cpp` | 270/291 | 92.8% | 96.8% | -| **src/ aggregate** | **3302/3469** | **95.2%** | **98.3%** | +| **src/ aggregate** | **3730/3971** | **93.9%** | **98.0%** | diff --git a/docs/data-and-performance.md b/docs/data-and-performance.md index 0466e62..04d4113 100644 --- a/docs/data-and-performance.md +++ b/docs/data-and-performance.md @@ -2,20 +2,27 @@ ## The sample stores -All three series derive from `plot::data::Series` (`include/plot/data/series.hpp`), which -describes a **uniform-grid** series: sample `i` lives at `x = x0() + i * dx()`. +All series derive from `plot::data::Series` (`include/plot/data/series.hpp`), which +describes the sample-indexing contract; a **uniform-grid** series additionally has +sample `i` at `x = x0() + i * dx()` (`ArraySeries`, `StreamSeries`), while +**`XYSeries`** carries its own sorted, ascending x array for data that is not on a +uniform grid. ```cpp class Series { public: virtual u64 size() const; virtual f64 x0() const; // x of sample 0 (the oldest retained sample) - virtual f64 dx() const; + virtual f64 dx() const; // 0 for XYSeries, which owns its x array virtual Range x_bounds() const; virtual Range y_bounds() const; virtual const MinMaxPyramid& pyramid() const; virtual bool streaming() const noexcept; // default false - virtual void gather(u32 level, u64 begin, u64 end, std::vector& out_xy) const; + virtual f64 x_at(u64 i) const; // x0() + i*dx(), or x()[i] + virtual bool neighbours(f64 x, u64& i0, u64& i1) const; // bracketing samples + virtual u64 snap_index(f64 x) const; // nearest sample + virtual void gather(u32 level, u64 begin, u64 end, std::vector& out_xy, + GatherMode mode = GatherMode::Envelope) const; Range reduce(u64 begin, u64 end) const; // exact min/max over [begin, end) f64 sum(u64 begin, u64 end) const; // exact sum over [begin, end) @@ -29,11 +36,65 @@ Indices are the series' **own window**: index 0 is the oldest sample it currentl **`ArraySeries`** (`array_series.hpp`) owns a `std::vector` of y samples plus an `x0`/`dx` grid. Construct with `ArraySeries(std::vector y, f64 x0, f64 dx)`. The vector is taken by value and owned, so the samples cannot change behind the series' back; the store -changes only through `reset(y, x0, dx)` (a whole new vector, then a rebuild) or `commit()`, -which rebuilds the pyramid and cached bounds from the current `y`. +changes only through `reset(y, x0, dx)` (a whole new vector, then a rebuild), `commit()`, +which rebuilds the pyramid and cached bounds from the current `y`, or `push(y)`, which +appends one sample incrementally — the pyramid folds it in amortised O(1) and the bounds +extend in place, NaN-aware, instead of rebuilding. **`StreamSeries`** (`stream_series.hpp`) is the single-producer / single-consumer streaming -store: `StreamSeries(u64 capacity, f64 x0, f64 dx)`. +store: `StreamSeries(u64 capacity, f64 x0, f64 dx)`. `capacity` is rounded up to a power of +two by the ring, `size()` saturates at that rounded `capacity()` (a `StreamSeries(100)` +retains up to 128 samples), and the pyramid is reserved for the rounded value up front. +`freeze()` copies the window published by the last `acquire()` into a fresh `ArraySeries` +(consumer thread only): a stable snapshot that no later production can move. + +## Non-finite samples + +The library-wide NaN policy: **a non-finite sample is excluded from every aggregate** — +min, max, sum, sum of squares and the pyramid's per-bucket finite count — and from the +cached bounds. A window with no finite samples at all aggregates to a **zero-span +`Range` / zero sums**, never to NaN. The policy is applied everywhere an aggregate is +built: `MinMaxPyramid::append` (a NaN occupies its absolute position, so bucket +boundaries stay positional, but folds nothing), the edge folds in `Series::reduce`, +`sum` and `sumsq`, `ArraySeries::commit` and `push` bounds, and +`StreamSeries::acquire` bounds. A bucket whose samples were all non-finite is stored as +an *empty* aggregate (count 0, zero sums) that every reader skips — it is absent, not +zero-valued. `gather()` follows suit: Envelope and Mean emit nothing for such a bucket, +and Nearest draws its centre sample raw. Hosts that want gaps in the data should draw +them with NaN samples and will get exactly that: excluded from statistics, present in +raw geometry. + +## Gather x is relative, and what each mode emits + +**`Series::gather` emits x relative to `x0()`** — exactly `i * dx` for a uniform +series, `x_at(i) - x0()` for `XYSeries` — never the absolute coordinate. y is +unchanged. The renderer reconstructs absolute data x in f64 (`x0() + emitted`) before +transforming, so no precision is lost at the f32 narrowing: the staged value is +O(visible pixels), not O(epoch). (Staging an absolute epoch x as f32 would quantise it +to a grid coarser than the pixel — f32 carries 24 bits, about 1 s at 1.7e9 — while the +relative value is small and exact.) The emitted geometry always spans +`[begin, end)` exactly and never reaches outside it: at level ≥ 1 the bucket edges are +selected ceil/floor (`first` = first bucket starting at or after `begin`, `last` = one +past the last bucket ending at or before `end`) and the two partial edge spans are +drawn from raw samples, which is what keeps an unaligned window envelope-exact +(previously the buckets straddling the range leaked samples from outside it into the +geometry). `level` is clamped to `kMaxPyramidLevel` (57 — the deepest level whose +bucket span fits a `u64`), so any `u32` level is safe. + +The `GatherMode` decides what each bucket contributes, at the same level the renderer +already picked: + +- **`Envelope`** (the default): one (min, max) pair per bucket, min drawn at the + bucket's first sample and max at its last. The dense, extreme-reading geometry a + time-series trace wants. +- **`Nearest`**: one raw sample per bucket — the centre sample, the pick + `pyramid().snap()` would make — drawn at its own x. Thinner geometry for traces + whose *shape* matters more than their extremes. +- **`Mean`**: one point per bucket, y = the bucket's finite samples' mean (the exact + `bucket_sum()` the pyramid stores divided by its finite count), x at the bucket + centre. A smoothed trace for noisy data. + +Level 0 is the raw samples for every mode. ## The SPSC contract @@ -82,11 +143,12 @@ the visible pixels rather than on the sample count. and, when it completes, pushes it and walks up, stopping at the first level that gains no entry: an append costs **amortised O(1)**. `drop_before(u)` slides the window's leading edge, bounding the memory by the retained window rather than by everything ever appended. -- An entry is 24 bytes (`MinMax` plus the exact `sum`/`sumsq` the exact range queries fold - over), so level 0 costs `0.375n` bytes and the geometric tail brings the whole pyramid to - about **`0.75n` bytes — roughly 19% of the `4n` bytes of raw samples** (`f32`). A - min/max-only index would be `0.25n` (~6%); the two doubles are the price of answering - mean/RMS/integral from whole buckets instead of raw samples. +- An entry is 32 bytes (`MinMax` plus the exact `sum`/`sumsq` the exact range queries fold + over and the finite-sample `count` the mean folds by), so level 0 costs `0.5n` bytes and + the geometric tail brings the whole pyramid to about **`1n` bytes — roughly 25% of the + `4n` bytes of raw samples** (`f32`). A min/max-only index would be `0.25n` (~6%); the + extra words are the price of answering mean/RMS/integral from whole buckets instead of + raw samples. Queries on the pyramid are **bucket-grained** and named `*_buckets` (`reduce_buckets`, `sum_buckets`, `sumsq_buckets`): a pyramid stores aggregates, so it can only answer exactly diff --git a/docs/embedding.md b/docs/embedding.md index 4a54863..a8f4b78 100644 --- a/docs/embedding.md +++ b/docs/embedding.md @@ -41,6 +41,48 @@ scale: | Qt6 | `devicePixelRatioF()` | re-read every `paintGL()` | | GTK4 | `mapping_.factor` = device size / logical allocation | the two sizes `::resize` reports, not `gtk_widget_get_scale_factor()` | +**Keys, key repeat, timestamps and touch parity.** The controller's keys and gestures +arrive through the adapters translated from each toolkit's own events, and four +conventions hold across all four hosts: + +* **The key set is the same everywhere**: `+`/`-` zoom (with the `=`/`_` spellings a + shifted layout produces), `f`/`F` re-fits, the four arrows pan (10% of the span per + press), and Escape cancels the active drag. Each host translates its native key codes + to `input::kKey*` before calling the controller. +* **A held key repeats in every adapter.** GLFW, Qt and GTK deliver the OS auto-repeat + as extra press events and forward each one. ImGui edge-detects a press exactly once, + so its widget asks `IsKeyPressed(..., repeat = true)` for every key it maps and + ImGui's own repeat timer supplies what the OS would have. +* **`PointerEvent::time_s` is the host's monotonic clock, in seconds.** Only + differences are read: the double-click (two presses of the same button within 0.35 s + and 4 px re-fits every axis) compares two stamps, never an epoch. A host that leaves + the field 0 never produces a double-click, by contract. + +| host | key repeat | `time_s` source | touch / pinch | +|---|---|---|---| +| GLFW | the OS auto-repeat, forwarded as repeated presses | `glfwGetTime()` | none — GLFW has no touch input | +| ImGui | `IsKeyPressed(..., repeat = true)` on every mapped key | `ImGui::GetTime()` | none — no touch input to forward | +| Qt6 | Qt repeats a held key as press events | `QInputEvent::timestamp()` / 1000 | `Qt::PinchGesture` → `GestureEvent`, folded by `PinchTracker` (`qt_pointer.hpp`) | +| GTK4 | GTK repeats a held key as press events | the GDK event's time / 1000 | `GtkGestureZoom` → `GestureEvent`, folded by `ZoomAnchor` (`gtk_input.hpp`) | + +The two pinch folds are the same fold in two namespaces, and both live in +toolkit-free headers precisely so the headless tests can drive them: a toolkit +reports a pinch as **cumulative** values (total scale since the gesture began, the +focal point now), while `GestureEvent` wants the increments (this frame's zoom factor +about the focal point, 1.0 = no change, and the focal point's movement as `dx`/`dy`). +The folds also keep their focal anchor in *logical* pixels, like every other adapter +anchor, so a window dragged onto a display with another scale factor mid-gesture +cannot read the ratio change as a phantom pan. + +**DPR / display changes.** The scale each host states to the renderer is re-read +whenever it can change, not latched: GLFW re-derives it in `refresh_sizes()` (every +frame, and from the resize callback), ImGui every frame from +`io.DisplayFramebufferScale`, Qt every `paintGL()` **and** on +`QEvent::DevicePixelRatioChange` — which also refreshes the controller's viewport +before any event queued behind the change maps a gesture through the old geometry — +and GTK in `::resize` through `DeviceMapping`. An input coordinate is always scaled by +the ratio in force at that event, never by one cached at startup. + **Headless verification.** Every demo supports `PLOT_DEMO_EXIT_AFTER_MS=` (stop the loop after that long) and `PLOT_DEMO_SCREENSHOT=` (write the final frame as a PPM). This is how the project runs its own GUI demos in CI under Xvfb, with no display access @@ -93,16 +135,39 @@ What `run()` does, in order: Pointer translation: `on_cursor_pos()` multiplies the GLFW window coordinates by `fb_scale_x_`/`fb_scale_y_` (framebuffer ÷ window) and synthesises `dx`/`dy` itself (GLFW -reports no delta); `down` comes from `glfwGetMouseButton`, and the modifier mask is read -from the key state, because GLFW's pointer and scroll callbacks carry no mask. Wheel -notches are forwarded as `WheelEvent`, and keys as `KeyEvent`. +reports no delta); `down` comes from `glfwGetMouseButton`, the modifier mask is read +from the key state, because GLFW's pointer and scroll callbacks carry no mask, and +`time_s` is `glfwGetTime()`. Wheel notches are forwarded as `WheelEvent` unchanged +(zero deltas included — the controller owns that case), and keys as `KeyEvent`: the +arrows pan, Escape cancels the active drag, and neither closes the window (the window +manager's close button and Alt+F4 are the way to quit). Figure mode: `host.set_figure(&figure)` makes the loop lay the figure over the framebuffer, create **one `Controller` per row**, route each pointer/wheel/key event to the row it lands -in (`controller_at(x, y)`), and draw with `Renderer::draw(Figure&)`. The host also spreads +in (`controller_at(x, y)`), and draw it with `Renderer::draw(Figure&)`. The host also spreads one hover over every row (`sync_figure_crosshair`), since only the host knows which row the pointer is in. +Routing has two rules worth knowing from the outside: + +* **One gesture at a time (button-aware capture).** A press captures the row it landed + in *and the button that pressed*, and every motion and the release of *that button* go + to the captured row until it comes up — so a drag that crosses into a neighbouring row + still ends where it began. A second button pressed meanwhile (a chord) neither steals + the capture to another row nor ends it; only the owning button's release ends it. What + the second button's events do inside the controller is the controller's business; the + host guarantees they all arrive at the row the first press claimed. +* **Row rects are cached, refreshed once per frame.** The events never call + `Figure::layout()` — a non-const `layout()` recomputes, and a burst of pointer moves + would re-lay every row out per move. The run loop calls `refresh_row_rects()` once per + frame (right after the figure's viewport is set), `set_figure()` fills the cache + immediately so a scripted run routes against the figure it was just handed, and every + handler reads the cache. The consequence across a resize: GLFW delivers the resize and + any events queued behind it in one batch, so events before the resize see the rows the + previous frame drew — at most one frame stale — and the loop's next refresh brings the + cache onto the new geometry. A driver that injects events without running the loop + calls `refresh_row_rects()` itself; it is the scripted equivalent of the frame. + `run()` leaves the window open on success so a producer thread can be joined before the GL resources go away; `close()` (or the destructor) releases the renderer, context and window on the calling thread. A run with `PLOT_DEMO_EXIT_AFTER_MS` set creates the window hidden. @@ -131,7 +196,11 @@ Because ImGui reports button *levels* and the controller wants *edges*, the widg remembers the previous frame's buttons and synthesises `pressed`/`released`; a button pressed over the child keeps being delivered until every button is up, so a drag that leaves the region still ends. ImGui's `(-FLT_MAX, -FLT_MAX)` "no pointer" sentinel is -treated as no position update (no movement, buttons still flow). +treated as no position update (no movement, buttons still flow). Every pointer event is +stamped with `ImGui::GetTime()`, which is what makes the controller's double-click +(re-fit) exist on this host; and every key the widget maps is asked with +`IsKeyPressed(..., repeat = true)`, because ImGui does not auto-repeat on its own — +the shared "a held key repeats" policy is opt-in here (see the conventions above). Coordinate assumption: the points→pixels mapping is the whole framebuffer's only when the host's GL viewport is the whole framebuffer at origin `(0,0)` and the bound target is the @@ -168,12 +237,22 @@ viewport on the controller, runs the frame callback, sets `ui_scale` to Input translation: Qt reports press/release edges directly, so `mousePressEvent` / `mouseMoveEvent` / `mouseReleaseEvent` / `mouseDoubleClickEvent` / `wheelEvent` / -`keyPressEvent` become `PointerEvent`/`WheelEvent`/`KeyEvent`. Every position is multiplied -by `devicePixelRatioF()`; the *anchor* a move's delta is measured from is kept in logical +`keyPressEvent` become `PointerEvent`/`WheelEvent`/`KeyEvent`, each stamped with +`QInputEvent::timestamp()` in seconds. Every position is multiplied by +`devicePixelRatioF()`; the *anchor* a move's delta is measured from is kept in logical pixels, so a drag crossing onto a display with another scale factor pans by the distance -moved rather than a cross-scale jump (`PointerAnchor`, `qt_pointer.hpp`). Qt's -`angleDelta()` is in eighths of a degree (120 per notch) and is converted; a wheel with no -vertical component is refused before it reaches the controller. +moved rather than a cross-scale jump (`PointerAnchor`, `qt_pointer.hpp`). The wheel is +converted from eighths of a degree, 120 to a notch; when a device reports +`pixelDelta()` instead (and the angle deltas are both zero), 40 scroll pixels are taken +to be one notch — a calibration, not a conversion, the same role the GTK adapter's +`kSurfacePixelsPerNotch` plays. A wheel event with neither a vertical nor a horizontal +component is refused before it reaches the controller; anything with one is forwarded, +and the controller pans x by the horizontal component. The widget also grabs +`Qt::PinchGesture`: each pinch update is folded by `PinchTracker` (`qt_pointer.hpp`) +into an incremental `GestureEvent` — this frame's zoom about the focal point, the focal +point's movement as the pan — and a `QEvent::DevicePixelRatioChange` refreshes the +controller's viewport (and, one frame later at the latest, the `ui_scale`) before any +event queued behind it. Include-order note: this header includes `plot/gl/api.hpp` before ``, because Qt reaches `` whose macros would rewrite `plot::gl`'s enumerators. A @@ -202,10 +281,16 @@ and the device size GTK reports, and refreshes the viewport; `::render` sets the the controller, runs the frame callback, sets `ui_scale` to the mapping's factor, and draws. Input translation uses GTK's gesture recognisers: `GtkGestureDrag` supplies motion, -`GtkGestureClick` supplies the press/release edges, `GtkEventControllerMotion` supplies +`GtkGestureClick` supplies the press/release edges (stamped with the GDK event's time, +in seconds, like every pointer event this adapter forwards), `GtkGestureZoom` supplies +the two-finger pinch — each `scale-changed` folded by `ZoomAnchor` (`gtk_input.hpp`) +into an incremental `GestureEvent`: this frame's zoom about the fingers' bounding-box +centre, the centre's movement as the pan — `GtkEventControllerMotion` supplies hover (forwarded with `down == false`, so a drag is not applied twice), and -`GtkEventControllerScroll` (vertical) and `GtkEventControllerKey` supply the wheel and the -`+`/`-`/`f` keys. GTK emits `drag-begin` before the click's `pressed`, so the adapter opens +`GtkEventControllerScroll` (vertical) and `GtkEventControllerKey` supply the wheel and +the keys (`+`/`-`/`f`, the arrows, Escape — the full shared key set, mapped in +`gtk_input.hpp`'s `key_from()` where the headless tests drive it). GTK emits +`drag-begin` before the click's `pressed`, so the adapter opens its gesture from the first press and closes it from the first release. The area queues a render after every input event that can change the picture (not on hover). diff --git a/docs/getting-started.md b/docs/getting-started.md index 7a1189c..3d0ffc3 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -51,6 +51,29 @@ directory as its public interface and nothing else. The exported adapters are na `plot::imgui` is a build-tree target only, because ImGui is supplied by `FetchContent` and there is no ImGui package for an installed config file to point at. +The version you ask for follows the package's own compatibility rule, **SameMinorVersion**: +`find_package(plot 0.1 REQUIRED)` accepts 0.1.x and refuses 0.2+, because at 0.x the minor +component is what moves when the surface breaks. What the surface *is* — the version this +header set was generated from — is in ``, installed with the other public +headers: `plot::version::major`, `::minor`, `::patch` and `::string` (plus the +`PLOT_VERSION_*` macros) for a compile-time answer, no `pkg-config --modversion` round-trip +needed: + +```cpp +#include + +static_assert(plot::version::major == 0, "plot 0.x only"); +``` + +A build that is not CMake can use the installed pkg-config file instead: + +```sh +pkg-config --cflags --libs plot # -I -L -lplot_core +``` + +Both grants travel with the install: `LICENSE` (MIT) and the embedded font's +`licences/OFL-1.1.txt` (SIL OFL 1.1) are under `share/licenses/plot/` in the prefix. + Every public component is reachable through the umbrella header: ```cpp @@ -68,10 +91,11 @@ and draws one trace. It uses the GLFW toolkit for the window and `plot::core` di it does not use the shipped `plot::glfw::Host` adapter, so it also shows the pieces the adapter hides (window, context, loader, renderer, controller, frame loop). -It was compiled and linked as part of writing this page: +It was compiled and linked as part of writing this page (`-I build/debug/include` is where +CMake generates `plot/version.hpp`, which the umbrella pulls in): ```sh -g++ -std=c++20 -I include main.cpp build/debug/libplot_core.a \ +g++ -std=c++20 -I include -I build/debug/include main.cpp build/debug/libplot_core.a \ $(pkg-config --libs glfw3) -ldl -lpthread -o plot_quickstart ``` diff --git a/docs/superpowers/specs/2026-09-29-plot-hardening-and-features-design.md b/docs/superpowers/specs/2026-09-29-plot-hardening-and-features-design.md new file mode 100644 index 0000000..081ef95 --- /dev/null +++ b/docs/superpowers/specs/2026-09-29-plot-hardening-and-features-design.md @@ -0,0 +1,305 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +# Hardening and features v0.2 — shared contracts + +Design record for the bug-fix + robustness + feature wave. **This file is the +interface bible for the five parallel streams.** File ownership is exclusive: +a stream edits only the files listed under it. Everything not listed is the +integrator's. + +## Bug list (B1–B7) and robustness list (R1–R9) being fixed + +- **B1** f32 staging of large-magnitude x destroys geometry (epoch axes). +- **B2** tick major/minor classification ULP-fragile (`ticks.cpp:176`). +- **B3** LOD gather emits buckets straddling `[begin,end)` (`series.cpp:168`). +- **B4** `XFollow::Live` on log x holds linear span (`plot.cpp:494`, `figure.cpp`). +- **B5** zero-notch wheel disarms auto_scale; `factor==inf` unguarded + (`controller.cpp:158`, `glfw` forwards dy=0). +- **B6** single text flush paints legend text over the crosshair readout panel. +- **B7** NaN policy incoherent (min/max drop unless seeding; sum propagates). +- **R1** `std::clamp` UB on negative viewport (`plot.cpp:886`). +- **R2** `set_x_range`/`set_y_range` store non-finite verbatim (`plot.cpp:377-384`). +- **R3** `kBucket << level` UB for level >= 58 via public `gather`. +- **R4** ring capacity rounds up but docs/reserve assume raw capacity. +- **R5** GLFW figure capture cleared by any button release. +- **R6** renderer residue: blend func, UNPACK_ALIGNMENT, no GL shutdown, + epoch() wrap, annotation caption overflow. +- **R7** GLFW host re-lays out the figure on every event. +- **R8** measure accepts horizontal rulers, silently measures [0,0]. +- **R9** minor-tick cap truncates the far end of the axis, majors included. + +## Cross-cutting decisions + +- **NaN policy (library-wide, documented):** a non-finite sample is *excluded* + from every aggregate (min, max, sum, sumsq) and from cached bounds. A window + with no finite samples aggregates to a zero-span Range / zero sums. Applies + in `MinMaxPyramid::append`, `Series::reduce/sum/sumsq` edge folds, + `ArraySeries::commit`, `StreamSeries::acquire` bounds. `docs/data-and-performance.md` + gets a "Non-finite samples" paragraph. +- **Gather x contract (breaking, B1):** `Series::gather` now emits x + **relative to `x0()`** (exactly `i*dx` for uniform series, `x_at(i)-x0()` for + XY), never absolute x. y is unchanged. The renderer reconstructs absolute + data x in f64 (`x0() + rel`) before transforming, so no precision is lost at + the f32 narrowing: the staged f32 is `to_space(x_abs) - to_space(origin)`, + which is O(visible pixels), not O(epoch). +- **Live follow on log axes (B4):** `Axis::offset` and the live-window span + live in **axis space** (`log10` when `log`), matching how the controller's + gestures already work (`to_space`/`from_space`). `place_live_x_window` and + the figure link-group copies hold the log-space span constant. +- **Key-repeat policy (library-wide):** a held key repeats in every adapter + (GLFW/Qt/GTK already do; ImGui switches to `repeat=true`). +- **Modifiers for box zoom:** Shift+Left = both axes (unchanged, default); + Ctrl+Left = x-only; Alt+Left = y-only. Other modifier combos = both. + +## Shared-header changes (made by the integrator before the fan-out) + +- `include/plot/input/events.hpp` + - `PointerEvent` gains `f64 time_s = 0.0;` (host-supplied monotonic seconds; + powers double-click). Hosts that leave 0 simply never double-click. + - New `GestureEvent { f32 x, y; f32 scale = 1.0f; f32 dx = 0.0f, dy = 0.0f; }` + — pinch: zoom about (x, y) by `scale`, pan by (dx, dy). `Controller` + gains `void on_gesture(const GestureEvent&)`. + - New key constants: `kKeyEscape = 27`, `kKeyLeft = 256`, `kKeyRight = 257`, + `kKeyUp = 258`, `kKeyDown = 259` (host-translated, like the ASCII ones). +- `include/plot/data/series.hpp` + - `enum class GatherMode { Envelope, Nearest, Mean };` — downsample policy. + `Envelope` = today's min/max pairs. `Nearest` = one raw sample per bucket + (via `snap`), x at the sample. `Mean` = one point per bucket, y = mean + (bucket `sum` / count), x at bucket centre. + - `virtual void gather(u32 level, u64 begin, u64 end, std::vector& out_xy, + GatherMode mode = GatherMode::Envelope) const` — signature change; every + override updates; renderer passes the mode explicitly. + - New virtuals (uniform default, XY override): + `virtual f64 x_at(u64 i) const` (`x0() + i*dx()`), + `virtual bool neighbours(f64 x, u64& i0, u64& i1) const` (false when x is + outside `[x_at(0), x_at(n-1)]`; else the bracketing indices), + `virtual u64 snap_index(f64 x) const` (nearest index, clamped). + - `MinMaxPyramid` gains `f64 bucket_sum(u32 l, u64 i) const` and + `f64 bucket_sumsq(u32 l, u64 i) const` (Mean/RMS need them). +- `include/plot/plot/trace.hpp` — `Trace`/`TraceDesc` gain (trailing, defaults + keep aggregate init source-compatible): + `GatherMode mode = GatherMode::Envelope;` + `f32 dash_on = 0.0f, dash_off = 0.0f;` (logical px, 0 = solid), + `enum class Marker { None, Cross, Circle, Square }`, `Marker marker = Marker::None;` + `f32 marker_size_px = 3.0f; u32 marker_every = 0;` (0 = auto: stride + `ceil(samples_per_pixel)`, so markers only appear where samples are sparse). + `width == 0` draws markers only (scatter) — documented. +- `include/plot/render/style.hpp` — gains `f32 line_aa_px = 1.0f;` (logical, + the AA fringe half-width, clamped >= 0.25f at use). No text-colour fields: + text colour becomes a per-call parameter (see C). +- `include/plot/plot/plot.hpp` — `set_viewport` normalises negative w/h to 0 (R1). +- ~~`include/plot/plot/crosshair.hpp` — `CrosshairRow` gains `Color color`~~ + DROPPED: `CrosshairRow` already names its trace; the renderer resolves the + colour as `plot.trace(row.trace).color` at draw time. No field added. + +## Stream A — data layer (exclusive) + +Files: `include/plot/data/*`, `src/data/*`, `tests/test_series.cpp`, +`tests/test_ring_buffer.cpp`, `tests/test_minmax_pyramid.cpp`, +`tests/test_xy_series.cpp` (new), plus the `Series::gather` override +forwarders and `docs/data-and-performance.md` NaN/policy paragraphs. + +1. **B3**: bucket-edge selection uses `first = max(ceil_div(origin+b, span), lbase)`, + `last = min((origin+e)/span, lend)`; lead/trail raw spans cover the rest. + Regression: gather at unaligned `begin`/`end` mid-window asserts no emitted + x outside `[begin, end)` and envelope values equal exact reduce over the range. +2. **B7**: NaN policy per above; regression: NaN at first/middle/last position + — bounds and reduce stay finite, sum skips NaN. +3. **R3**: `level_span` caps at level 57 (span 2^63); `gather(level > 57)` + clamps; no UB/div-zero for any u32 level. +4. **R4**: docs say "at most `capacity()` rounded up to a power of two"; + `StreamSeries` reserves the *rounded* `ring_.capacity()`. +5. **GatherMode** implementation in `gather_impl` (Mean/Nearest via the new + pyramid accessors); `XY`/`Array`/`Stream` forwarders updated; relative-x + contract implemented everywhere (x emitted as `x_at(i) - x0()`). +6. **XY series** (full feature): `include/plot/data/xy_series.hpp`, + `src/data/xy_series.cpp`, target `plot_core`. Sorted-ascending x + (documented precondition, `assert` in debug); binary-search + `neighbours`/`snap_index`; pyramid over sample index; `commit()` rebuilds. +7. **Snapshot**: `ArraySeries StreamSeries::freeze() const` — consumer-thread + only; copies the retained window (`copy_raw`) into a fresh ArraySeries with + this window's `x0()`/`dx()`. Freezes against the *last acquire* (documented). +8. **Incremental append**: `void ArraySeries::push(f32)` — appends, folds into + the pyramid (`append`), extends bounds incrementally (NaN-aware), extends + `x_bounds_`. `commit()` stays the wholesale path. +Acceptance: new tests for every item above; `plot_tests` builds and passes. + +## Stream B — plot + input (exclusive) + +Files: `src/plot/ticks.cpp`, `src/plot/plot.cpp`, `src/plot/figure.cpp`, +`src/input/controller.cpp`, `include/plot/input/controller.hpp`, +`include/plot/plot/axis.hpp` (offset doc), `tests/test_ticks.cpp`, +`tests/test_controller.cpp`, `tests/test_plot.cpp`, `tests/test_figure.cpp`, +`tests/test_measure.cpp`, and the plot-layer consumers of the new `Series` +virtuals (`crosshair_value`, `crosshair_span`, `covered_samples`, +`measurement` — route through `x_at`/`neighbours`/`snap_index` so an XY series +crosshairs and measures correctly). + +1. **B2**: major = integer-index test (`k % ratio == 0` on the minor index, + `k` derived from `(v - first_major)/minor_step` rounded); no float tolerance. + Regression: epoch range 1.75e9 ± 300 s at 800 px produces labelled majors. +2. **R9**: majors are never truncated; only minors count against `kMaxTicks`. +3. **B4**: live window placement in axis space (log-aware span/offset) in + `Plot::place_live_x_window`, `Plot::set_x_range` live branch, and the two + figure link-group copies. Regression: streaming log axis holds visible + decades constant across samples; scrolled-back window keeps span. +4. **B5**: `on_wheel` — `dy == 0 && dx == 0` returns; `dy == 0, dx != 0` pans x + only (`dx` notches, 5% of the x span per notch, log-aware via `to_space`); + zoom path rejects `factor == 1` and non-finite. Same guard in the + middle-drag `axis_zoom`. +5. **R1**: `layout()` clamp guards `hi >= lo` before clamping (no UB even for a + hostile viewport; `set_viewport` normalises, already done by integrator). +6. **R2**: range setters refuse non-finite endpoints (Off branch + y), keep the + previous range and `auto_scale`. +7. **R8**: `add_measure`/`measurement` refuse horizontal-ruler (and + vertical-ruler dead-field) cursors — `valid` stays false, `add_measure` + returns an id that reports invalid. +8. **Interaction features**: + - Keyboard pan: Left/Right/Up/Down pan 10% of the span per press/repeat + (x-only for Left/Right, y-only for Up/Down; log-aware). + - Escape (`kKeyEscape`): cancels the active drag — restores the ranges the + axes had at `begin_drag` (captured then), drops box-zoom without applying, + clears grab state. + - Box-zoom masks per the modifier table above; applied per-axis at release. + - Double-click reset: two presses of the same button within 0.35 s and 4 px + (uses `PointerEvent::time_s`) re-fits every axis (`auto_scale = true` + + fit). Second press does not begin a drag. +Acceptance: controller tests for each gesture incl. cancel-restore; tick +regressions; XY crosshair/measure unit tests via an `XY` fixture. + +## Stream C — render (exclusive) + +Files: `src/render/*`, `include/plot/render/*` (renderer/primitives/text/buffer +interfaces), `src/gl/handle.cpp`, `include/plot/gl/handle.hpp`, +`tests/test_render_smoke.cpp`, `tests/test_primitives.cpp`. + +1. **B1**: implement the relative-x staging contract (see Cross-cutting): + trace staging reconstructs `x_abs = series.x0() + rel` in f64, stages + `f32(to_space(x_abs) - to_space(origin))`; every data-space line draw + (grid, cursors, regions, measures, crosshair, annotations) picks a natural + origin (its own reference data x/y) and `mapping()` folds + `origin` into the offset uniform in f64 before the single narrowing. + Regression: EGL smoke renders an epoch-window series and a zero-origin + twin and asserts both rasterise to the same pixel column set (tolerance). +2. **B6**: two text batches — `text_under_` (legend names, cursor labels, + measure labels, annotation captions) flushed *immediately before* + `draw_crosshair_readout`; `text_over_` (readout text, tick labels, titles) + flushed at `end_frame`. The readout panel occludes under-batch text. + Regression: legend + overlapping readout frame — legend name pixels are not + lit inside the readout panel rect. +3. **R6**: `begin_frame` saves `GL_BLEND_SRC/_DST` via `glGetIntegerv` and + `end_frame` restores function + enable bit; `Texture::upload_rgba` + saves/restores `GL_UNPACK_ALIGNMENT`; `Renderer::release_gl()` (public) + destroys all GL handles explicitly, called first by the destructor (safe + when the context is current) and documented for host teardown order; + `epoch()` widened to u64 end-to-end (buffer generation + slot epoch + + VAO cached epoch); annotation captions clamp their box inside `axes` + (skip the caption when it cannot fit). +4. **Dash**: trace dash reaches `u_dash` per trace flush (device px, logical + converted); legend swatch samples the dash. `dash_off == 0` = solid. +5. **Markers**: staged as line segments through the existing batch + (Cross 2 segs, Circle 8-gon, Square 4 segs), colour = trace colour, size = + `device(marker_size_px)`, stride `marker_every` or auto `ceil(spp)`; + `width == 0` draws markers only. Render-smoke regression: a marker'd trace + lights vertices a plain line does not, and a `marker_every = 3` trace + stages 1/3 the marker count. +6. **AA knob**: `u_aa` uniform = `max(0.25, device(style.line_aa_px))`; shaders + drop the hardcoded fringe. Default output unchanged (1.0 logical). +7. **Text colour**: per-vertex `a_colour` (vec4); `stage_text(..., Color)`; + `flush_text` stops setting `u_color`; crosshair readout value rows draw in + their row's trace colour (`CrosshairRow::color`), labels in `axis_text`. + Smoke regression: a coloured readout row differs from the axis-text pass. +8. **GatherMode**: renderer passes `trace.mode` to `gather`. +Acceptance: smoke tests above; full suite green; no uniform/attribute drift +(cross-check against GLSL in the same PR). + +## Stream D — adapters (exclusive) + +Files: `adapters/**` only, `tests/test_glfw_host.cpp`, `tests/test_qt_pointer.cpp`, +`tests/test_gtk_input.cpp`, `tests/test_imgui_widget.cpp`, +`docs/embedding.md` (shared conventions: key repeat, touch parity, DPR note). + +1. **R5**: GLFW figure capture records the button; only the owning button's + release ends it; a second button does not steal or clear the capture + (documented single-gesture policy). Test: press left row0, press right row1, + release right — left drag still routes to row0. +2. **R7**: `Host` caches row rects; invalidated on resize + `set_figure`, + refreshed once per frame; event handlers read the cache (one-frame-stale + rows during resize — documented). +3. Key repeat: ImGui `IsKeyPressed(..., repeat=true)`; policy comment. +4. **Timestamps**: all four adapters fill `PointerEvent::time_s` + (glfwGetTime / QEvent timestamps /1000 / gdk time /1000 / ImGui::GetTime). +5. **New keys**: GLFW maps arrows + Escape to the new constants (with + `GLFW_KEY_LEFT` etc.); Qt maps `Key_Left...`; GTK maps `GDK_KEY_Left...`; + ImGui `ImGuiKey_LeftArrow...`, `ImGuiKey_Escape`. +6. **Qt**: `wheelEvent` uses `pixelDelta()/40` notches when `angleDelta` is 0 + (pixelDelta path); `DevicePixelRatioChange` refreshes the controller + viewport + scales; pinch via `grabGesture(Qt::PinchGesture)` -> + `GestureEvent`. +7. **GTK4**: `GtkGestureZoom` attached in the plot area factory -> + `GestureEvent` (scale + focal delta); scroll `ScrollUnit::Surface` path + already exists — keep. +8. **GLFW**: `on_scroll` forwards unchanged (controller owns the dy=0 case). +Acceptance: adapter-gated tests updated; toolkit-free seams (qt_pointer, +gtk_input) carry the new logic so headless tests cover it. + +## Stream E — build, packaging, docs, CI (exclusive) + +Files: root `CMakeLists.txt`, `cmake/*`, `CMakePresets.json`, +`.github/workflows/ci.yml` (new), `plot.pc.in` (new), +`docs/building-and-testing.md`, `docs/getting-started.md`, `README.md` +(feature list + versioning), `tests/test_series.cpp` (delete the wall-clock +steady-state case only), `readme_main.cpp` (new, referenced by README). + +1. `GNUInstallDirs` for every install path; `VERSION`/`SOVERSION` on + `plot_core` when shared; `SameMinorVersion` compatibility (0.x reality); + generated `include/plot/version.hpp` (`configure_file`, installed, added to + the umbrella + exemption handling as needed); `plot.pc` pkg-config file; + install `LICENSE` + `licences/OFL-1.1.txt` under + `${CMAKE_INSTALL_DATADIR}/licenses/plot/`; `install(DIRECTORY)` gets + `FILES_MATCHING PATTERN "*.hpp"`. +2. `PLOT_BUILD_TTF_FONT` moves to `cmake/plot_options.cmake` (docs match); + coverage-flag detection switches to a `PLOT_COVERAGE` option check on the + config profile, not a `CMAKE_CXX_FLAGS` string match. +3. CI: `.github/workflows/ci.yml` — ubuntu-latest, apt deps (libglfw3-dev, + qt6-base-dev, libgtk-4-dev, libegl1, libglvnd-dev, xvfb, lcov, + python3-fonttools), jobs: debug (configure/build/xvfb ctest), coverage + (tools/coverage.sh, uploads the table), asan (linux-asan preset). `continue-on-error` + on the ImGui fetch is not needed (network is available on GH runners). +4. Docs: correct the case-count math (recount from the tree at integration + time), remove the stale hard-coded coverage figures from README and + building-and-testing (coverage.md stays the single source, regenerated), + document the new keys/gestures/touch parity in `interaction.md` + + `embedding.md` (note: B/C own feature *code*; E only documents what the + contract above specifies). +Acceptance: `cmake --install` smoke into a temp prefix finds `plot` via +`find_package` in a throwaway consumer; umbrella tests stay green; suite +counts match docs. + +## Integration (integrator only) + +Merge all streams; reconcile `include/plot/plot.hpp` umbrella; full +configure+build+ctest; regenerate coverage via `tools/coverage.sh`; update the +per-file coverage table and README numbers from the fresh run; run the GLFW +demo headless under Xvfb as the final smoke; update this design record's +status. + +## Status: COMPLETE (2026-09-30) + +All five streams landed and were integrated on `main` (uncommitted working +tree). `Controller::on_gesture` was implemented by the integrator (Stream B was +cut off before it). Incident: the root filesystem hit 100% mid-wave and +truncated `src/render/renderer.cpp` to 0 bytes; Stream C restored it from its +transcript and re-verified. Final state: + +- `linux-debug` suite: **350 cases, 0 failed** (was 283); 4/4 ctest entries pass. +- Coverage: **93.9% aggregate (3730/3971) over 24 files**, every file >= 80% + (floor 80/90) — regenerated in `docs/coverage.md` by `tools/coverage.sh`. +- Headless smoke: `plot_glfw_demo` + `plot_multiplot_demo` under `xvfb-run`, + exit 0, screenshots contain rendered ink (65.4% / 20.0% non-background at + 1280x720); the multiplot run reports **20,901 staged GPU segments for + ~4.5M visible samples, GL error 0** (README paragraph updated). +- Known follow-ups: real touch hardware has never exercised the Qt/GTK pinch + paths; macOS/Windows remain untested; CI YAML is written but has run only + locally in structure (no GitHub remote execution during this wave). diff --git a/include/plot/data/array_series.hpp b/include/plot/data/array_series.hpp index 72b3ef1..811bbdd 100644 --- a/include/plot/data/array_series.hpp +++ b/include/plot/data/array_series.hpp @@ -22,6 +22,10 @@ class ArraySeries final : public Series { void reset(std::vector y, f64 x0, f64 dx); /// Rebuilds the pyramid and cached bounds from y_ as it stands now. void commit(); + /// Appends one sample incrementally: the pyramid folds it (`append`, amortised + /// O(1)) and the cached bounds extend in O(1), NaN-aware, instead of commit() + /// rebuilding the whole store. The wholesale path stays `commit()`. + void push(f32 y); u64 size() const override; f64 x0() const override; @@ -29,7 +33,8 @@ class ArraySeries final : public Series { Range x_bounds() const override; Range y_bounds() const override; const MinMaxPyramid& pyramid() const override; - void gather(u32 level, u64 begin, u64 end, std::vector& out_xy) const override; + void gather(u32 level, u64 begin, u64 end, std::vector& out_xy, + GatherMode mode = GatherMode::Envelope) const override; protected: void copy_raw(u64 begin, u64 end, f32* out) const override; @@ -41,6 +46,11 @@ class ArraySeries final : public Series { MinMaxPyramid pyramid_; Range x_bounds_{}; Range y_bounds_{}; + /// False until a finite sample has been seen: `y_bounds_` is then a + /// placeholder (zero-span), not an aggregate, and the next finite sample + /// seeds it. This is what makes push()'s incremental extension and commit()'s + /// rebuild agree on a window that holds non-finite samples. + bool y_has_finite_ = false; }; } // namespace plot::data diff --git a/include/plot/data/minmax_pyramid.hpp b/include/plot/data/minmax_pyramid.hpp index f227259..a25c6c9 100644 --- a/include/plot/data/minmax_pyramid.hpp +++ b/include/plot/data/minmax_pyramid.hpp @@ -19,6 +19,12 @@ struct MinMax { /// series and the level maths is a shift. inline constexpr u32 kBucket = 64; +/// Deepest pyramid level. `kBucket << 57` is `2^63`, the widest bucket span a +/// u64 sample count can name; past it the shift would overflow (UB), so every +/// level computation clamps here and a `gather()` asked for a deeper level +/// simply gets this one (whose buckets are already wider than any window). +inline constexpr u32 kMaxPyramidLevel = 57; + /// Incremental min/max LOD pyramid, indexed in absolute bucket space. /// /// Level `l` bucket `k` covers the absolute samples @@ -37,14 +43,14 @@ inline constexpr u32 kBucket = 64; /// `drop_before(u)` slides the window's leading edge to absolute sample `u`: each /// level discards the buckets whose span starts before `u`, so the memory is /// bounded by the retained window and not by the number of samples ever appended. -/// A bucket entry is 24 bytes -- `MinMax` plus the exact `sum`/`sumsq` the exact -/// range queries fold over -- so level 0 costs 0.375n bytes and the geometric tail -/// brings the whole pyramid to about 0.75n bytes, ~19% of the 4n bytes of raw -/// samples. (A min/max-only index would be 0.25n, ~6%: the two doubles are the -/// price of answering mean/RMS/integral from whole buckets instead of raw -/// samples.) Every stored entry is the complete aggregate of its own span and -/// every stored span lies inside the retained window, so no aggregate is ever -/// recomputed or invalidated. +/// A bucket entry is 32 bytes -- `MinMax` plus the exact `sum`/`sumsq` the exact +/// range queries fold over and the finite-sample `count` the mean folds by -- so +/// level 0 costs 0.5n bytes and the geometric tail brings the whole pyramid to +/// about 1n bytes, ~25% of the 4n bytes of raw samples. (A min/max-only index +/// would be 0.25n, ~6%: the extra words are the price of answering mean/RMS/ +/// integral from whole buckets instead of raw samples.) Every stored entry is +/// the complete aggregate of its own span and every stored span lies inside the +/// retained window, so no aggregate is ever recomputed or invalidated. /// /// Queries are in *window* coordinates: index 0 is the oldest retained sample, /// `size()` is the retained sample count, and `window_base()` is the absolute @@ -64,6 +70,11 @@ class MinMaxPyramid { /// appends never grows the level vector. The bucket deques themselves grow in /// chunks: a sliding window cannot know their final length up front. void reserve(u64 n); + /// Folds one sample into the pyramid. A non-finite sample advances the + /// absolute sample count (bucket boundaries are positional) but is excluded + /// from the aggregates: min/max/sum/sumsq/count never see it, and a bucket + /// whose samples were all non-finite is stored as an empty aggregate + /// (count 0, zero sums) that every reader treats as absent. void append(f32 v); /// Slides the retained window's leading edge to absolute sample index /// `absolute_sample`, dropping every aggregate that covered a sample before it. @@ -100,6 +111,15 @@ class MinMaxPyramid { /// Aggregate of the absolute level-`l` bucket `i`. Precondition: /// level_base(l) <= i < level_base(l) + level_size(l). MinMax bucket(u32 l, u64 i) const; + /// The bucket's exact `sum`/`sumsq` folds -- what `GatherMode::Mean` needs to + /// average a bucket and what an RMS readout folds without touching raw + /// samples. Same precondition as `bucket()`. + f64 bucket_sum(u32 l, u64 i) const; + f64 bucket_sumsq(u32 l, u64 i) const; + /// How many *finite* samples the bucket aggregated: the divisor that makes + /// `bucket_sum()` a mean under the non-finite-exclusion policy, and 0 exactly + /// when the bucket holds no usable sample. Same precondition as `bucket()`. + u32 bucket_count(u32 l, u64 i) const; /// Min/max over the whole level-0 buckets fully contained in the window range /// [begin, end). Partial edge buckets are excluded, never approximated; an @@ -119,13 +139,18 @@ class MinMaxPyramid { u64 snap(f64 x, f64 x0, f64 dx) const; private: - /// One bucket: its extremes plus its own exact sum and sum of squares. The two - /// doubles take a bucket from 8 bytes to 24, which is what lets mean/RMS/ - /// integral come from whole buckets instead of raw samples. + /// One bucket: its extremes plus its own exact sum and sum of squares, and + /// how many finite samples produced them. The extra words take a bucket from + /// 8 bytes to 32, which is what lets mean/RMS/integral come from whole + /// buckets instead of raw samples -- and the count is what keeps a bucket of + /// nothing (all its samples non-finite) empty rather than zero-valued: an + /// entry with count 0 has zero sums and a meaningless mm, and every reader + /// skips it. struct Entry { MinMax mm; f64 sum = 0.0; f64 sumsq = 0.0; + u32 count = 0; ///< finite samples folded in; 0 = an empty bucket }; /// One level's complete buckets, in absolute bucket-index order. struct Level { diff --git a/include/plot/data/ring_buffer.hpp b/include/plot/data/ring_buffer.hpp index af0fa06..0af584f 100644 --- a/include/plot/data/ring_buffer.hpp +++ b/include/plot/data/ring_buffer.hpp @@ -12,8 +12,13 @@ namespace plot::data { /// Single-producer / single-consumer ring of at most `capacity` elements. /// -/// The capacity is rounded up to a power of two so slot addressing is a mask -/// instead of a modulo. The producer writes a slot and then publishes the new +/// The capacity is rounded up to a power of two -- `capacity()` reports the +/// *rounded* value, not the requested one, and `size()` saturates there -- so +/// slot addressing is a mask instead of a modulo. Callers that need the +/// retained window's exact bound must read `capacity()`, never their own +/// constructor argument (see `StreamSeries`, which reserves the rounded value). +/// +/// The producer writes a slot and then publishes the new /// sample count with a release store; a consumer that observes that count with an /// acquire load is guaranteed to see the slot contents written before it. /// diff --git a/include/plot/data/series.hpp b/include/plot/data/series.hpp index ebcdcbb..22ec9f2 100644 --- a/include/plot/data/series.hpp +++ b/include/plot/data/series.hpp @@ -9,6 +9,20 @@ namespace plot::data { +/// What geometry a gather level produces for its buckets. The LOD *level* is +/// chosen exactly as before; the mode decides what each bucket contributes. +enum class GatherMode { + /// One (min, max) envelope pair per bucket: the default, and what a dense + /// time-series trace wants -- the eye reads the extremes. + Envelope, + /// One raw sample per bucket (the pyramid's `snap` pick), x at the sample: + /// a thinner geometry for traces whose *shape* matters more than extremes. + Nearest, + /// One point per bucket, y = the bucket mean (`sum` / count), x at the + /// bucket centre: a smoothed trace for noisy data. + Mean, +}; + /// A uniform-grid series of y samples: sample i lives at x = x0() + i*dx(). /// /// Every concrete series answers range queries exactly, and never by scanning the @@ -43,21 +57,52 @@ class Series { /// so the series that owns the sliding window is the one that answers. virtual bool streaming() const noexcept { return false; } - /// Appends interleaved (x, y) pairs in data coordinates for level `level`. + /// Appends interleaved (x, y) pairs for level `level`. /// - /// Level 0 emits the raw samples of [begin, end). Level >= 1 emits one (min, max) - /// envelope pair per *complete* level bucket wholly inside the retained window, - /// bounded by the two partial buckets at the edges of the requested range — the - /// bucket the window starts inside and the bucket the newest samples are in — - /// which it emits as raw samples through copy_raw(). A bucket that is only partly - /// retained holds aggregates of samples the window no longer has and a partial - /// trailing bucket is not stored at all, so neither may be drawn as an envelope. + /// **x is relative to `x0()`**: the emitted value is `x_at(i) - x0()` as + /// f32, never the absolute coordinate. A consumer reconstructs the absolute + /// x in f64 (`x0() + emitted`) before mapping it to the screen; staging an + /// absolute epoch-time x as f32 would quantise it to a grid coarser than + /// the pixel (f32 carries 24 bits -- 1 s at 1.7e9), while the relative + /// value `i*dx` is small and exact. y is unchanged. /// - /// The emitted geometry therefore always spans [begin, end) exactly, for at most - /// two buckets of extra raw work (about two pixels at the level level_for() - /// picks), and never reaches outside the retained window. Pairs are appended to - /// out_xy, which is never cleared. - virtual void gather(u32 level, u64 begin, u64 end, std::vector& out_xy) const = 0; + /// Level 0 emits the raw samples of [begin, end). Level >= 1 emits one + /// geometry element per *complete* level bucket wholly inside the retained + /// window, bounded by the two partial buckets at the edges of the requested + /// range -- the bucket the window starts inside and the bucket the newest + /// samples are in -- which it emits as raw samples through copy_raw(). A + /// bucket that is only partly retained holds aggregates of samples the + /// window no longer has and a partial trailing bucket is not stored at all, + /// so neither may be drawn from aggregates. `mode` picks what a bucket + /// contributes (see `GatherMode`); the edge raw samples are the same for + /// every mode. A bucket whose samples are all non-finite is empty, not + /// zero-valued (the library-wide NaN policy): Envelope and Mean emit nothing + /// for it, Nearest draws the raw centre sample. `level` is clamped to + /// `kMaxPyramidLevel`. + /// + /// The emitted geometry therefore always spans [begin, end) exactly, for at + /// most two buckets of extra raw work (about two pixels at the level + /// level_for() picks), and never reaches outside the retained window -- nor + /// outside [begin, end). Pairs are appended to out_xy, which is never + /// cleared. + virtual void gather(u32 level, u64 begin, u64 end, std::vector& out_xy, + GatherMode mode = GatherMode::Envelope) const = 0; + + /// Absolute x of sample `i` (`x0() + i*dx()` on a uniform-grid series). + /// Clamping is the caller's business: out-of-range `i` is the caller's bug. + virtual f64 x_at(u64 i) const; + + /// The samples bracketing data-space `x`: sets `i0` to the last index with + /// `x_at(i0) <= x` and `i1` to the first with `x_at(i1) >= x` (equal when x + /// lands exactly on a sample). False when x lies outside the series' x + /// bounds; `i0`/`i1` are then untouched. The crosshair and measurement + /// paths read coordinates only through this and `x_at()`, so a series with + /// its own x array interpolates exactly. + virtual bool neighbours(f64 x, u64& i0, u64& i1) const; + + /// Index of the sample nearest data-space `x`, clamped to `[0, size()-1]`; + /// 0 for an empty series. + virtual u64 snap_index(f64 x) const; /// Exact min/max, sum and sum-of-squares over [begin, end). /// Preconditions: begin < end, end <= size(); a degenerate range yields a @@ -75,8 +120,9 @@ class Series { virtual void copy_raw(u64 begin, u64 end, f32* out) const = 0; /// The shared body of the concrete gather() overrides: raw samples at level 0, - /// bucket min/max envelope above it. - void gather_impl(u32 level, u64 begin, u64 end, std::vector& out_xy) const; + /// bucket geometry above it (per `mode`). + void gather_impl(u32 level, u64 begin, u64 end, std::vector& out_xy, + GatherMode mode) const; private: /// [begin, end) split into the bucket-aligned interior the pyramid can answer diff --git a/include/plot/data/stream_series.hpp b/include/plot/data/stream_series.hpp index 4ee96e0..664fff0 100644 --- a/include/plot/data/stream_series.hpp +++ b/include/plot/data/stream_series.hpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -57,6 +58,11 @@ namespace plot::data { /// class specifies. class StreamSeries final : public Series { public: + /// `capacity` is the sample count to retain; it is rounded up to a power of + /// two by the ring (see `RingBuffer`), and `size()` saturates at that rounded + /// `capacity()` -- a `StreamSeries(100)` retains up to 128 samples. The + /// pyramid's level table is reserved for the rounded capacity up front, so a + /// saturated stream never grows it. explicit StreamSeries(u64 capacity, f64 x0, f64 dx); /// Producer thread only. @@ -68,6 +74,17 @@ class StreamSeries final : public Series { /// Consumer thread only: snapshots the produced count and brings the pyramid /// and bounds up to it. void acquire(); + /// Consumer thread only: copies the window published by the *last acquire()* + /// into a fresh `ArraySeries` with this window's `x0()`/`dx()`. The frozen + /// series never changes again, no matter what the producer pushes afterwards + /// -- it answers from its own copy, not from the ring -- which is what a + /// consumer that needs a stable window for longer than one read wants. (The + /// ring itself keeps overwriting; only the copy is frozen, so this costs one + /// window's worth of samples. It reads through `copy_raw()`, so like every + /// read it is a snapshot of *which* absolute indices to walk, not a guarantee + /// about slots a producer is overwriting -- call it on the consumer thread, + /// interleaved with `acquire()`, per the class note.) + ArraySeries freeze() const; u64 size() const override; f64 x0() const override; @@ -75,7 +92,8 @@ class StreamSeries final : public Series { Range x_bounds() const override; Range y_bounds() const override; const MinMaxPyramid& pyramid() const override; - void gather(u32 level, u64 begin, u64 end, std::vector& out_xy) const override; + void gather(u32 level, u64 begin, u64 end, std::vector& out_xy, + GatherMode mode = GatherMode::Envelope) const override; /// Always true: this series' `x_bounds()` is the retained ring window, which /// slides with every `acquire()` -- the property `XFollow::Live` follows. bool streaming() const noexcept override { return true; } diff --git a/include/plot/data/xy_series.hpp b/include/plot/data/xy_series.hpp new file mode 100644 index 0000000..00fe790 --- /dev/null +++ b/include/plot/data/xy_series.hpp @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once +#include + +#include +#include +#include + +namespace plot::data { + +/// Series over its own x array: sample i lives at `x()[i]`, and the samples are +/// sorted by ascending x. This is the store for data whose x is *not* on a +/// uniform grid -- scattered measurements, event times, a log-swept frequency +/// axis -- where `ArraySeries`' fixed `dx` cannot hold. +/// +/// **Precondition: `x` is sorted ascending.** The O(log n) crosshair and +/// nearest-sample lookups are binary searches, which are only correct on sorted +/// input; the constructor and `reset()` assert sortedness in debug builds and +/// the behaviour with unsorted input is undefined in release ones. Callers with +/// a possibly-unsorted source sort it first (sorting the *pairs*, not the +/// arrays independently, or the samples get scrambled). +/// +/// Everything the base `Series` contracts to holds, with the sample index as +/// the grid: the pyramid buckets samples by *index* (`kBucket` samples per +/// level-0 bucket, exactly as for a uniform series) and `gather()` emits x +/// relative to `x0()` -- `f32(x_at(i) - x0())`, never the absolute +/// coordinate -- so an epoch-scale x array narrows to f32 the same safe, +/// O(visible pixels) way a uniform one does. The queries that need a *uniform* +/// grid are overridden with the binary-search answer instead: `x_at()` reads +/// the array, `neighbours()` brackets with `upper_bound`/`lower_bound`, and +/// `snap_index()` compares the two candidates the search leaves. `dx()` is +/// meaningless here and returns 0: nothing that routes through +/// `x_at`/`neighbours`/`snap_index` consults it. +class XYSeries final : public Series { + public: + /// Takes both arrays by value. `x.size() == y.size()` and `x` sorted + /// ascending (asserted in debug). + XYSeries(std::vector x, std::vector y); + /// Replaces both arrays, then rebuilds derived state (== commit()). Same + /// preconditions as the constructor. + void reset(std::vector x, std::vector y); + /// Rebuilds the pyramid and the cached bounds from the arrays as they stand. + void commit(); + + u64 size() const override; + /// x of sample 0: the first (smallest) x in the array. + f64 x0() const override; + /// Always 0: the grid is the x array, not a uniform step. + f64 dx() const override; + /// From the first and last x; a zero span for an empty series. + Range x_bounds() const override; + Range y_bounds() const override; + const MinMaxPyramid& pyramid() const override; + /// Same contract as `Series::gather`, with x emitted relative to `x0()`. + void gather(u32 level, u64 begin, u64 end, std::vector& out_xy, + GatherMode mode = GatherMode::Envelope) const override; + + /// The sample's own x. + f64 x_at(u64 i) const override; + /// Binary-search bracket of data-space `x`: false outside + /// `[x_at(0), x_at(size() - 1)]`, else the last sample at or left of `x` and + /// the first at or right of it (equal on an exact hit). + bool neighbours(f64 x, u64& i0, u64& i1) const override; + /// Binary-search nearest sample, clamped to `[0, size() - 1]`; ties go to the + /// lower index, and an empty series returns 0. + u64 snap_index(f64 x) const override; + + /// The x array itself (sorted ascending). + const std::vector& x() const noexcept { return x_; } + + protected: + void copy_raw(u64 begin, u64 end, f32* out) const override; + + private: + std::vector x_; + std::vector y_; + MinMaxPyramid pyramid_; + Range x_bounds_{}; + Range y_bounds_{}; + /// False until a finite sample has been seen (see `ArraySeries`): the cached + /// y bounds are then a placeholder, and the first finite sample seeds them. + bool y_has_finite_ = false; +}; + +} // namespace plot::data diff --git a/include/plot/gl/api.hpp b/include/plot/gl/api.hpp index c318600..fef7c24 100644 --- a/include/plot/gl/api.hpp +++ b/include/plot/gl/api.hpp @@ -58,8 +58,14 @@ enum : GLenum { // Blending. GL_BLEND = 0x0BE2, + GL_ZERO = 0x0000, + GL_ONE = 0x0001, GL_SRC_ALPHA = 0x0302, GL_ONE_MINUS_SRC_ALPHA = 0x0303, + GL_BLEND_SRC_RGB = 0x80C9 /* 1.4 */, + GL_BLEND_DST_RGB = 0x80C8 /* 1.4 */, + GL_BLEND_SRC_ALPHA = 0x80CB /* 1.4 */, + GL_BLEND_DST_ALPHA = 0x80CA /* 1.4 */, // Pixel store state. GL_UNPACK_ALIGNMENT = 0x0CF5, @@ -127,7 +133,9 @@ enum : GLenum { X(void, glClearColor, (float, float, float, float)) \ X(void, glClear, (GLbitfield)) \ X(void, glEnable, (GLenum)) \ + X(GLboolean, glIsEnabled, (GLenum)) \ X(void, glBlendFunc, (GLenum, GLenum)) \ + X(void, glBlendFuncSeparate, (GLenum, GLenum, GLenum, GLenum)) /* 1.4 */ \ X(void, glPixelStorei, (GLenum, GLint)) \ X(void, glDisable, (GLenum)) \ X(void, glScissor, (GLint, GLint, GLsizei, GLsizei)) \ diff --git a/include/plot/gl/handle.hpp b/include/plot/gl/handle.hpp index 097d126..38094c3 100644 --- a/include/plot/gl/handle.hpp +++ b/include/plot/gl/handle.hpp @@ -36,7 +36,10 @@ class Handle { /// already used, so state that captured the old object -- the attribute /// pointers of a vertex array above all -- must compare this counter rather /// than the name (see `render::detail::InstanceStore::epoch()`). - u32 generation() const noexcept { return generation_; } + /// + /// 64-bit on purpose: `epoch()` sums this with a slot counter, and a long-lived + /// streaming frame loop must not be able to wrap a u32 sum into a stale value. + u64 generation() const noexcept { return generation_; } /// Deletes the GL object, if any, and empties the handle. void reset() noexcept { @@ -73,7 +76,7 @@ class Handle { GLuint id_ = 0; private: - u32 generation_ = 0; + u64 generation_ = 0; }; /// Vertex buffer object. All fallible operations report through `Status`; @@ -172,6 +175,9 @@ class Texture final : public Handle { bool create_r8(u32 width, u32 height, Status* st = nullptr) noexcept; /// Replaces the whole image with `width * height * 4` bytes of RGBA data. + /// Sets `GL_UNPACK_ALIGNMENT` to 4 for the upload and restores the caller's + /// value afterwards, so a host that shares the context never inherits this + /// call's pixel-store state. void upload_rgba(u32 width, u32 height, const void* pixels) const noexcept; /// Replaces the whole image with `width * height` bytes of single-channel diff --git a/include/plot/input/controller.hpp b/include/plot/input/controller.hpp index deb888a..aee07e2 100644 --- a/include/plot/input/controller.hpp +++ b/include/plot/input/controller.hpp @@ -3,6 +3,8 @@ #pragma once +#include + #include #include #include @@ -47,14 +49,21 @@ using plot::RegionGrab; /// | gesture | mode | effect | /// |---|---|---| /// | shift + left drag | `BoxZoom` | both ranges from the selection rect | +/// | ctrl + left drag | `BoxZoom` | x range only from the selection rect | +/// | alt + left drag | `BoxZoom` | y range only from the selection rect | /// | left drag on a handle | `MoveCursor` / `MoveRegion` / `ResizeRegion*` | move the handle the press grabbed | /// | left drag elsewhere | `Pan` | translate both ranges | /// | right drag | `Pan` | same, for hosts that reserve left | /// | middle drag on the x label strip | `AxisZoomX` | x-only zoom | /// | middle drag on the y label strip | `AxisZoomY` | y-only zoom | /// | wheel (shift: x only, ctrl: y only) | -- | zoom about the pointer | +/// | wheel `dx` (no `dy`) | -- | pan x, 5% of the span per notch | /// | `+` / `-` | -- | zoom about the plot centre | /// | `f` | -- | auto-fit every axis | +/// | Left/Right | -- | pan every x axis, 10% of its span | +/// | Up/Down | -- | pan every y axis, 10% of its span | +/// | Escape | -- | cancel the active drag: the ranges captured at `begin_drag` are restored, a box zoom applies nothing | +/// | double click (same button, 0.35 s, 4 px, `PointerEvent::time_s`) | -- | re-fit every axis; the second press begins no drag | /// /// The right button pans *and* grabs: both are buttons a host drives drags with, so /// neither one is singled out for handles. Middle keeps its own role on the label @@ -74,9 +83,10 @@ using plot::RegionGrab; /// Cursors and regions are *grabbable*: a press on one enters a drag that moves it, /// with no modifier, and a press anywhere else pans exactly as it always has -- so /// the new gesture is a superset of the old one and every host that drives a drag -/// keeps panning. `Ctrl` is no longer needed for a cursor move and does not change -/// what a press on one does; it only means the same thing it always did (a press -/// away from a handle pans, with or without it). +/// keeps panning. `Ctrl` and `Alt` no longer fall through to that press: they are +/// the modifier box zoom's masks now (see the gesture table -- Ctrl frames the x +/// range only, Alt the y range only, from the selection, over whatever was under +/// the press), which is why a *plain* press is the one that grabs a handle or pans. /// /// One hit test runs for both, in this order, and that precedence is the whole rule: /// @@ -203,9 +213,18 @@ class Controller { /// zeroed: a zero-notch zoom changes no range but still goes through the range /// setters, which clear auto scale -- so a sanitised amount would quietly disarm /// auto-fit. An unusable amount leaves the plot exactly as it was; an unusable - /// *position* only loses its anchor, falling back to the last known one. + /// *position* only loses its anchor, falling back to the last known one. The + /// same refusal covers `dx`, which pans when `dy` is zero. void on_wheel(const WheelEvent& e); void on_key(const KeyEvent& e); + /// A pinch frame from a touch host (see `GestureEvent`): `scale` zooms about + /// `(x, y)` exactly like a wheel notch pair would -- the same setters, the + /// same live-axis behaviour, the same guards -- and `(dx, dy)` pans by the + /// focal point's movement. A frame with `scale == 1` and no movement is + /// refused outright, for the same reason a zero-notch wheel is: the setters + /// would clear `auto_scale` for no visible change. An amount that is not a + /// number is refused rather than sanitised. + void on_gesture(const GestureEvent& e); DragMode drag_mode() const noexcept { return mode_; } /// The current box-zoom selection in pixels; meaningful while BoxZoom. @@ -237,6 +256,35 @@ class Controller { void update_drag(const PointerEvent& e); void end_drag(const PointerEvent& e); + /// The ranges (and, for x, the live offsets and auto-scale flags) every axis held + /// when a drag began -- what Escape restores. Captured at `begin_drag`, before the + /// gesture can move anything. + void capture_ranges(); + /// Escape: the active drag is cancelled -- the ranges captured at `begin_drag` + /// are restored, a box zoom applies nothing, and the grab state is cleared. The + /// restore goes through the plot's own setters, so a live axis is restored as the + /// span and offset it held (the setters' native reading) rather than as positions + /// that the next frame's follow would overwrite anyway. + void cancel_drag(); + /// Re-fits every axis (`auto_fit_x()` plus one `auto_fit_y()` per y axis), which + /// re-arms auto scale with the fit: the double-click reset. + void fit_all(); + /// True when `e` is the second press of a double-click: the same button, within + /// 0.35 s (both timestamps > 0 -- a host that leaves `time_s` at 0 never produces + /// one) and within 4 logical px of the recorded press. Records `e` as the press a + /// third one would be measured against either way; a detected double-click clears + /// the record, so a third press starts a fresh gesture rather than re-firing. + bool double_click(const PointerEvent& e); + + /// Pans every x axis by `fraction` of its own span, in axis space (log-aware: + /// a log axis translates multiplicatively). A positive fraction scrolls toward + /// larger x. The wheel's `dx` path and the Left/Right arrows share it. + void shift_x_axes(f64 fraction); + /// Same for every y axis: a positive fraction scrolls toward larger y (the Up + /// arrow). Screen y is inverted, so this is *not* the sign `pan()` derives from a + /// drag's `dy` -- an arrow says where to look, a drag says where the data goes. + void shift_y_axes(f64 fraction); + void pan(const PointerEvent& e); void box_zoom_update(const PointerEvent& e); void apply_box_zoom(); @@ -317,6 +365,34 @@ class Controller { Rect selection_{}; f32 origin_x_ = 0.0f; f32 origin_y_ = 0.0f; + /// The box-zoom mask, resolved once at `begin_drag` from the modifier table + /// (Shift = both, Ctrl = x-only, Alt = y-only, any other combo = both): which + /// axes the selection's release frames. `apply_box_zoom()` reads these for the + /// per-axis application *and* for the click threshold -- a masked-out dimension + /// of a thin selection does not make the gesture a click. + bool box_zoom_x_ = true; + bool box_zoom_y_ = true; + /// The range state a drag began with, per axis in order: what `cancel_drag()` + /// restores. Empty while no drag is open. + struct SavedX { + Range range{}; + f64 offset = 0.0; + bool auto_scale = false; + }; + struct SavedY { + Range range{}; + bool auto_scale = false; + }; + std::vector saved_x_; + std::vector saved_y_; + /// The last press seen, for the double-click reset: the button, the host + /// timestamp, and the position it was made at. `has_click_` is false until the + /// first press arrives. + bool has_click_ = false; + int click_button_ = -1; + f64 click_time_s_ = 0.0; + f32 click_x_ = 0.0f; + f32 click_y_ = 0.0f; u64 drag_cursor_ = kInvalid; /// The region drag's state. `drag_region_` is the region and `drag_zone_` the zone /// the press resolved, kept for the whole gesture so the highlight a grab started diff --git a/include/plot/input/events.hpp b/include/plot/input/events.hpp index f807e52..600fe10 100644 --- a/include/plot/input/events.hpp +++ b/include/plot/input/events.hpp @@ -35,6 +35,26 @@ struct PointerEvent { bool down = false; ///< the button is held after this event bool pressed = false; ///< a press transition happened on this event bool released = false; ///< a release transition happened on this event + /// Host-supplied monotonic seconds (the host's own clock, only differences + /// are read). Two presses of the same button within 0.35 s and 4 px are a + /// double-click and re-fit every axis. A host that leaves 0 never produces + /// one -- the field is optional, not another synchronisation contract. + f64 time_s = 0.0; +}; + +/// A pinch/two-finger gesture, in viewport pixels. The toolkit adapters +/// translate their native pinch state into one of these per frame of the +/// gesture: `scale` is this frame's zoom factor about `(x, y)` (1.0 = no +/// change), and `(dx, dy)` the focal point's movement since the previous +/// gesture event (a two-finger pan). GLFW has no touch input, so only hosts +/// that can deliver one feed this; the controller treats it exactly like the +/// equivalent wheel + pointer pair. +struct GestureEvent { + f32 x = 0.0f; + f32 y = 0.0f; + f32 scale = 1.0f; + f32 dx = 0.0f; + f32 dy = 0.0f; }; /// A wheel/scroll event at a pixel position. A positive `dy` (scroll up) zooms @@ -65,4 +85,15 @@ inline constexpr int kKeyFit = 'f'; /// Hosts that report shifted keys uppercase can pass this unchanged. inline constexpr int kKeyFitUpper = 'F'; +/// Escape cancels the active drag (pan, box zoom, cursor/region drag): the +/// ranges the axes had when the drag began are restored and no box is applied. +inline constexpr int kKeyEscape = 27; +/// Arrow keys pan: Left/Right move every x axis by 10% of its span per press +/// (repeat included), Up/Down move every y axis. Hosts translate their native +/// key codes to these, exactly as they do for the ASCII set above. +inline constexpr int kKeyLeft = 256; +inline constexpr int kKeyRight = 257; +inline constexpr int kKeyUp = 258; +inline constexpr int kKeyDown = 259; + } // namespace plot::input diff --git a/include/plot/plot.hpp b/include/plot/plot.hpp index 30bf0f6..4a955b3 100644 --- a/include/plot/plot.hpp +++ b/include/plot/plot.hpp @@ -37,6 +37,7 @@ #include #include #include +#include #include #include @@ -64,3 +65,9 @@ #include #include + +// The library version. The one public header configure_file() generates into +// the build tree (see include/plot/version.hpp.in), so it is not under the +// source include/ root with the layers above -- last here so the layer order +// stays the story of the list. +#include diff --git a/include/plot/plot/axis.hpp b/include/plot/plot/axis.hpp index 29e38e0..6435fdb 100644 --- a/include/plot/plot/axis.hpp +++ b/include/plot/plot/axis.hpp @@ -56,15 +56,21 @@ struct Axis { /// a y axis has no live edge -- and `Off` on both, so nothing changes for a /// caller who never asks for it. See XFollow. XFollow follow = XFollow::Off; - /// How far behind the live edge the visible window's right edge sits, in data - /// units, under `XFollow::Live`. Stored rather than implied by the range, - /// because the range is recomputed from the series' newest x on every frame: - /// this is the part of the view the user chose, so it is the part that survives - /// the recompute. Three things write it, all of them through one clamp: a pan or - /// a zoom through `Plot::set_x_range()`, which derives it from the range the - /// gesture asked for, `Plot::set_x_offset()` for a caller that states it - /// directly, and `Plot::layout()`'s placement, which re-clamps it against the - /// retained window every frame. `Plot::x_offset()` reads it back. + /// How far behind the live edge the visible window's right edge sits, in **axis + /// space**, under `XFollow::Live`: data units on a linear axis, `log10` decades + /// on a logarithmic one -- the same space the controller's pan/zoom gestures run + /// in (`to_space`/`from_space`), so a log axis scrolls and holds *decades* the + /// way a linear axis scrolls and holds units. (`to_space(live.max) - + /// to_space(hi)` is the definition: the distance the axis' own transform + /// measures between the live edge and the window's right edge.) Stored rather + /// than implied by the range, because the range is recomputed from the series' + /// newest x on every frame: this is the part of the view the user chose, so it + /// is the part that survives the recompute. Three things write it, all of them + /// through one clamp: a pan or a zoom through `Plot::set_x_range()`, which + /// derives it from the range the gesture asked for, `Plot::set_x_offset()` for a + /// caller that states it directly, and `Plot::layout()`'s placement, which + /// re-clamps it against the retained window -- measured in the same space -- + /// every frame. `Plot::x_offset()` reads it back. f64 offset = 0.0; /// Set by an *owner* that is following this axis' window on the plot's behalf: a /// `Figure` link group, while it follows, writes its own live edge here every frame. diff --git a/include/plot/plot/plot.hpp b/include/plot/plot/plot.hpp index be5f3bb..80d0058 100644 --- a/include/plot/plot/plot.hpp +++ b/include/plot/plot/plot.hpp @@ -152,8 +152,16 @@ class Plot { // --- viewport, style, text metrics ------------------------------------ - /// The rectangle this plot occupies on screen, in framebuffer pixels. - void set_viewport(Rect px) noexcept { viewport_ = px; } + /// The rectangle this plot occupies on screen, in framebuffer pixels. A + /// negative width or height is normalised to 0: the layout clamps against + /// the viewport's dimensions and a negative would make those clamps + /// ill-formed (`std::clamp` with `lo > hi`), so the invariant is repaired + /// at the one place a hostile rect can enter. + void set_viewport(Rect px) noexcept { + if (px.w < 0.0f) px.w = 0.0f; + if (px.h < 0.0f) px.h = 0.0f; + viewport_ = px; + } const Rect& viewport() const noexcept { return viewport_; } /// Set from the renderer, which owns presentation: `Renderer::draw()` copies diff --git a/include/plot/plot/ticks.hpp b/include/plot/plot/ticks.hpp index ef39642..f7596d1 100644 --- a/include/plot/plot/ticks.hpp +++ b/include/plot/plot/ticks.hpp @@ -68,8 +68,10 @@ inline constexpr usize kMaxTicks = 256; /// `include_minors` adds, for grid geometry, the minor ticks subdividing each /// major step by 5 (by 4 when the step's leading digit is 2). Without it `out` /// holds exactly the labelled major ticks -- the labelled set a caller draws -/// numbers for, with no unlabelled entries to filter. Either way the total is -/// capped at kMaxTicks. +/// numbers for, with no unlabelled entries to filter. The cap counts **minor** +/// ticks only: a range dense enough to exceed it loses its subdivisions, never +/// its labelled majors -- every major at or below `hi` is emitted, so the far +/// end of the axis keeps its numbers. /// /// Hostile input clears `out` and returns: a non-finite endpoint, `max <= min` /// (a zero or negative span, which includes the collapsed range of a diff --git a/include/plot/plot/trace.hpp b/include/plot/plot/trace.hpp index 536d48f..76e3a07 100644 --- a/include/plot/plot/trace.hpp +++ b/include/plot/plot/trace.hpp @@ -9,6 +9,16 @@ namespace plot { +/// How a trace's geometry is gathered from its LOD pyramid. The level choice +/// is unchanged; this picks what a bucket contributes (see +/// `data::GatherMode`). +using TraceMode = data::GatherMode; + +/// A marker shape for per-sample points. Drawn with the trace's own colour +/// through the shared line pipeline; `marker_every == 0` strides +/// automatically (one marker per pixel column at most). +enum class Marker { None, Cross, Circle, Square }; + /// How a caller asks for a trace. The series pointer is borrowed and must outlive /// the plot's use of it; `axis_x`/`axis_y` index the plot's x and y axis lists. struct TraceDesc { @@ -16,7 +26,8 @@ struct TraceDesc { Color color = Color::hex(0x4FA3FF); /// The stroke's width in **logical** pixels, like every stroke in `Style` (see /// that struct's unit rule): the renderer converts it once, so a trace has the - /// same weight against the axis lines whatever the display's DPI. + /// same weight against the axis lines whatever the display's DPI. `0` draws + /// the markers only -- a scatter trace. f32 width = 1.5f; u32 axis_x = 0, axis_y = 0; /// What the trace is called, for the legend. Empty means "do not list it", which @@ -26,6 +37,20 @@ struct TraceDesc { /// unchanged, exactly as `AxisDesc`'s trailing format does (and, in a build with /// `-Wextra`, warning-clean). std::string name{}; + /// Downsample policy for the LOD buckets (Envelope by default). + TraceMode mode = TraceMode::Envelope; + /// Dash pattern in logical pixels: ink, then gap. `dash_off <= 0` (and the + /// default) is a solid line -- no geometry is added, the dash is a + /// fragment-shader discard. + f32 dash_on = 0.0f; + f32 dash_off = 0.0f; + /// Per-sample markers (see `Marker`). `marker_every == 0` strides + /// automatically -- at most about one marker per pixel column -- so a dense + /// trace never stages more markers than it has columns; a positive value + /// draws every n-th gathered sample literally. + Marker marker = Marker::None; + f32 marker_size_px = 3.0f; + u32 marker_every = 0; }; /// A series bound to a colour, width and axis pair. The series pointer may be @@ -35,13 +60,21 @@ struct Trace { const data::Series* series = nullptr; Color color = Color::hex(0x4FA3FF); /// Logical pixels, as in `TraceDesc::width`: converted to device pixels by the - /// renderer's one `Style::device()` call per stroke. + /// renderer's one `Style::device()` call per stroke. `0` = scatter (markers + /// only, if any). f32 width = 1.5f; u32 axis_x = 0, axis_y = 0; /// The legend's entry for this trace, empty when it has none. A name does not /// make a trace drawable: the legend lists a trace only when it is visible, so a /// null series is skipped there exactly as it is by every other consumer. std::string name{}; + /// Same fields as `TraceDesc`, applied by the renderer. + TraceMode mode = TraceMode::Envelope; + f32 dash_on = 0.0f; + f32 dash_off = 0.0f; + Marker marker = Marker::None; + f32 marker_size_px = 3.0f; + u32 marker_every = 0; /// True when the trace has a series with at least one sample to draw. bool visible() const noexcept { return series != nullptr && series->size() > 0; } diff --git a/include/plot/render/buffer.hpp b/include/plot/render/buffer.hpp index dff30dc..ef298ed 100644 --- a/include/plot/render/buffer.hpp +++ b/include/plot/render/buffer.hpp @@ -35,10 +35,11 @@ struct LineInstance { /// rect onto the two the same way round, so a cell is never mirrored. `(1, 0)` /// is unrotated text, and `(0, -1)` is a run advancing up the screen, i.e. a /// 90 degree counter-clockwise label such as a y-axis title. +/// - `(r, g, b, a)` is the run's tint, straight alpha. Per instance rather than +/// a uniform, so one draw can carry a readout whose values are coloured per +/// row (each row's own trace) while its labels stay the axis text colour. /// -/// Six floats, so the whole text pipeline still fits one instanced draw call per -/// frame with rotation free: a matrix per glyph would be three times the bytes -/// for the same pixels. +/// Ten floats, still one instanced draw call per frame. struct TextInstance { f32 x; f32 y; @@ -46,10 +47,14 @@ struct TextInstance { f32 v; f32 ax; f32 ay; + f32 r; + f32 g; + f32 b; + f32 a; }; static_assert(sizeof(LineInstance) == 4 * sizeof(f32), "line instance must stay 4 floats"); -static_assert(sizeof(TextInstance) == 6 * sizeof(f32), "text instance must stay 6 floats"); +static_assert(sizeof(TextInstance) == 10 * sizeof(f32), "text instance must stay 10 floats"); /// How a store's staged instances reach the GPU. Semantics are identical either /// way -- only the transfer differs, and `Renderer::init()` picks one from @@ -137,7 +142,9 @@ class InstanceStore { /// buffer cannot leave the vertex array bound to a deleted one), and `slot_epoch_` /// advances on every upload that lands in a new slot. Both counters only ever /// increase, so their sum changes whenever either does, and never repeats. - u32 epoch() const noexcept { return buffer_.generation() + slot_epoch_; } + /// 64-bit: the sum of two counters that advance every upload of a streaming + /// frame loop must not be able to wrap into a stale value. + u64 epoch() const noexcept { return buffer_.generation() + slot_epoch_; } void bind() const noexcept { buffer_.bind(); } bool persistent() const noexcept { return mode_ == UploadMode::PersistentMapped; } @@ -165,7 +172,7 @@ class InstanceStore { u32 frame_uploads_ = 0; /// Times an upload landed in a new slot. Kept apart from the buffer's own /// identity (see `epoch()`) so neither has to know about the other. - u32 slot_epoch_ = 0; + u64 slot_epoch_ = 0; u8* mapped_ = nullptr; ///< base of the mapped ring, null in `Orphan` mode /// Guards every slot the frame just ended wrote. Recorded once, after its last /// draw; waited on once, at the start of the next frame. @@ -186,7 +193,7 @@ class LineBuffer { } bool mark_drawn() noexcept { return store_.mark_drawn(); } usize slot_offset() const noexcept { return store_.slot_offset(); } - u32 epoch() const noexcept { return store_.epoch(); } + u64 epoch() const noexcept { return store_.epoch(); } bool persistent() const noexcept { return store_.persistent(); } void bind() const noexcept { store_.bind(); } @@ -206,7 +213,7 @@ class TextBuffer { } bool mark_drawn() noexcept { return store_.mark_drawn(); } usize slot_offset() const noexcept { return store_.slot_offset(); } - u32 epoch() const noexcept { return store_.epoch(); } + u64 epoch() const noexcept { return store_.epoch(); } bool persistent() const noexcept { return store_.persistent(); } void bind() const noexcept { store_.bind(); } diff --git a/include/plot/render/primitives.hpp b/include/plot/render/primitives.hpp index cc4c59f..aaf2889 100644 --- a/include/plot/render/primitives.hpp +++ b/include/plot/render/primitives.hpp @@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: 2026 Martino Ferrari #pragma once +#include #include #include #include @@ -60,7 +61,7 @@ class LineBatch { /// Like `LineBatch` it keeps its capacity across frames. Each staged glyph is /// one instance: the glyph cell's top-left corner in pixels, the glyph rect's /// normalised top-left in the atlas and the cell's rotation basis, interleaved as -/// `x,y,u,v,ax,ay` — the layout the text shader declares (see `TextInstance` in +/// `x,y,u,v,ax,ay,r,g,b,a` — the layout the text shader declares (see `TextInstance` in /// buffer.hpp). /// /// Every glyph a batch stages is read through one atlas rect of one size — its @@ -92,8 +93,9 @@ class TextBatch { /// rasterised them. `axis` is the direction the run advances in, in pixel /// space; it rotates the cell (see `TextInstance`), and is normalised here, so /// any non-zero direction works and a zero or non-finite one means "unrotated". - void add_glyph(Vec2 pen_px, const Glyph& glyph, f32 px = 1.0f, - Vec2 axis = {1.0f, 0.0f}); + /// `color` tints this run's glyphs (per-vertex `a_colour`, see `TextInstance`). + void add_glyph(Vec2 pen_px, const Glyph& glyph, f32 px = 1.0f, Vec2 axis = {1.0f, 0.0f}, + Color color = {1.0f, 1.0f, 1.0f, 1.0f}); /// Stages every codepoint of `utf8` from `pen_px` and returns the pen past the /// last glyph, so the run is exactly `font.measure(utf8) * px` long measured @@ -104,14 +106,14 @@ class TextBatch { /// `next_codepoint()` and `glyph()`, so a run stages exactly what `measure()` /// summed. A codepoint the backend has no glyph for stages nothing and moves /// the pen nowhere — a run is never wider than the glyphs it can draw. A null - /// or empty string stages nothing and returns `pen_px`. + /// or empty string stages nothing and returns `pen_px`. `color` tints the run. Vec2 add_text(const char* utf8, Vec2 pen_px, f32 px, const TextBackend& font, - Vec2 axis = {1.0f, 0.0f}); + Vec2 axis = {1.0f, 0.0f}, Color color = {1.0f, 1.0f, 1.0f, 1.0f}); /// Glyphs staged so far, one instance each. - u32 glyph_count() const noexcept { return static_cast(v_.size() / 6u); } + u32 glyph_count() const noexcept { return static_cast(v_.size() / 10u); } - /// Interleaved `x,y,u,v,ax,ay`, six floats per staged glyph. + /// Interleaved `x,y,u,v,ax,ay,r,g,b,a`, ten floats per staged glyph. const std::vector& data() const noexcept { return v_; } /// The cell the staged glyphs are drawn in: what `set_cell()` declared, or -- @@ -124,7 +126,7 @@ class TextBatch { private: /// Stages one cell whose basis the caller has already normalised, so a run and /// the pen it advances share one rounding of the axis. - void add_cell(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 unit_ux); + void add_cell(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 unit_ux, Color color); std::vector v_; Vec2 cell_uv_{}; diff --git a/include/plot/render/program.hpp b/include/plot/render/program.hpp index 79ecb40..28e3444 100644 --- a/include/plot/render/program.hpp +++ b/include/plot/render/program.hpp @@ -35,6 +35,9 @@ class LineProgram { /// discard on the interpolated `v_along`, so it costs no geometry, and it is the one uniform /// that distinguishes the crosshair's stroke from every other line in the library. void set_dash(Vec2 on_off) const noexcept; + /// `u_aa`: the AA fringe half-width in pixels. The style's `line_aa_px` in + /// device pixels, clamped at 0.25; 1.0 reproduces the historical fringe. + void set_aa(f32 px) const noexcept; /// `u_color`: stroke colour, alpha included. void set_color(Color color) const noexcept; @@ -66,8 +69,8 @@ class TextProgram { void set_glyph_uv(Vec2 uv) const noexcept; /// `u_tex_size`: atlas size in texels. void set_texture_size(Vec2 texels) const noexcept; - /// `u_color`: tint colour, alpha included. - void set_color(Color color) const noexcept; + /// The tint is per instance (`a_colour`), not a uniform, so a draw can carry + /// runs of several colours -- there is deliberately no colour setter here. private: gl::Program program_; diff --git a/include/plot/render/renderer.hpp b/include/plot/render/renderer.hpp index 45672a9..7fe7f5d 100644 --- a/include/plot/render/renderer.hpp +++ b/include/plot/render/renderer.hpp @@ -31,6 +31,7 @@ struct FrameStats { u32 lod_traces = 0; ///< traces drawn from a min/max pyramid level u64 visible_samples = 0; ///< samples the gathered windows covered, summed u32 staged_segments = 0; ///< line segments staged, all colour groups + u32 staged_markers = 0; ///< marker shapes staged over the traces u32 draw_calls = 0; ///< draw calls issued, line and text }; @@ -70,10 +71,26 @@ struct FrameStats { class Renderer { public: Renderer() noexcept = default; - ~Renderer() = default; + /// Releases every GL handle first (`release_gl()`), so the destructor is safe + /// whenever the context that owns them is still current -- the normal case for + /// a member renderer destroyed by its host's widget teardown. + ~Renderer(); Renderer(const Renderer&) = delete; Renderer& operator=(const Renderer&) = delete; + /// Destroys every GL object this renderer created: both programs, both vertex + /// arrays and their instance buffers, and the glyph atlas texture. The + /// renderer is left uninitialised (`ready() == false`) and `init()` can be + /// called again on the same object. + /// + /// **Teardown contract:** call this while the context that owns the names is + /// still current, before the host destroys that context -- a GL name deleted + /// after its context is gone is leaked, not deleted. The destructor calls this + /// first for the common case of a member renderer whose context outlives it; + /// a host that tears the context down before its renderer must call + /// `release_gl()` explicitly. + void release_gl() noexcept; + /// Compiles both programs, uploads the font's atlas and allocates the vertex /// buffers. The calling thread must have the context current and /// `plot::gl::load()` must have succeeded, otherwise the returned `Status` is @@ -215,7 +232,7 @@ class Renderer { /// /// The GL state it leaves behind, so a host can embed the plot without guessing: /// vertex array 0, program 0, `GL_ARRAY_BUFFER` 0, texture unit 0 active and - /// nothing bound to `GL_TEXTURE_2D` on it, blending off, scissoring off, and the + /// nothing bound to `GL_TEXTURE_2D` on it, scissoring off, and the /// viewport left at the frame's rect (the caller sets its own for the next pass). /// /// The scissor is the other piece of state a frame moves, and it ends the frame *off*: @@ -346,12 +363,19 @@ class Renderer { /// One axis pair's data -> pixel mapping, already shifted so the viewport's /// top-left corner is the origin (the shaders emit NDC relative to the GL /// viewport, not the framebuffer). + /// + /// `origin_x` is the data-space reference the caller staged its geometry + /// relative to: every data-space x reaches this layer as the f32 + /// `to_space(x) - to_space(origin_x)` (B1), so the mapping folds the origin + /// back into the offset uniform -- `offset_x = sx*to_space(origin_x) + tx - + /// viewport.x`, computed in f64 and narrowed once -- and the staged values + /// stay O(visible pixels) however large the absolute x is (an epoch axis). struct Mapping { Vec2 scale{1.0f, 1.0f}; Vec2 offset{0.0f, 0.0f}; }; - Mapping mapping(const Plot& plot, u32 axis_x, u32 axis_y) const noexcept; + Mapping mapping(const Plot& plot, u32 axis_x, u32 axis_y, f64 origin_x) const noexcept; /// The identity mapping for geometry already staged in viewport pixels. Mapping pixel_mapping() const noexcept; /// Shifts one framebuffer-space pixel position into viewport space. @@ -370,12 +394,14 @@ class Renderer { /// /// `dash` is the pattern in pixels for the fragment shader's discard: `x` the ink /// length, `y` the gap, and a non-positive `y` -- the default every caller but the - /// crosshair passes -- means **solid**. It is set on every flush, so one draw's dash - /// can never leak into the next. + /// crosshair passes and a dashed trace -- means **solid**. It is set on every flush, + /// so one draw's dash can never leak into the next. The AA fringe (`u_aa`) is + /// likewise set on every flush, from `style().line_aa_px` in device pixels. void flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash = Vec2{}) noexcept; - /// Draws every staged glyph in `style().axis_text` and empties the batch. Text - /// is texture-bound, so this runs once per frame, after all line geometry. - void flush_text() noexcept; + /// Draws every staged glyph in `batch` -- each instance carries its own tint + /// (`a_colour`) -- and empties the batch. Text is texture-bound, so this runs + /// once per text batch, after the line geometry it belongs over. + void flush_text(render::TextBatch& batch) noexcept; /// Narrows the frame's clip to `rect` -- framebuffer pixels, top-left origin, like every /// `Rect` here -- and *intersects* it with the frame's viewport, so the clip can only @@ -408,13 +434,20 @@ class Renderer { /// measurement band fills, is the wrong primitive here). A rect with no width or /// no height stages nothing. void fill_pixels(const Rect& rect, Color color); - /// Stages one data-space segment, taking the two axes' log flags into account. - void stage_segment(f64 x0, f64 y0, f64 x1, f64 y1, bool log_x, bool log_y); - /// Stages `n` interleaved `x,y` data-space points (a gathered window). - void stage_xy(const f32* xy, u32 n, bool log_x, bool log_y); - /// Lays one run of text out at a framebuffer position and stages it, shifting - /// it into viewport space. Returns the pen past the run. - Vec2 stage_text(Vec2 pos_px, const char* utf8); + /// Stages one data-space segment as f32 values **relative to `origin_x`** in + /// the axis' pre-affine space (`log10` of it on a log axis): the mapping folds + /// `origin_x` back in, in f64, before the single f32 narrowing (B1), so a + /// large-magnitude x -- an epoch axis -- never loses sub-pixel precision. + void stage_segment(f64 origin_x, f64 x0, f64 y0, f64 x1, f64 y1, bool log_x, bool log_y); + /// Stages `n` interleaved `x,y` points of a gathered window (see + /// `Series::gather`): x **relative to the series' `x0()`**, y absolute. The + /// absolute data x is reconstructed in f64 (`series_x0 + rel`) and staged as + /// `to_space(x_abs) - to_space(origin_x)` -- O(visible pixels), not O(epoch). + void stage_xy(const f32* xy, u32 n, f64 series_x0, f64 origin_x, bool log_x, bool log_y); + /// Lays one run of text out at a framebuffer position and stages it into + /// `batch`, shifting it into viewport space and tinting it `color` (the + /// per-instance `a_colour`). Returns the pen past the run. + Vec2 stage_text(render::TextBatch& batch, Vec2 pos_px, const char* utf8, Color color); /// Width of the longest prefix of `utf8` that fits `budget_px` along `axis`, at /// the size the text pipeline draws with: exactly what `stage_text_clipped()` /// stages. @@ -430,7 +463,8 @@ class Renderer { /// horizontal x title, which spends the strip's width, and (0,-1) for the y /// title, which reads bottom-to-top and spends the strip's height. `pos_px` is /// the pen -- for the rotated run, the bottom-left corner of its first cell. - f32 stage_text_clipped(Vec2 pos_px, const char* utf8, f32 budget_px, Vec2 axis); + f32 stage_text_clipped(render::TextBatch& batch, Vec2 pos_px, const char* utf8, f32 budget_px, + Vec2 axis, Color color); /// Pins the text pipeline for a frame: the plot layer's backend at the size the /// style asks for, the cell every staged glyph is drawn in, and the atlas the @@ -531,6 +565,12 @@ class Renderer { /// pointer is on: a readout the legend covered would be the feature failing at its one job. void draw_crosshair_readout(const Plot& plot, const PlotLayout& layout); + /// One marker shape (`Marker`) at a viewport-pixel centre: `Cross` two + /// segments, `Circle` an 8-gon, `Square` four. Staged into the line batch in + /// pixel space, at `size_px` half-extent, so a marker's size is device pixels + /// whatever the data scale is. + void stage_marker(plot::Marker marker, Vec2 centre_px, f32 size_px); + /// The built-in backend the renderer owns, and the face the frames draw with: /// `set_text_backend()` writes `font_`, which starts here and comes back here /// when a host passes null. Every use of the face goes through the @@ -549,8 +589,16 @@ class Renderer { render::TextVertexArray text_vao_; render::LineBatch lines_; - render::TextBatch glyphs_; + /// The frame's two text batches (B6): `glyphs_under_` collects the text the + /// crosshair readout's panel may legitimately occlude (legend names, cursor + /// labels, measure labels, annotation captions) and is flushed immediately + /// before `draw_crosshair_readout()`; `glyphs_over_` collects what must sit + /// over everything (readout text, tick labels, titles) and is flushed at + /// `end_frame()`. One batch per z-order, one draw call each. + render::TextBatch glyphs_under_; + render::TextBatch glyphs_over_; std::vector gather_; ///< scratch for one gathered window, re-used + std::vector staged_; ///< scratch for one staged window, re-used mutable std::vector readback_; ///< dump_frame_ppm's row buffer /// The host's `GL_COLOR_CLEAR_VALUE`, queried on the first `begin_frame()` (GL @@ -559,6 +607,16 @@ class Renderer { Color host_clear_{}; bool host_clear_saved_ = false; + /// The host's blend state, saved by `begin_frame()` and handed back by + /// `end_frame()` (R6): a frame sets blending for its straight-alpha pipelines + /// and must not leave its function -- or the enable bit -- behind for the + /// host's own draws to inherit. + gl::GLint blend_src_rgb_ = gl::GL_ONE; + gl::GLint blend_src_a_ = gl::GL_ONE; + gl::GLint blend_dst_rgb_ = gl::GL_ZERO; + gl::GLint blend_dst_a_ = gl::GL_ZERO; + bool blend_was_on_ = false; + Rect viewport_; ///< the frame's region, top-left origin, framebuffer px Rect gl_viewport_; ///< the same rect flipped for GL, and for readback u32 fb_w_ = 0; diff --git a/include/plot/render/shaders.hpp b/include/plot/render/shaders.hpp index 28a493d..7ec1f9c 100644 --- a/include/plot/render/shaders.hpp +++ b/include/plot/render/shaders.hpp @@ -16,7 +16,9 @@ namespace plot::render { /// `v_along`, the distance travelled from `a_p0` in pixels. /// /// Uniforms: `u_scale` (vec2, data space to pixels), `u_offset` (vec2, pixels), -/// `u_viewport` (vec2, pixels), `u_width` (float, pixels), `u_color` (vec4). +/// `u_viewport` (vec2, pixels), `u_width` (float, pixels), `u_aa` (float, the AA +/// fringe half-width in pixels -- the style's `line_aa_px` in device pixels, +/// clamped at 0.25), `u_color` (vec4). /// `plot::Rect` is top-left origin while GL is bottom-left, so the y axis is /// flipped exactly once, here. /// @@ -44,13 +46,14 @@ extern const char* const kLineFS; /// handedness, so a glyph's top row stays its top row however the cell is /// turned. The fragment shader converts back to texels and samples the /// single-channel `GL_R8` atlas at the nearest texel centre, so `GL_NEAREST` -/// never lands on a cell boundary. `u_color` tints the straight alpha. +/// never lands on a cell boundary. The instance's own `a_colour` tints the +/// straight alpha -- per vertex, so one draw can carry runs of several colours. /// /// Uniforms: `u_viewport` (vec2, pixels), `u_px_range` (vec2, the glyph cell's /// size on screen in pixels), `u_glyph_uv` (vec2, the glyph cell's size in /// normalised atlas coordinates -- one size for the whole draw, since every /// glyph of a frame comes from one atlas), `u_tex_size` (vec2, atlas size in -/// texels), `u_atlas` (sampler2D, unit 0), `u_color` (vec4). +/// texels), `u_atlas` (sampler2D, unit 0). extern const char* const kTextVS; extern const char* const kTextFS; diff --git a/include/plot/render/style.hpp b/include/plot/render/style.hpp index f3c0f2b..2321496 100644 --- a/include/plot/render/style.hpp +++ b/include/plot/render/style.hpp @@ -102,6 +102,12 @@ struct Style { bool grid_minor_visible = true; ///< stage the minor grid at all. f32 line_width_px = 1.5f; ///< logical width of the strokes framing the data ///< (axis lines, cursor markers). + /// Logical half-width of the anti-aliased fringe the line shader blends at + /// every stroke edge (the smoothstep ramp runs over `|dist| in [w - aa, + /// w + aa]`). Clamped to at least 0.25 device px at use: below that the + /// ramp is thinner than a fragment and edges alias again. 1.0 is the value + /// every default was chosen on. + f32 line_aa_px = 1.0f; /// Logical width of a *draggable handle* drawn highlighted: the tab a hovered or /// grabbed cursor marker is widened to, and the tab a region's hot bound is drawn /// as -- the latter sized by `Plot::layout()` with this same metric, so the two diff --git a/include/plot/render/vertex_array.hpp b/include/plot/render/vertex_array.hpp index 2e53aa1..9cd410a 100644 --- a/include/plot/render/vertex_array.hpp +++ b/include/plot/render/vertex_array.hpp @@ -54,20 +54,21 @@ class LineVertexArray { gl::VertexArray vao_; u32 instances_ = 0; /// Instance-buffer generation the attribute pointers currently point into. - u32 epoch_ = 0; + u64 epoch_ = 0; }; /// Vertex array plus instance buffer of the text pipeline: `a_pos` at location 0 /// (the cell's top-left corner in pixels), the glyph's atlas rect `a_uv` at -/// location 1 and its rotation basis `a_axis` at location 2, two floats each, -/// every one advancing once per instance. The layout is the one `TextInstance` +/// location 1, its rotation basis `a_axis` at location 2 and the run's tint +/// `a_colour` at location 3 (`vec2`, `vec2`, `vec2`, `vec4` floats), each +/// advancing once per instance. The layout is the one `TextInstance` /// declares and shaders.hpp consumes; a caller never writes a location, stride or /// offset, it stages a `TextBatch` and uploads its `data()`. class TextVertexArray { public: bool create(Status* st = nullptr, UploadMode mode = UploadMode::Orphan) noexcept; - /// Uploads `glyphs` instances from interleaved `x,y,u,v,ax,ay` floats. + /// Uploads `glyphs` instances from interleaved `x,y,u,v,ax,ay,r,g,b,a` floats. void upload(const f32* interleaved, u32 glyphs) noexcept; void bind() const noexcept { vao_.bind(); } @@ -84,7 +85,7 @@ class TextVertexArray { TextBuffer buffer_; gl::VertexArray vao_; u32 instances_ = 0; - u32 epoch_ = 0; + u64 epoch_ = 0; }; } // namespace plot::render diff --git a/include/plot/version.hpp.in b/include/plot/version.hpp.in new file mode 100644 index 0000000..8306ed3 --- /dev/null +++ b/include/plot/version.hpp.in @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +// Template for : configure_file() expands it into +// /include/plot/version.hpp at configure time, and that generated file +// -- not this one -- is a public header. The umbrella names it, and the +// install rules place it under include/plot/ with the rest of the surface, so +// a consumer can ask what it compiled against: the macros for a preprocessor +// test, the plot::version constants for everything else. Both come from the +// project() version, so there is exactly one place a release edits. + +#pragma once + +#define PLOT_VERSION_MAJOR @plot_VERSION_MAJOR@ +#define PLOT_VERSION_MINOR @plot_VERSION_MINOR@ +#define PLOT_VERSION_PATCH @plot_VERSION_PATCH@ +#define PLOT_VERSION_STRING "@plot_VERSION@" + +namespace plot::version { + +/// The library version: one component per constant, and the whole thing as the +/// string the project() declares (e.g. "0.1.0"). +inline constexpr int major = PLOT_VERSION_MAJOR; +inline constexpr int minor = PLOT_VERSION_MINOR; +inline constexpr int patch = PLOT_VERSION_PATCH; +inline constexpr const char* string = PLOT_VERSION_STRING; + +} // namespace plot::version diff --git a/plot.pc.in b/plot.pc.in new file mode 100644 index 0000000..6d61ae6 --- /dev/null +++ b/plot.pc.in @@ -0,0 +1,26 @@ +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2026 Martino Ferrari +# +# pkg-config metadata for the plot core library: configure_file() turns this +# into /plot.pc and the install rules place it in +# ${CMAKE_INSTALL_LIBDIR}/pkgconfig/. It serves consumers whose build is not +# CMake -- find_package(plot) and the package config under lib/cmake/plot/ +# remain the primary interface. The prefix is anchored to the file's own +# location (`pkg-config --define-prefix` and a plain PKG_CONFIG_PATH both +# resolve it), so an install relocated after configuration -- including +# `cmake --install --prefix` -- still resolves its own headers and library. +# The ${...} references below are pkg-config's own, evaluated against this +# file's prefix; configure_file runs with @ONLY so they reach the installed +# file untouched. + +prefix=${pcfiledir}/../.. +exec_prefix=${prefix} +libdir=${prefix}/@PLOT_PC_LIBDIR@ +includedir=${prefix}/@PLOT_PC_INCLUDEDIR@ + +Name: @PROJECT_NAME@ +Description: @PROJECT_DESCRIPTION@ +Version: @PROJECT_VERSION@ +Libs: -L${libdir} @PLOT_PC_LIB@ +Libs.private: @PLOT_PC_LIBS_PRIVATE@ +Cflags: -I${includedir} diff --git a/readme_main.cpp b/readme_main.cpp new file mode 100644 index 0000000..877040a --- /dev/null +++ b/readme_main.cpp @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +// The README's embed block, committed as a file: the whole of a minimal GLFW +// program (the adapter opens the window, runs the loop and feeds the +// controller, and the callback is where live data would be pushed). The +// compile command it is verified with is in README.md, next to the same code. + +#include // the optional GLFW adapter +#include + +#include +#include + +int main() { + plot::glfw::Host host; + plot::Plot& p = host.plot(); + + std::vector y(512); + for (plot::usize i = 0; i < y.size(); ++i) + y[i] = static_cast(std::sin(0.05 * static_cast(i))); + plot::data::ArraySeries series(std::move(y), 0.0, 1.0); + + const plot::u32 x = p.add_x_axis({"x", {0.0, 511.0}, true}); + const plot::u32 sig = p.add_y_axis({"signal", {-1.0, 1.0}, true, plot::AxisPlace::Left}); + p.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, x, sig, "sine"}); + + plot::Status st; + return host.run({800, 480, "plot"}, [](plot::Plot&, plot::f64) {}, &st) ? 0 : 1; +} diff --git a/src/data/array_series.cpp b/src/data/array_series.cpp index a366d89..adc0442 100644 --- a/src/data/array_series.cpp +++ b/src/data/array_series.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -23,23 +24,45 @@ void ArraySeries::commit() { pyramid_.clear(); pyramid_.reserve(n); + // Bounds seed on the first *finite* sample and fold the rest: a non-finite + // sample is excluded from every aggregate, and a window with none aggregates + // to a zero-span Range (the library-wide NaN policy). f32 lo = 0.0f, hi = 0.0f; - bool first = true; + bool finite = false; for (const f32 v : y_) { pyramid_.append(v); - if (first) { - lo = v; - hi = v; - first = false; - } else { + if (!std::isfinite(v)) continue; + if (finite) { lo = std::min(lo, v); hi = std::max(hi, v); + } else { + lo = v; + hi = v; + finite = true; } } - y_bounds_ = first ? Range{0.0, 0.0} : Range{f64(lo), f64(hi)}; + y_has_finite_ = finite; + y_bounds_ = finite ? Range{f64(lo), f64(hi)} : Range{0.0, 0.0}; x_bounds_ = n == 0 ? Range{x0_, x0_} : Range{x0_, x0_ + f64(n - 1) * dx_}; } +void ArraySeries::push(f32 v) { + y_.push_back(v); + pyramid_.append(v); + // The incremental twin of commit()'s fold: skip non-finite, seed on the first + // finite sample, extend after that. + if (std::isfinite(v)) { + if (y_has_finite_) { + y_bounds_.min = std::min(y_bounds_.min, f64(v)); + y_bounds_.max = std::max(y_bounds_.max, f64(v)); + } else { + y_bounds_ = Range{f64(v), f64(v)}; + y_has_finite_ = true; + } + } + x_bounds_ = Range{x0_, x0_ + f64(y_.size() - 1) * dx_}; +} + u64 ArraySeries::size() const { return static_cast(y_.size()); } f64 ArraySeries::x0() const { return x0_; } @@ -52,8 +75,9 @@ Range ArraySeries::y_bounds() const { return y_bounds_; } const MinMaxPyramid& ArraySeries::pyramid() const { return pyramid_; } -void ArraySeries::gather(u32 level, u64 begin, u64 end, std::vector& out_xy) const { - gather_impl(level, begin, end, out_xy); +void ArraySeries::gather(u32 level, u64 begin, u64 end, std::vector& out_xy, + GatherMode mode) const { + gather_impl(level, begin, end, out_xy, mode); } void ArraySeries::copy_raw(u64 begin, u64 end, f32* out) const { diff --git a/src/data/minmax_pyramid.cpp b/src/data/minmax_pyramid.cpp index 7bf1fb3..f7c0dd3 100644 --- a/src/data/minmax_pyramid.cpp +++ b/src/data/minmax_pyramid.cpp @@ -12,8 +12,12 @@ namespace plot::data { namespace { -/// Samples covered by one level-l bucket. -constexpr u64 level_span(u32 l) { return u64{kBucket} << l; } +/// Samples covered by one level-l bucket. Clamped to kMaxPyramidLevel: the +/// shift past level 57 would overflow u64 (UB), and a bucket wider than any +/// u64 sample count is the same bucket as the widest one anyway. +constexpr u64 level_span(u32 l) { + return u64{kBucket} << (l > kMaxPyramidLevel ? kMaxPyramidLevel : l); +} /// Level-l buckets needed to hold n samples (ceiling division). constexpr u64 buckets_for(u64 n, u32 l) { @@ -72,11 +76,13 @@ void cover_buckets(const MinMaxPyramid& p, u64 b0, u64 b1, Fn&& fn) { } /// Folds the decomposition of [b0, b1) into a min/max Range. Returns a -/// zero-span Range when there is nothing to fold. +/// zero-span Range when there is nothing to fold. Buckets with no finite +/// sample (count 0) are absent, not zero-valued, and are skipped. Range fold_range(const MinMaxPyramid& p, u64 b0, u64 b1) { Range r{}; bool first = true; cover_buckets(p, b0, b1, [&](u32 l, u64 i) { + if (p.bucket_count(l, i) == 0) return; const MinMax m = p.bucket(l, i); if (first) { r.min = f64(m.min); @@ -96,10 +102,13 @@ void MinMaxPyramid::add_level() { levels_.emplace_back(); } void MinMaxPyramid::reserve(u64 n) { // Materialise every level n samples can need -- level 0 plus one per doubling - // of the bucket -- so a run of appends never grows the level vector. See the - // header for why the bucket deques themselves cannot be pre-sized. + // of the bucket, capped at kMaxPyramidLevel -- so a run of appends never grows + // the level vector. See the header for why the bucket deques themselves cannot + // be pre-sized. u32 want = 1; - for (u64 span = level_span(0); span <= n && want < 63; span <<= 1) ++want; + for (u64 span = level_span(0); span <= n && want <= kMaxPyramidLevel; span <<= 1) { + ++want; + } if (levels_.size() < want) levels_.resize(want); } @@ -115,20 +124,25 @@ void MinMaxPyramid::push(u32 l, u64 k, const Entry& e) { } void MinMaxPyramid::append(f32 v) { - const f64 vd = f64(v); // The new sample always lands in the in-progress level-0 bucket, which is the // only bucket any append ever touches: a bucket becomes an entry exactly once, // complete, and is never rewritten. - if (count_ % kBucket == 0) { - pending_.mm.min = v; - pending_.mm.max = v; - pending_.sum = vd; - pending_.sumsq = vd * vd; - } else { - pending_.mm.min = std::min(pending_.mm.min, v); - pending_.mm.max = std::max(pending_.mm.max, v); + if (count_ % kBucket == 0) pending_ = Entry{}; + // Non-finite samples occupy their absolute position -- bucket boundaries are + // positional, so NaN or not the sample advances count_ and completes its + // bucket -- but they are excluded from every aggregate (see the header). + if (std::isfinite(v)) { + const f64 vd = f64(v); + if (pending_.count == 0) { + pending_.mm.min = v; + pending_.mm.max = v; + } else { + pending_.mm.min = std::min(pending_.mm.min, v); + pending_.mm.max = std::max(pending_.mm.max, v); + } pending_.sum += vd; pending_.sumsq += vd * vd; + ++pending_.count; } ++count_; if (count_ % kBucket != 0) return; // the bucket is still partial: nothing to push @@ -141,7 +155,8 @@ void MinMaxPyramid::append(f32 v) { // Cascade: level l gains an entry exactly when kBucket << l divides the sample // count, so the walk stops at the first level that gained nothing -- amortised // O(1) per sample (1/kBucket + 1/(2*kBucket) + ... = 1/(kBucket/2) merges). - for (u32 l = 1; l < 63; ++l) { + // Capped at kMaxPyramidLevel: past it the span shift would overflow. + for (u32 l = 1; l <= kMaxPyramidLevel; ++l) { const u64 span = level_span(l); if (count_ % span != 0) break; const u64 k = count_ / span - 1; // the level-l bucket that just completed @@ -152,10 +167,14 @@ void MinMaxPyramid::append(f32 v) { const Entry& a = at(l - 1, child); const Entry& b = at(l - 1, child + 1); Entry m; - m.mm.min = std::min(a.mm.min, b.mm.min); - m.mm.max = std::max(a.mm.max, b.mm.max); + // An empty child (count 0) has zero sums and a meaningless mm: it must not + // vote 0 into the merge, so a merge with it is just the other child. + m.mm = a.count == 0 ? b.mm : (b.count == 0 ? a.mm + : MinMax{std::min(a.mm.min, b.mm.min), + std::max(a.mm.max, b.mm.max)}); m.sum = a.sum + b.sum; m.sumsq = a.sumsq + b.sumsq; + m.count = a.count + b.count; push(l, k, m); } } @@ -225,6 +244,12 @@ u64 MinMaxPyramid::level_size(u32 l) const noexcept { MinMax MinMaxPyramid::bucket(u32 l, u64 i) const { return at(l, i).mm; } +f64 MinMaxPyramid::bucket_sum(u32 l, u64 i) const { return at(l, i).sum; } + +f64 MinMaxPyramid::bucket_sumsq(u32 l, u64 i) const { return at(l, i).sumsq; } + +u32 MinMaxPyramid::bucket_count(u32 l, u64 i) const { return at(l, i).count; } + Range MinMaxPyramid::reduce_buckets(u64 begin, u64 end) const { const u64 e = std::min(end, size()); if (begin >= e) return Range{0.0, 0.0}; @@ -265,7 +290,7 @@ u32 MinMaxPyramid::level_for(u64 n, f64 samples_per_pixel) const { const f64 ratio = samples_per_pixel / f64(kBucket); if (n == 0 || !(ratio > 1.0)) return 0; const f64 lg = std::floor(std::log2(ratio)); - u32 l = lg >= f64(62) ? 62u : static_cast(lg); + u32 l = lg >= f64(kMaxPyramidLevel) ? kMaxPyramidLevel : static_cast(lg); while (l > 0) { const bool exists = l < level_count() && level_size(l) >= 2; if (exists && buckets_for(n, l) >= 2) break; diff --git a/src/data/series.cpp b/src/data/series.cpp index 8fc0ec3..81e6e8a 100644 --- a/src/data/series.cpp +++ b/src/data/series.cpp @@ -4,6 +4,7 @@ #include #include +#include #include namespace plot::data { @@ -13,6 +14,9 @@ namespace { /// gather path never allocates, and large enough to amortise the call. constexpr u64 kGatherChunk = 256; +/// Ceiling division: first multiple of `d` at or after `v`, in `d` units. +constexpr u64 ceil_div(u64 v, u64 d) { return v / d + (v % d == 0 ? u64{0} : u64{1}); } + } // namespace Series::QueryParts Series::split_query(u64 begin, u64 end) const { @@ -75,6 +79,7 @@ Range Series::reduce(u64 begin, u64 end) const { const u64 n = copy_edges(p, edge); for (u64 i = 0; i < n; ++i) { const f64 v = f64(edge[i]); + if (!std::isfinite(v)) continue; // non-finite samples are excluded (B7) if (first) { r.min = v; r.max = v; @@ -84,6 +89,8 @@ Range Series::reduce(u64 begin, u64 end) const { r.max = std::max(r.max, v); } } + // No finite sample at all (an all-NaN window): a zero-span Range, per the + // library-wide NaN policy. return first ? Range{0.0, 0.0} : r; } @@ -98,7 +105,10 @@ f64 Series::sum(u64 begin, u64 end) const { } f32 edge[2 * kBucket]; const u64 n = copy_edges(p, edge); - for (u64 i = 0; i < n; ++i) total += f64(edge[i]); + for (u64 i = 0; i < n; ++i) { + const f64 v = f64(edge[i]); + if (std::isfinite(v)) total += v; // non-finite samples are excluded (B7) + } return total; } @@ -115,27 +125,73 @@ f64 Series::sumsq(u64 begin, u64 end) const { const u64 n = copy_edges(p, edge); for (u64 i = 0; i < n; ++i) { const f64 v = f64(edge[i]); - total += v * v; + if (std::isfinite(v)) total += v * v; // non-finite samples are excluded (B7) } return total; } +f64 Series::x_at(u64 i) const { return x0() + f64(i) * dx(); } + +bool Series::neighbours(f64 x, u64& i0, u64& i1) const { + const u64 n = size(); + if (n == 0) return false; + const f64 lo = x_at(0); + const f64 hi = x_at(n - 1); + if (!(x >= lo && x <= hi)) return false; + const f64 step = dx(); + if (!(step > 0.0)) { + i0 = 0; + i1 = 0; + return true; + } + const f64 t = (x - lo) / step; + const u64 k = x >= hi ? n - 1 : static_cast(t); // floor, in range + // An exact hit collapses to one index, whatever the division above rounded + // to: compare against the samples themselves, not against t's fraction. + if (x_at(k) == x) { + i0 = k; + i1 = k; + return true; + } + if (k + 1 < n && x_at(k + 1) == x) { + i0 = k + 1; + i1 = k + 1; + return true; + } + i0 = k; + i1 = k + 1 < n ? k + 1 : k; + return true; +} + +u64 Series::snap_index(f64 x) const { + const u64 n = size(); + if (n == 0) return 0; + const f64 step = dx(); + if (!(step > 0.0)) return 0; + const f64 k = std::round((x - x0()) / step); + // The first test also rejects NaN (every comparison with it is false), which + // would otherwise reach the u64 cast undefined. + if (!(k > 0.0)) return 0; + if (k >= f64(n)) return n - 1; + return static_cast(k); +} + void Series::gather_raw(u64 begin, u64 end, std::vector& out_xy) const { - const f64 x_0 = x0(); const f64 step = dx(); f32 raw[kGatherChunk]; for (u64 i = begin; i < end;) { const u64 take = std::min(kGatherChunk, end - i); copy_raw(i, i + take, raw); for (u64 k = 0; k < take; ++k) { - out_xy.push_back(f32(x_0 + f64(i + k) * step)); + out_xy.push_back(f32(f64(i + k) * step)); // x relative to x0(): see gather() out_xy.push_back(raw[k]); } i += take; } } -void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector& out_xy) const { +void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector& out_xy, + GatherMode mode) const { const u64 n = size(); const u64 b = std::min(begin, n); const u64 e = std::min(std::max(end, b), n); @@ -145,28 +201,37 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector& out_xy gather_raw(b, e, out_xy); return; } + // Public levels clamp to the deepest representable one (R3): past + // kMaxPyramidLevel the bucket-span shift would overflow, and the widest + // bucket there is is already wider than any window. + level = std::min(level, kMaxPyramidLevel); - // Level >= 1: one envelope pair per *retained* bucket the range overlaps, x at - // the bucket's first sample (min) and at its last sample (max), which keeps x - // monotonic while covering the bucket's span. The pyramid's buckets are - // absolute, so map the window range onto its grid and clamp to the buckets that - // are both complete and wholly inside the window: a bucket that is only partly - // retained aggregates samples the window no longer has, and a partial trailing - // bucket is not stored at all. + // Level >= 1: one geometry element per *retained* bucket the range overlaps. + // The pyramid's buckets are absolute, so map the window range onto its grid + // and clamp to the buckets that are both complete and wholly inside the + // requested range: `first` is the first bucket whose span starts at or after + // `begin` (ceiling, so the bucket cannot reach left of the range) and `last` + // is one past the last bucket whose span ends at or before `end` (floor, so + // it cannot reach right of it). This is the B3 fix: a floor on `first` (or a + // bucket containing `end - 1`) emitted aggregates that covered samples + // outside [begin, end), so the envelope was wrong for any unaligned range. + // Buckets that are only partly *retained* are also excluded by construction: + // stored entries are complete aggregates of their own span, and a partial + // trailing bucket is never stored. // - // Those two excluded buckets are still part of the range, and one of them holds - // the newest samples -- for a live trace, the part being watched. They are drawn - // from the raw samples instead: at most one bucket at each end, about a pixel - // each at the level level_for() picks, so the envelope path stays O(buckets) - // and the geometry still spans the requested range exactly. - const f64 x_0 = x0(); + // The two excluded spans at the edges -- [b, lead) and [trail, e) -- are part + // of the range, and one of them holds the newest samples -- for a live trace, + // the part being watched. They are drawn from the raw samples instead: at + // most one bucket at each end, about a pixel each at the level level_for() + // picks, so the envelope path stays O(buckets) and the geometry still spans + // the requested range exactly, for every mode. const f64 step = dx(); const u64 span = u64{kBucket} << level; const u64 origin = pyramid().window_base(); const u64 lbase = pyramid().level_base(level); const u64 lend = lbase + pyramid().level_size(level); - const u64 first = std::max((origin + b) / span, lbase); - const u64 last = std::min((origin + e - 1) / span + 1, lend); + const u64 first = std::max(ceil_div(origin + b, span), lbase); + const u64 last = std::min((origin + e) / span, lend); if (first >= last) { // No complete bucket of this level is inside the range -- a window squeezed // into one bucket -- so the raw samples are the only exact geometry there is. @@ -175,15 +240,56 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector& out_xy } const u64 lead = first * span - origin; // window index the first emitted bucket starts at if (b < lead) gather_raw(b, std::min(lead, e), out_xy); - for (u64 bi = first; bi < last; ++bi) { - const MinMax m = pyramid().bucket(level, bi); - const u64 first_sample = bi * span - origin; // >= 0: stored buckets start inside the window - const u64 last_sample = std::min((bi + 1) * span, origin + e) - 1 - origin; - out_xy.push_back(f32(x_0 + f64(first_sample) * step)); - out_xy.push_back(m.min); - out_xy.push_back(f32(x_0 + f64(last_sample) * step)); - out_xy.push_back(m.max); + + switch (mode) { + case GatherMode::Envelope: { + // One (min, max) pair per bucket, x at the bucket's first sample (min) + // and at its last sample (max), which keeps x monotonic while covering + // the bucket's span. A bucket with no finite sample is absent, not + // zero-valued, and emits nothing. + for (u64 bi = first; bi < last; ++bi) { + if (pyramid().bucket_count(level, bi) == 0) continue; + const MinMax m = pyramid().bucket(level, bi); + const u64 first_sample = bi * span - origin; // >= 0: stored buckets start inside the window + const u64 last_sample = std::min((bi + 1) * span, origin + e) - 1 - origin; + out_xy.push_back(f32(f64(first_sample) * step)); // x relative to x0() + out_xy.push_back(m.min); + out_xy.push_back(f32(f64(last_sample) * step)); + out_xy.push_back(m.max); + } + break; + } + case GatherMode::Nearest: { + // One raw sample per bucket, picked the way pyramid().snap() would pick + // it: the sample nearest the bucket's centre, which on the fixed grid is + // the centre sample itself. x is at the sample; the value is raw, so a + // bucket whose centre sample is non-finite contributes that NaN, exactly + // as level 0 would have drawn it. + for (u64 bi = first; bi < last; ++bi) { + const u64 centre = bi * span + span / 2 - origin; // inside [b, e): the bucket is wholly inside + f32 v = 0.0f; + copy_raw(centre, centre + 1, &v); + out_xy.push_back(f32(f64(centre) * step)); // x relative to x0() + out_xy.push_back(v); + } + break; + } + case GatherMode::Mean: { + // One point per bucket, y = the bucket's finite samples' mean (the exact + // sum the bucket stores, divided by its finite count) and x at the + // bucket's centre. A bucket with no finite sample has no mean and emits + // nothing. + for (u64 bi = first; bi < last; ++bi) { + const u32 count = pyramid().bucket_count(level, bi); + if (count == 0) continue; + const u64 centre = bi * span + span / 2 - origin; + out_xy.push_back(f32(f64(centre) * step)); // x relative to x0() + out_xy.push_back(f32(pyramid().bucket_sum(level, bi) / f64(count))); + } + break; + } } + const u64 trail = last * span - origin; // one past the last emitted bucket's span if (trail < e) gather_raw(trail, e, out_xy); } diff --git a/src/data/stream_series.cpp b/src/data/stream_series.cpp index d86f0a2..2786f9e 100644 --- a/src/data/stream_series.cpp +++ b/src/data/stream_series.cpp @@ -18,8 +18,11 @@ constexpr u64 kReadChunk = 256; StreamSeries::StreamSeries(u64 capacity, f64 x0, f64 dx) : ring_(capacity), x0_(x0), dx_(dx) { // Size the pyramid's level table for a full window up front: the ring never - // holds more than capacity() samples, so appending never grows it. - pyramid_.reserve(capacity); + // holds more than capacity() samples, so appending never grows it. The ring + // rounded the request up to a power of two, and the window it will hold is + // the *rounded* capacity -- reserving the raw argument would under-size a + // non-power-of-two request exactly when the stream saturates (R4). + pyramid_.reserve(ring_.capacity()); } void StreamSeries::push(f32 y) { ring_.push(y); } @@ -60,6 +63,15 @@ void StreamSeries::acquire() { y_bounds_ = reduce(0, size()); } +ArraySeries StreamSeries::freeze() const { + // The window of the last acquire(), copied out once: the frozen series owns + // its samples, so nothing the producer does afterwards can move it. + const u64 n = size(); + std::vector y(static_cast(n)); + if (n > 0) copy_raw(0, n, y.data()); + return ArraySeries(std::move(y), x0(), dx()); +} + u64 StreamSeries::size() const { return pyramid_.size(); } f64 StreamSeries::x0() const { return x0_ + f64(pyramid_.window_base()) * dx_; } @@ -75,8 +87,9 @@ Range StreamSeries::y_bounds() const { return y_bounds_; } const MinMaxPyramid& StreamSeries::pyramid() const { return pyramid_; } -void StreamSeries::gather(u32 level, u64 begin, u64 end, std::vector& out_xy) const { - gather_impl(level, begin, end, out_xy); +void StreamSeries::gather(u32 level, u64 begin, u64 end, std::vector& out_xy, + GatherMode mode) const { + gather_impl(level, begin, end, out_xy, mode); } void StreamSeries::copy_raw(u64 begin, u64 end, f32* out) const { diff --git a/src/data/xy_series.cpp b/src/data/xy_series.cpp new file mode 100644 index 0000000..e4b71b0 --- /dev/null +++ b/src/data/xy_series.cpp @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include + +#include +#include +#include +#include +#include + +namespace plot::data { +namespace { + +/// Ceiling division: the first level-`l` bucket whose whole span starts at or +/// after `v` samples (in bucket units). +constexpr u64 ceil_div(u64 v, u64 d) { return v / d + (v % d == 0 ? u64{0} : u64{1}); } + +} // namespace + +XYSeries::XYSeries(std::vector x, std::vector y) { reset(std::move(x), std::move(y)); } + +void XYSeries::reset(std::vector x, std::vector y) { + // The binary searches below (and every O(log n) consumer of them) are only + // correct on sorted input; see the header for the precondition. + assert(x.size() == y.size()); + assert(std::is_sorted(x.begin(), x.end()) && "XYSeries requires x sorted ascending"); + x_ = std::move(x); + y_ = std::move(y); + commit(); +} + +void XYSeries::commit() { + const u64 n = static_cast(y_.size()); + pyramid_.clear(); + pyramid_.reserve(n); + + // Same NaN-aware fold as ArraySeries::commit(): non-finite y is excluded + // from the cached bounds, and a window with no finite sample aggregates to a + // zero-span Range. (The pyramid's append() applies the same policy per + // bucket.) The x bounds come straight from the sorted array's ends. + f32 lo = 0.0f, hi = 0.0f; + bool finite = false; + for (const f32 v : y_) { + pyramid_.append(v); + if (!std::isfinite(v)) continue; + if (finite) { + lo = std::min(lo, v); + hi = std::max(hi, v); + } else { + lo = v; + hi = v; + finite = true; + } + } + y_has_finite_ = finite; + y_bounds_ = finite ? Range{f64(lo), f64(hi)} : Range{0.0, 0.0}; + x_bounds_ = n == 0 ? Range{0.0, 0.0} : Range{x_.front(), x_.back()}; +} + +u64 XYSeries::size() const { return static_cast(y_.size()); } + +f64 XYSeries::x0() const { return x_.empty() ? 0.0 : x_.front(); } + +f64 XYSeries::dx() const { return 0.0; } + +Range XYSeries::x_bounds() const { return x_bounds_; } + +Range XYSeries::y_bounds() const { return y_bounds_; } + +const MinMaxPyramid& XYSeries::pyramid() const { return pyramid_; } + +f64 XYSeries::x_at(u64 i) const { return x_[static_cast(i)]; } + +bool XYSeries::neighbours(f64 x, u64& i0, u64& i1) const { + const u64 n = size(); + if (n == 0) return false; + // Also rejects NaN: every comparison with it is false. + if (!(x >= x_.front() && x <= x_.back())) return false; + // Last sample at or left of x, first at or right of it. Both searches are in + // range: x >= front puts a lower_bound hit at index >= 1's predecessor at 0, + // and x <= back caps upper_bound's predecessor at n - 1. + const auto up = std::upper_bound(x_.begin(), x_.end(), x); + const auto lo = std::lower_bound(x_.begin(), x_.end(), x); + i0 = static_cast(up - x_.begin()) - 1; + i1 = static_cast(lo - x_.begin()); + return true; +} + +u64 XYSeries::snap_index(f64 x) const { + const u64 n = size(); + if (n == 0) return 0; + if (!(x > x_.front())) return 0; // at or left of the first sample; NaN too + if (x >= x_.back()) return n - 1; + const auto it = std::lower_bound(x_.begin(), x_.end(), x); + const u64 hi = static_cast(it - x_.begin()); + const u64 lo = hi - 1; // hi >= 1: x is strictly past x_.front() + return (x - x_[static_cast(lo)]) <= (x_[static_cast(hi)] - x) + ? lo + : hi; // ties go to the lower index +} + +void XYSeries::gather(u32 level, u64 begin, u64 end, std::vector& out_xy, + GatherMode mode) const { + const u64 n = size(); + const u64 b = std::min(begin, n); + const u64 e = std::min(std::max(end, b), n); + if (b >= e) return; + + // x emitted relative to x0(): see Series::gather(). The x array is f64, so + // the subtraction happens wide and only the small, exact offset narrows. + const f64 origin = x0(); + + if (level == 0) { + for (u64 i = b; i < e; ++i) { + out_xy.push_back(f32(x_[static_cast(i)] - origin)); + out_xy.push_back(y_[static_cast(i)]); + } + return; + } + level = std::min(level, kMaxPyramidLevel); + + // The bucket grid is the sample index, exactly as for a uniform series, and + // an XYSeries never slides its window, so the pyramid's base is 0 and the + // window range needs no origin shift. Bucket selection is the B3-correct + // ceil/floor: `first` is the first bucket whose samples all sit at or after + // `begin`, `last` one past the last bucket wholly before `end`, and the two + // edge spans around them are read from the raw arrays -- so no emitted + // geometry comes from outside [begin, end), and the emitted x values are the + // array's own coordinates relative to x0(). + const u64 span = u64{kBucket} << level; + const u64 lend = pyramid().level_size(level); + const u64 first = std::min(ceil_div(b, span), lend); + const u64 last = std::min(e / span, lend); + if (first >= last) { + for (u64 i = b; i < e; ++i) { + out_xy.push_back(f32(x_[static_cast(i)] - origin)); + out_xy.push_back(y_[static_cast(i)]); + } + return; + } + const u64 lead = first * span; + for (u64 i = b; i < lead; ++i) { + out_xy.push_back(f32(x_[static_cast(i)] - origin)); + out_xy.push_back(y_[static_cast(i)]); + } + + switch (mode) { + case GatherMode::Envelope: { + for (u64 bi = first; bi < last; ++bi) { + if (pyramid().bucket_count(level, bi) == 0) continue; + const MinMax m = pyramid().bucket(level, bi); + const u64 lo = bi * span; // the bucket's first sample + const u64 hi = std::min((bi + 1) * span, e) - 1; // its last sample inside the range + out_xy.push_back(f32(x_[static_cast(lo)] - origin)); + out_xy.push_back(m.min); + out_xy.push_back(f32(x_[static_cast(hi)] - origin)); + out_xy.push_back(m.max); + } + break; + } + case GatherMode::Nearest: { + for (u64 bi = first; bi < last; ++bi) { + const u64 centre = bi * span + span / 2; // inside the bucket, hence inside [b, e) + out_xy.push_back(f32(x_[static_cast(centre)] - origin)); + out_xy.push_back(y_[static_cast(centre)]); + } + break; + } + case GatherMode::Mean: { + for (u64 bi = first; bi < last; ++bi) { + const u32 count = pyramid().bucket_count(level, bi); + if (count == 0) continue; + const u64 centre = bi * span + span / 2; + out_xy.push_back(f32(x_[static_cast(centre)] - origin)); + out_xy.push_back(f32(pyramid().bucket_sum(level, bi) / f64(count))); + } + break; + } + } + + const u64 trail = last * span; + for (u64 i = trail; i < e; ++i) { + out_xy.push_back(f32(x_[static_cast(i)] - origin)); + out_xy.push_back(y_[static_cast(i)]); + } +} + +void XYSeries::copy_raw(u64 begin, u64 end, f32* out) const { + const u64 want = end > begin ? end - begin : 0; + if (want == 0) return; + const u64 n = size(); + const u64 b = std::min(begin, n); + const u64 e = std::min(std::max(end, b), n); + std::copy(y_.begin() + static_cast(b), + y_.begin() + static_cast(e), out); + std::fill(out + static_cast(e - b), + out + static_cast(want), 0.0f); +} + +} // namespace plot::data diff --git a/src/gl/handle.cpp b/src/gl/handle.cpp index 5cbbb6a..162df31 100644 --- a/src/gl/handle.cpp +++ b/src/gl/handle.cpp @@ -317,9 +317,15 @@ bool Texture::create_r8(u32 width, u32 height, Status* st) noexcept { void Texture::upload_rgba(u32 width, u32 height, const void* pixels) const noexcept { glBindTexture(GL_TEXTURE_2D, id_); + // RGBA rows are 4-byte aligned whatever the width, but the *caller's* upload + // state is not ours to move: save what the host had and put it back, the same + // contract `upload_r8`'s callers honour for the single-channel path. + GLint host_alignment = 4; + glGetIntegerv(GL_UNPACK_ALIGNMENT, &host_alignment); glPixelStorei(GL_UNPACK_ALIGNMENT, 4); glTexImage2D(GL_TEXTURE_2D, 0, static_cast(GL_RGBA8), static_cast(width), static_cast(height), 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels); + glPixelStorei(GL_UNPACK_ALIGNMENT, host_alignment); } void Texture::upload_r8(u32 width, u32 height, const void* pixels) const noexcept { diff --git a/src/input/controller.cpp b/src/input/controller.cpp index aa4cc97..bc33eac 100644 --- a/src/input/controller.cpp +++ b/src/input/controller.cpp @@ -39,6 +39,21 @@ constexpr f32 kCursorHitRadiusLogical = 7.0f; /// A box-zoom selection narrower than this on either side is a click. Logical for /// the same reason: the gesture is the same hand movement on any display. constexpr f32 kMinBoxLogical = 4.0f; +/// Share of an axis' span one horizontal wheel notch (`dx`) pans, in axis space: +/// a notch is a small, deliberate step, a twentieth of the view. +constexpr f64 kPanPerNotch = 0.05; +/// Share of an axis' span one arrow-key press pans, in axis space -- twice the +/// wheel's step, because a key press is a coarser statement than a detent. A held +/// key repeats (the adapters' shared policy), so a hold pans continuously. +constexpr f64 kPanPerPress = 0.10; +/// The double-click window: two presses of the same button within this many +/// seconds -- and within `kDoubleClickPxLogical` of each other -- are a double-click +/// reset, not two gestures. Measured on `PointerEvent::time_s`, the host's clock. +constexpr f64 kDoubleClickSeconds = 0.35; +/// How close together the two presses of a double-click must land, in **logical** +/// pixels: the same hand-distance rule as the other tolerances here, converted +/// through `Style::device()` at the plot's scale. +constexpr f32 kDoubleClickPxLogical = 4.0f; /// The narrowest *interior* a region's body keeps, in **logical** pixels: the edge bands are /// cut back until this much of the middle is left, so a region too narrow for the full grab /// radius on each side is still something a pointer can take hold of and *move*. Without it @@ -136,7 +151,13 @@ void Controller::on_pointer(const PointerEvent& raw) { plot_->layout(); // keep the chrome geometry the gestures map through current if (e.pressed && e.down) { - begin_drag(e); + if (double_click(e)) { + // The second press of a double-click re-fits every axis and begins no drag: + // a reset is a click-shaped gesture, not the start of one. + fit_all(); + } else { + begin_drag(e); + } } else if (e.released) { end_drag(e); } else if (e.down && mode_ != DragMode::None) { @@ -158,31 +179,59 @@ void Controller::on_wheel(const WheelEvent& raw) { // like a zero-notch zoom -- a factor of exactly one -- and the zoom body would // still run, calling the range setters with the unchanged range. Those clear // `auto_scale` unconditionally, so a single garbage wheel would silently disarm - // auto-fit on every axis. Return before the event can mutate anything. - if (!std::isfinite(raw.dy)) return; + // auto-fit on every axis. `dx` is refused by the same rule: it pans when `dy` is + // zero, and a garbage pan is no more welcome than a garbage zoom. Return before + // the event can mutate anything. + if (!std::isfinite(raw.dy) || !std::isfinite(raw.dx)) return; const WheelEvent e = sanitized(raw, has_pointer_, pointer_x_, pointer_y_); plot_->layout(); - const f64 factor = zoom_factor(static_cast(e.dy)); - if (!(factor > 0.0)) return; - const bool shift = has_mod(e.mods, Mod::Shift); - const bool ctrl = has_mod(e.mods, Mod::Ctrl); - // Shift alone restricts the zoom to x, ctrl alone to y; neither (or both) - // zooms both axes. The data coordinate under the pointer stays put. - const bool zoom_x = !(ctrl && !shift); - const bool zoom_y = !(shift && !ctrl); + // A zero-notch wheel -- `dy == 0` and `dx == 0`, which a trackpad or a driver + // happily forwards -- must change nothing at all. Letting it run would call the + // range setters with every range unchanged, and those clear `auto_scale`: one + // empty scroll would silently disarm auto-fit on every axis. There is nothing to + // apply, so there is nothing to lay out or republish either. + if (e.dy == 0.0f && e.dx == 0.0f) return; - if (zoom_x) { - for (u32 i = 0; i < plot_->x_axis_count(); ++i) { - zoom_x_axis(i, x_zoom_anchor(i, e.x, e.y), factor); + if (e.dy != 0.0f) { + const f64 factor = zoom_factor(static_cast(e.dy)); + // The zoom is refused, and the whole event with it, when its factor could not + // be applied honestly: a factor of exactly one changes no range but would + // still clear `auto_scale` on the way through the setters, a non-finite one + // (a huge downward `dy` explodes `exp`) would hand a non-finite span to the + // setters, and a non-positive one is not a zoom at all. "Leaves the plot + // exactly as it was" is the rule for an unusable amount. + if (!std::isfinite(factor) || !(factor > 0.0) || factor == 1.0) return; + + const bool shift = has_mod(e.mods, Mod::Shift); + const bool ctrl = has_mod(e.mods, Mod::Ctrl); + // Shift alone restricts the zoom to x, ctrl alone to y; neither (or both) + // zooms both axes. The data coordinate under the pointer stays put. + const bool zoom_x = !(ctrl && !shift); + const bool zoom_y = !(shift && !ctrl); + + if (zoom_x) { + for (u32 i = 0; i < plot_->x_axis_count(); ++i) { + zoom_x_axis(i, x_zoom_anchor(i, e.x, e.y), factor); + } } - } - if (zoom_y) { - for (u32 i = 0; i < plot_->y_axis_count(); ++i) { - zoom_y_axis(i, plot_->y_transform(i).to_data_y(static_cast(e.y)), factor); + if (zoom_y) { + for (u32 i = 0; i < plot_->y_axis_count(); ++i) { + zoom_y_axis(i, plot_->y_transform(i).to_data_y(static_cast(e.y)), factor); + } } } + + // A horizontal wheel pans x only: `dx` notches, 5% of the x span each, in axis + // space (so a log axis scrolls multiplicatively, exactly as its transform maps). + // A positive `dx` scrolls right -- the view moves toward larger x, the same + // reading `WheelEvent`'s comment gives the field. On a live axis the range + // setter reads the shifted window as a span and an offset behind the edge, so + // scrolling right runs to the live edge and stops there, exactly as a drag pan + // does. + if (e.dx != 0.0f) shift_x_axes(static_cast(e.dx) * kPanPerNotch); + // The ranges moved under a pointer that did not, so the geometry under it changed: refresh // the layout -- the region rects the hit test reads were measured for the range this wheel // has just replaced -- and republish what is hot. A frame drawn right after a scroll then @@ -197,12 +246,79 @@ void Controller::on_wheel(const WheelEvent& raw) { publish_hot(); } +void Controller::on_gesture(const GestureEvent& raw) { + // The same refusal rule as the wheel: a frame carrying something that is not + // a number is not a gesture, and a frame that could not change anything -- + // a unit scale and no movement -- must not reach the range setters, which + // clear `auto_scale` for no visible change. `scale <= 0` is not a zoom. + if (!std::isfinite(raw.x) || !std::isfinite(raw.y) || !std::isfinite(raw.scale) || + !std::isfinite(raw.dx) || !std::isfinite(raw.dy)) { + return; + } + if (raw.scale <= 0.0f) return; + if (raw.scale == 1.0f && raw.dx == 0.0f && raw.dy == 0.0f) return; + + plot_->layout(); + + const f64 factor = 1.0 / static_cast(raw.scale); // pinch out = zoom in + if (std::isfinite(factor) && factor > 0.0 && factor != 1.0) { + // A pinch is a two-handed instrument, so it has no modifier semantics: it + // zooms both axes about the focal point, exactly as an unmodified wheel + // notch pair would -- same setters, same live-axis behaviour. + for (u32 i = 0; i < plot_->x_axis_count(); ++i) { + zoom_x_axis(i, x_zoom_anchor(i, raw.x, raw.y), factor); + } + for (u32 i = 0; i < plot_->y_axis_count(); ++i) { + zoom_y_axis(i, plot_->y_transform(i).to_data_y(static_cast(raw.y)), factor); + } + } + + // The focal point's movement is a two-finger pan: the same path a one-finger + // drag takes, deltas included, so a pinch-and-drag moves the data under the + // fingers on every axis the drag would move. + if (raw.dx != 0.0f || raw.dy != 0.0f) { + PointerEvent pan_e; + pan_e.x = raw.x; + pan_e.y = raw.y; + pan_e.dx = raw.dx; + pan_e.dy = raw.dy; + pan(pan_e); + } + + plot_->layout(); + if (has_pointer_) update_crosshair(pointer_x_, pointer_y_); + publish_hot(); +} + void Controller::on_key(const KeyEvent& e) { if (!e.down) return; // every action below is press-only + // Escape cancels the active drag (see cancel_drag). A key with no drag open -- + // and any other key -- changes nothing and returns below without touching the + // layout, the crosshair or the published handles. + if (e.key == kKeyEscape) { + if (mode_ != DragMode::None) cancel_drag(); + return; + } + + // The arrow keys pan by axis class: Left/Right every x axis, Up/Down every y + // axis, 10% of that axis' own span per press (and per repeat, which is why the + // adapters forward key repeat). An arrow says where to *look*: Left walks the + // window toward smaller x, Up toward larger y -- the screen-y inversion a drag's + // dy carries does not apply, because a drag says where the data goes and a key + // says where the viewer goes. + switch (e.key) { + case kKeyLeft: shift_x_axes(-kPanPerPress); break; + case kKeyRight: shift_x_axes(kPanPerPress); break; + case kKeyUp: shift_y_axes(kPanPerPress); break; + case kKeyDown: shift_y_axes(-kPanPerPress); break; + default: + break; + } + const bool pans = e.key >= kKeyLeft && e.key <= kKeyDown; const bool zooms = e.key == kKeyZoomIn || e.key == kKeyZoomOut; const bool fits = e.key == kKeyFit || e.key == kKeyFitUpper; - if (!zooms && !fits) return; // a key this controller has no gesture for + if (!pans && !zooms && !fits) return; // a key this controller has no gesture for if (zooms) { const f64 notches = e.key == kKeyZoomIn ? kKeyZoomNotches : -kKeyZoomNotches; @@ -219,7 +335,7 @@ void Controller::on_key(const KeyEvent& e) { const f64 hi = to_space(a.range.max, a.log); zoom_y_axis(i, from_space(0.5 * (lo + hi), a.log), factor); } - } else { + } else if (fits) { plot_->auto_fit_x(); for (u32 i = 0; i < plot_->y_axis_count(); ++i) plot_->auto_fit_y(i); } @@ -363,13 +479,33 @@ void Controller::begin_drag(const PointerEvent& e) { drag_cursor_ = kInvalid; drag_region_ = kInvalid; drag_zone_ = RegionGrab::None; + // What Escape will restore if this gesture is cancelled: the axes' state *now*, + // before the drag moves anything (see capture_ranges). + capture_ranges(); - // Shift first, and only on the left: it is a modifier gesture of its own, and a - // selection box is what it means even when it starts over a handle. - if (e.button == Button::Left && has_mod(e.mods, Mod::Shift)) { - mode_ = DragMode::BoxZoom; - selection_ = Rect{e.x, e.y, 0.0f, 0.0f}; - return; + // The modifier box zoom, on the left only: a selection box is what a modified + // left press means even when it starts over a handle. The mask is resolved once, + // here, from the modifier table -- Shift is the plain both-axis selection it has + // always been, Ctrl frames the x range only, Alt the y range only, and any other + // combo falls back to both -- and `apply_box_zoom()` reads it on release, per + // axis and for the click threshold alike. + if (e.button == Button::Left) { + const bool shift = has_mod(e.mods, Mod::Shift); + const bool ctrl = has_mod(e.mods, Mod::Ctrl); + const bool alt = has_mod(e.mods, Mod::Alt); + if (shift || ctrl || alt) { + mode_ = DragMode::BoxZoom; + // The masks: Ctrl alone frames the x range only, Alt alone the y range only, + // and every other combo (Shift included) frames both -- the field says whether + // the release *applies* that axis, so the axis named by the mask is the one + // that stays true. + const bool ctrl_only = ctrl && !shift && !alt; + const bool alt_only = alt && !shift && !ctrl; + box_zoom_x_ = !alt_only; + box_zoom_y_ = !ctrl_only; + selection_ = Rect{e.x, e.y, 0.0f, 0.0f}; + return; + } } if (e.button == Button::Middle) { const PlotLayout& lay = std::as_const(*plot_).layout(); @@ -472,10 +608,113 @@ void Controller::end_drag(const PointerEvent& e) { drag_cursor_ = kInvalid; drag_region_ = kInvalid; drag_zone_ = RegionGrab::None; + // The capture is the cancelled gesture's undo record, and this gesture was not + // cancelled: keeping it would let a later Escape restore a state that ended long + // ago (and `capture_ranges()` only runs at a press). + saved_x_.clear(); + saved_y_.clear(); } // --- gestures ------------------------------------------------------------- +void Controller::capture_ranges() { + saved_x_.clear(); + saved_y_.clear(); + for (u32 i = 0; i < plot_->x_axis_count(); ++i) { + const Axis& a = plot_->x_axis(i); + saved_x_.push_back(SavedX{a.range, a.offset, a.auto_scale}); + } + for (u32 i = 0; i < plot_->y_axis_count(); ++i) { + const Axis& a = plot_->y_axis(i); + saved_y_.push_back(SavedY{a.range, a.auto_scale}); + } +} + +void Controller::cancel_drag() { + // The ranges the axes held at `begin_drag`, through the plot's own setters: an + // `Off` axis gets its range and its auto-scale flag back exactly, and a live one + // gets its span and offset restated the way the setters read them (the offset is + // written back verbatim after the range, since the live edge of *this* moment -- + // not of the press -- is what the setter would otherwise derive it from). A + // cancelled gesture is as if it never ran: nothing is applied, no auto-scale is + // disarmed that was not already, and no box is framed. + for (u32 i = 0; i < plot_->x_axis_count() && i < saved_x_.size(); ++i) { + const SavedX& saved = saved_x_[i]; + plot_->set_x_range(i, saved.range); + plot_->set_x_offset(i, saved.offset); + plot_->set_x_auto_scale(i, saved.auto_scale); + } + for (u32 i = 0; i < plot_->y_axis_count() && i < saved_y_.size(); ++i) { + const SavedY& saved = saved_y_[i]; + plot_->set_y_range(i, saved.range); + plot_->set_y_auto_scale(i, saved.auto_scale); + } + // The gesture itself: no mode, no selection, nothing grabbed -- and the highlight + // the renderer draws follows, through the same tail every event that changes the + // geometry under the pointer runs. + mode_ = DragMode::None; + selection_ = Rect{}; + drag_cursor_ = kInvalid; + drag_region_ = kInvalid; + drag_zone_ = RegionGrab::None; + plot_->layout(); + if (has_pointer_) update_crosshair(pointer_x_, pointer_y_); + publish_hot(); +} + +void Controller::fit_all() { + plot_->auto_fit_x(); + for (u32 i = 0; i < plot_->y_axis_count(); ++i) plot_->auto_fit_y(i); + // A fit moved the geometry under a stationary pointer, exactly as a wheel notch + // does -- same tail, same reason (see on_wheel). + plot_->layout(); + if (has_pointer_) update_crosshair(pointer_x_, pointer_y_); + publish_hot(); +} + +bool Controller::double_click(const PointerEvent& e) { + const f32 radius = plot_->style().device(kDoubleClickPxLogical); + const f32 dx = e.x - click_x_; + const f32 dy = e.y - click_y_; + // Both presses must carry a host timestamp for the window to mean anything: a + // host that leaves `time_s` at 0 never produces a double-click (the field is + // optional, and two presses "0 s apart" would otherwise fire on any host). + const bool in_time = e.time_s > 0.0 && click_time_s_ > 0.0 && + e.time_s - click_time_s_ <= kDoubleClickSeconds; + const bool in_space = dx * dx + dy * dy <= radius * radius; + const bool is_double = + has_click_ && click_button_ == static_cast(e.button) && in_time && in_space; + // This press is what a third one would be measured against -- unless the two + // just made a double-click, in which case the record clears: a third press starts + // a fresh gesture rather than re-firing the reset. + has_click_ = !is_double; + click_button_ = static_cast(e.button); + click_time_s_ = e.time_s; + click_x_ = e.x; + click_y_ = e.y; + return is_double; +} + +void Controller::shift_x_axes(f64 fraction) { + for (u32 i = 0; i < plot_->x_axis_count(); ++i) { + const Axis& a = plot_->x_axis(i); + const f64 lo = to_space(a.range.min, a.log); + const f64 hi = to_space(a.range.max, a.log); + const f64 d = fraction * (hi - lo); // positive scrolls toward larger x + plot_->set_x_range(i, Range{from_space(lo + d, a.log), from_space(hi + d, a.log)}); + } +} + +void Controller::shift_y_axes(f64 fraction) { + for (u32 i = 0; i < plot_->y_axis_count(); ++i) { + const Axis& a = plot_->y_axis(i); + const f64 lo = to_space(a.range.min, a.log); + const f64 hi = to_space(a.range.max, a.log); + const f64 d = fraction * (hi - lo); // positive scrolls toward larger y + plot_->set_y_range(i, Range{from_space(lo + d, a.log), from_space(hi + d, a.log)}); + } +} + void Controller::pan(const PointerEvent& e) { // The transforms map a range onto layout().axes, so the pixel-to-data scale is // the range span over the axes rect -- not over the whole viewport. Using the @@ -510,12 +749,17 @@ void Controller::box_zoom_update(const PointerEvent& e) { void Controller::apply_box_zoom() { const Rect sel = selection_; - // A selection under a few *logical* pixels on either side is a click: no zoom. - // The selection is measured in device pixels like everything the controller is - // handed, so the threshold is converted at the plot's own scale -- through the one - // place that conversion lives, so a change to its fallback is picked up here too. + // A selection under a few *logical* pixels on the axes the mask will zoom is a + // click: no zoom. The threshold is masked with the gesture -- a Ctrl drag frames + // the x range from a selection however short it is vertically, and vice versa -- + // which is what makes a thin horizontal band a legal x-only box rather than a + // click no matter what the y says. The selection is measured in device pixels + // like everything the controller is handed, so the threshold is converted at the + // plot's own scale -- through the one place that conversion lives, so a change to + // its fallback is picked up here too. const f32 min_box = plot_->style().device(kMinBoxLogical); - if (sel.w < min_box || sel.h < min_box) return; + if (box_zoom_x_ && sel.w < min_box) return; + if (box_zoom_y_ && sel.h < min_box) return; // Clamped to the axes rect the transforms are built from, so a drag that left // the plot area cannot ask for data outside what the box covers on screen. @@ -528,17 +772,24 @@ void Controller::apply_box_zoom() { const f64 r = std::clamp(static_cast(sel.right()), ax, ar); const f64 t = std::clamp(static_cast(sel.y), ay, ab); const f64 b = std::clamp(static_cast(sel.bottom()), ay, ab); - if (!(r > l) || !(b > t)) return; + if (box_zoom_x_ && !(r > l)) return; + if (box_zoom_y_ && !(b > t)) return; // The ranges come straight from the transforms, so the box frames exactly the - // data under it; the selection's bottom edge is the range minimum on y. - for (u32 i = 0; i < plot_->x_axis_count(); ++i) { - const Transform tx = plot_->x_transform(i); - plot_->set_x_range(i, Range{tx.to_data_x(l), tx.to_data_x(r)}); + // data under it; the selection's bottom edge is the range minimum on y. Masked + // axes are left exactly as they were -- the release applies only what the press + // promised. + if (box_zoom_x_) { + for (u32 i = 0; i < plot_->x_axis_count(); ++i) { + const Transform tx = plot_->x_transform(i); + plot_->set_x_range(i, Range{tx.to_data_x(l), tx.to_data_x(r)}); + } } - for (u32 i = 0; i < plot_->y_axis_count(); ++i) { - const Transform ty = plot_->y_transform(i); - plot_->set_y_range(i, Range{ty.to_data_y(b), ty.to_data_y(t)}); + if (box_zoom_y_) { + for (u32 i = 0; i < plot_->y_axis_count(); ++i) { + const Transform ty = plot_->y_transform(i); + plot_->set_y_range(i, Range{ty.to_data_y(b), ty.to_data_y(t)}); + } } } @@ -621,7 +872,12 @@ void Controller::axis_zoom(const PointerEvent& e) { if (!(scale > 0.0) || !std::isfinite(scale)) return; const f64 logical_dy = static_cast(e.dy) / scale; const f64 factor = std::exp(-logical_dy * kZoomPerPixel); - if (!(factor > 0.0)) return; + // The same degenerate factors the wheel refuses: a factor of exactly one (a + // motion that reports no dy -- running it would still clear `auto_scale` on its + // way through the range setters), a non-finite one (an enormous dy explodes + // `exp` into a span the setters cannot store), and a non-positive one, which is + // not a zoom at all. + if (!std::isfinite(factor) || !(factor > 0.0) || factor == 1.0) return; if (mode_ == DragMode::AxisZoomX) { for (u32 i = 0; i < plot_->x_axis_count(); ++i) { zoom_x_axis(i, x_zoom_anchor(i, e.x, e.y), factor); diff --git a/src/plot/figure.cpp b/src/plot/figure.cpp index 0faec06..dff188a 100644 --- a/src/plot/figure.cpp +++ b/src/plot/figure.cpp @@ -22,6 +22,22 @@ namespace { /// wants a taller floor gives the figure a taller viewport, or gives the rows weights. constexpr f32 kMinRowHeightLogical = 24.0f; +/// A coordinate in the shared axis' **axis space**: `log10` when the figure's one +/// x axis -- the bottom row's, the row the shared strip belongs to -- is +/// logarithmic, the value itself otherwise. A following group's span and offset +/// live in this space exactly as a single `Live` axis' do (see `Axis::offset`), so +/// a log group scrolls decades and holds decades; a *linear* span there would +/// rescale every row's view as the live edge moved. The bottom row rules the +/// space because it owns the shared axis; a group whose members disagree about +/// `log` still gets one data-space range pushed into every member, computed in +/// that row's space. +f64 to_link_space(f64 v, bool log) noexcept { + return log ? Transform::log10_positive(v) : v; +} +f64 from_link_space(f64 v, bool log) noexcept { + return log ? std::pow(10.0, v) : v; +} + } // namespace Figure::Figure(u32 rows) : plots_(rows) {} @@ -151,9 +167,11 @@ Status Figure::set_x_link_range(u32 group, Range r) { // Under a following group the argument is not a pair of positions: it states the // span the window should have and, through its maximum, where the window sits // behind the live edge -- the same reinterpretation `Plot::set_x_range()` makes - // on a `Live` axis, applied to the group's own edge. A span that is not a span is - // refused rather than stored, because a following group has nothing to place a - // window from otherwise. + // on a `Live` axis, applied to the group's own edge. Both the span and the + // offset are taken in the shared axis' **axis space** (log10 decades on a log + // group), which is what makes the window hold decades instead of a decaying + // linear width. A span that is not a span is refused rather than stored, + // because a following group has nothing to place a window from otherwise. const f64 lo = std::min(r.min, r.max); const f64 hi = std::max(r.min, r.max); if (!(hi > lo)) { @@ -161,8 +179,9 @@ Status Figure::set_x_link_range(u32 group, Range r) { "a following group's range states the window's span, so its bounds " "must differ"}; } - g.span = hi - lo; - g.offset = live.max - hi; + const bool log = !plots_.empty() && plots_.back().x_axis(0u).log; + g.span = to_link_space(hi, log) - to_link_space(lo, log); + g.offset = to_link_space(live.max, log) - to_link_space(hi, log); g.has_range = true; place_live_window(g); push_link(g); @@ -249,18 +268,26 @@ bool Figure::place_live_window(XLink& group) { if (!live_edge(group, live)) return false; // The span is the group's own -- what the user zoomed to, or what the last range // it was given stated -- and the follow window only moves it: the span is what - // makes this a scroll rather than a rescale. A span that is not a span is nothing + // makes this a scroll rather than a rescale. Both it and the offset are in the + // shared axis' **axis space** (see `to_link_space`), so a log group holds its + // visible decades whatever the edge does. A span that is not a span is nothing // to place a window from, so the group's range is left exactly as it is. if (!std::isfinite(group.span) || !(group.span > 0.0)) return false; if (!std::isfinite(group.offset)) group.offset = 0.0; - // The offset can outlive the window it names: a ring that dropped samples moves the - // oldest x forward under it, so it is clamped against what the members hold *now*. - // An offset past that would put the window's right edge behind the oldest sample - // left, i.e. over data that is gone. - group.offset = std::clamp(group.offset, 0.0, std::max(0.0, live.max - live.min)); + // The offset can outlive the window it names: a ring that dropped samples moves + // the oldest x forward under it, so it is clamped against what the members hold + // *now* -- measured in the same space, or a log group could hold an offset larger + // than everything it retains. An offset past that would put the window's right + // edge behind the oldest sample left, i.e. over data that is gone. + const bool log = !plots_.empty() && plots_.back().x_axis(0u).log; + const f64 newest_space = to_link_space(live.max, log); + const f64 retained = + std::max(0.0, newest_space - to_link_space(live.min, log)); + group.offset = std::clamp(group.offset, 0.0, retained); group.live_newest = live.max; group.has_live = true; - group.range = Range{live.max - group.offset - group.span, live.max - group.offset}; + const f64 right = newest_space - group.offset; + group.range = Range{from_link_space(right - group.span, log), from_link_space(right, log)}; return true; } @@ -300,15 +327,20 @@ void Figure::sync_x_links() { // A group that was changed from outside restates its scroll state the way a // single `Live` axis does -- the new range's span, and the offset its maximum // implies -- and against the group's own edge, not the edge of the row the - // change arrived on. Then the window is recomputed from the live edge, which is + // change arrived on. Both are read in the shared axis' **axis space** (the + // same space `set_x_link_range()` and `place_live_window()` state them in), + // so a member's pan or zoom on a log group states decades like everything + // else on it. Then the window is recomputed from the live edge, which is // the whole of what following is. if (adopted) { Range live{}; if (live_edge(group, live)) { - const f64 span = std::max(0.0, group.range.max - group.range.min); + const bool log = !plots_.empty() && plots_.back().x_axis(0u).log; + const f64 span = std::max(0.0, to_link_space(group.range.max, log) - + to_link_space(group.range.min, log)); if (span > 0.0) { group.span = span; - group.offset = live.max - group.range.max; + group.offset = to_link_space(live.max, log) - to_link_space(group.range.max, log); } } } diff --git a/src/plot/plot.cpp b/src/plot/plot.cpp index 2340682..0ac00c9 100644 --- a/src/plot/plot.cpp +++ b/src/plot/plot.cpp @@ -31,41 +31,69 @@ const Axis& default_axis() noexcept { return axis; } +/// An axis coordinate in the space the live follow arithmetic runs in: log10 when +/// the axis is logarithmic, the value itself otherwise -- the same space the +/// controller's `to_space`/`from_space` gestures run in, and the space `Axis::offset` +/// and a `Live` window's span are stated in. A log axis therefore *scrolls* decades +/// and holds decades under follow: a linear span on a log axis would rescale the +/// view on every frame (the defect this fixes). +f64 to_axis_space(f64 v, bool log) noexcept { + return log ? Transform::log10_positive(v) : v; +} +f64 from_axis_space(f64 v, bool log) noexcept { + return log ? std::pow(10.0, v) : v; +} + /// The sample range a data interval covers: `[begin, end)` over the samples whose /// x lies inside `[lo, hi]`, both ends included -- exactly the samples a region's -/// statistics are over. The nearest-sample snap seeds the walk and the two -/// corrections after it move by the one or two samples a rounded index can be off -/// by, so which side of a bound a sample falls on is decided by the same doubles -/// the series carries rather than by a division that is exact only on a grid that -/// is. Still O(1): the seed is never more than half a sample from the answer. +/// statistics are over. The brackets come from `Series::neighbours()`, which names +/// the samples at or either side of a bound *by the series' own x coordinates* +/// (`x_at()`), so a series whose samples are not on a uniform grid -- an XY series +/// -- is covered exactly where its samples actually are, not where a `dx()` grid +/// would have put them. Still O(1): two neighbour lookups. /// /// False when the interval holds no sample at all, which is a real case: an /// interval between two adjacent samples on a sparse series, one entirely before or -/// after the window, a non-finite bound, and a series with no usable `dx()`. +/// after the window, and a non-finite bound. bool covered_samples(const data::Series& series, f64 lo, f64 hi, u64& begin, u64& end) { const u64 n = series.size(); - const f64 x0 = series.x0(); - const f64 dx = series.dx(); - if (n == 0u || !(dx > 0.0) || !std::isfinite(lo) || !std::isfinite(hi) || !(lo <= hi)) { - return false; + if (n == 0u || !std::isfinite(lo) || !std::isfinite(hi) || !(lo <= hi)) return false; + + // The first sample at or after `lo`: the right neighbour of the bound, unless the + // bound lands on a sample -- which is then itself the first one inside. A bound + // outside the window covers everything on its side: before the oldest sample the + // coverage starts at 0, after the newest there is nothing inside. + u64 i0 = 0; + u64 i1 = 0; + if (!series.neighbours(lo, i0, i1)) { + if (series.x_at(0u) > lo) { + i1 = 0u; + } else { + return false; + } + } else if (series.x_at(i0) >= lo) { + i1 = i0; } - const auto x_of = [x0, dx](u64 i) { return x0 + static_cast(i) * dx; }; + const u64 b = i1; - // The first sample at or after `lo`: walk left while the one before is also - // inside, then right while this one is not yet. - u64 b = series.pyramid().snap(lo, x0, dx); - while (b > 0u && x_of(b - 1u) >= lo) --b; - while (b < n && x_of(b) < lo) ++b; + // One past the last sample at or before `hi`, from the same lookup: the left + // neighbour of the bound, or the sample the bound landed on. + u64 j0 = 0; + u64 j1 = 0; + if (!series.neighbours(hi, j0, j1)) { + if (series.x_at(n - 1u) < hi) { + j0 = n - 1u; + } else { + return false; + } + } else if (series.x_at(j1) <= hi) { + j0 = j1; + } + const u64 last = j0; - // One past the last sample at or before `hi`, from the same seed. - const u64 seed = series.pyramid().snap(hi, x0, dx); - u64 e = seed == n - 1u ? n : seed + 1u; - while (e < n && x_of(e) <= hi) ++e; - while (e > 0u && x_of(e - 1u) > hi) --e; - - if (e <= b) return false; + if (last < b) return false; // the interval falls between two adjacent samples begin = b; - end = e; + end = last + 1u; return true; } @@ -228,6 +256,12 @@ u32 Plot::add_trace(const TraceDesc& desc) { trace.axis_x = desc.axis_x; trace.axis_y = desc.axis_y; trace.name = desc.name; + trace.mode = desc.mode; + trace.dash_on = desc.dash_on; + trace.dash_off = desc.dash_off; + trace.marker = desc.marker; + trace.marker_size_px = desc.marker_size_px; + trace.marker_every = desc.marker_every; traces_.push_back(trace); mark_measures_dirty(); return static_cast(traces_.size() - 1); @@ -363,15 +397,27 @@ void Plot::set_x_range(u32 axis, Range r) { // normalised; a span that is not a span is ignored outright, leaving the span // and offset the axis holds. `auto_fit_x()` is how a window is framed either // way. + // + // The offset is taken in **axis space** (log10 when the axis is logarithmic): + // "how far behind the live edge" is a distance the axis' own transform measures, + // so a log axis states it in decades and the window that follows from it holds + // decades, not a decaying linear width. The span itself is read back from the + // stored range by place_live_x_window(), in the same space. const f64 lo = std::min(r.min, r.max); const f64 hi = std::max(r.min, r.max); if (!std::isfinite(lo) || !std::isfinite(hi) || !(hi > lo)) return; a.range = Range{lo, hi}; - a.offset = live.max - hi; + a.offset = to_axis_space(live.max, a.log) - to_axis_space(hi, a.log); place_live_x_window(axis); a.auto_scale = false; // as ever: a range a caller chose is not an auto-fit return; } + // A bound that is not a number is not a range: storing one would hand the + // transforms a NaN (every mapping of it is NaN) and clear `auto_scale`, the one + // way back -- so the call is refused outright and the axis keeps the range *and* + // the auto-scale state it had. The same rule `set_region()` applies to a region's + // bounds, and for the same reason. + if (!std::isfinite(r.min) || !std::isfinite(r.max)) return; // Off, or Live with nothing streaming to follow: the range is stored verbatim, // which is what every caller of this setter got before follow existed. a.range = r; @@ -380,6 +426,9 @@ void Plot::set_x_range(u32 axis, Range r) { void Plot::set_y_range(u32 axis, Range r) { if (axis >= y_axes_.size()) return; + // The x setter's refusal, on the y side: a non-finite bound would leave the axis + // with no range to draw and no auto-scale to recover with. + if (!std::isfinite(r.min) || !std::isfinite(r.max)) return; y_axes_[axis].range = r; y_axes_[axis].auto_scale = false; } @@ -421,7 +470,10 @@ XFollow Plot::x_follow(u32 axis) const { void Plot::set_x_offset(u32 axis, f64 units_behind_live) { if (axis >= x_axes_.size()) return; // No such offset: a NaN would place a window that is not a window, and the last - // usable one is a better answer than that for a value no gesture can undo. + // usable one is a better answer than that for a value no gesture can undo. The + // units are **axis space** ones -- data units on a linear axis, decades on a log + // one -- matching how `set_x_range()` derives the offset and how + // `place_live_x_window()` spends it. if (!std::isfinite(units_behind_live)) return; x_axes_[axis].offset = std::max(0.0, units_behind_live); // The upper end of the clamp is what the traces still retain, which only they @@ -489,19 +541,30 @@ bool Plot::place_live_x_window(u32 axis) { if (a.follow != XFollow::Live) return false; Range live{}; if (!live_x_bounds(axis, live)) return false; - // The span is the axis' own -- what the user zoomed to, or what the last fit - // produced -- and the follow window only moves it: the span is what makes this a - // scroll rather than a rescale. A span that is not a span is nothing to place a - // window from, so the range is left exactly as it is. - const f64 span = a.range.span(); + // The span and the offset are distances in **axis space** -- log10 when the axis + // is logarithmic -- the space the controller's gestures are computed in. The span + // is the axis' own -- what the user zoomed to, or what the last fit produced -- + // and the follow window only moves it: the span is what makes this a scroll + // rather than a rescale. On a log axis a *linear* span would rescale the view + // once the newest sample moved: ten units of span at 1e0 is ten decades, at 1e9 + // it is nothing -- while the log-space span holds the visible decades constant + // whatever the edge does. A span that is not a span is nothing to place a window + // from, so the range is left exactly as it is. + const f64 hi_space = to_axis_space(a.range.max, a.log); + const f64 lo_space = to_axis_space(a.range.min, a.log); + const f64 span = hi_space - lo_space; if (!std::isfinite(span) || !(span > 0.0)) return false; // The offset can outlive the window it names: a ring that dropped samples moves // the oldest x forward under it, so it is clamped against what the traces hold - // *now* -- an offset past that would put the window's right edge behind the - // oldest sample left, i.e. over data that is gone. - const f64 retained = std::max(0.0, live.max - live.min); + // *now* -- an offset past that would put the window's right edge behind the oldest + // sample left, i.e. over data that is gone. In axis space, like the span: the + // retained window's length is measured by the same ruler that measures the + // offset, or a log axis could hold an offset larger than everything it retains. + const f64 newest_space = to_axis_space(live.max, a.log); + const f64 retained = std::max(0.0, newest_space - to_axis_space(live.min, a.log)); a.offset = std::clamp(a.offset, 0.0, retained); - a.range = Range{live.max - a.offset - span, live.max - a.offset}; + const f64 right = newest_space - a.offset; + a.range = Range{from_axis_space(right - span, a.log), from_axis_space(right, a.log)}; return true; } @@ -669,11 +732,26 @@ const Measurement& Plot::measurement(u32 id) const { slot.result = Measurement{}; if (series != nullptr && samples > 0 && slot.cursor_a < cursors_.size() && slot.cursor_b < cursors_.size()) { - const u64 a = series->pyramid().snap(cursors_[slot.cursor_a].x, series->x0(), series->dx()); - const u64 b = series->pyramid().snap(cursors_[slot.cursor_b].x, series->x0(), series->dx()); - // snap() clamps to [0, size-1], so [a, b+1) is inside the series whenever - // the cursor pair is not inverted. An inverted pair is not an interval. - if (a <= b) slot.result = Measure::evaluate(*series, a, b + 1); + const Cursor& ca = cursors_[slot.cursor_a]; + const Cursor& cb = cursors_[slot.cursor_b]; + // R8: a measurement is read from two *x* coordinates, and a horizontal ruler + // has no x -- it is the dead field of its orientation (see set_cursor_x()). + // Evaluating the pair anyway would silently measure from x=0 and report + // numbers about nothing, so the result stays invalid instead; the id the + // caller holds is real, and `valid == false` is its answer. + if (!ca.is_horizontal() && !cb.is_horizontal()) { + // The cursors land on samples through the series' own lookups + // (`snap_index` clamps like the old pyramid snap did, and `x_at()` is + // where the sample really is), so an XY series measures between the + // samples its own x array names rather than between indices a uniform + // `dx()` guesses. + const u64 a = series->snap_index(ca.x); + const u64 b = series->snap_index(cb.x); + // snap_index() clamps to [0, size-1], so [a, b+1) is inside the series + // whenever the cursor pair is not inverted. An inverted pair is not an + // interval. + if (a <= b) slot.result = Measure::evaluate(*series, a, b + 1); + } } slot.last_series = series; slot.last_size = samples; @@ -821,10 +899,22 @@ MeasureSpan Plot::measure_span(u32 id) const noexcept { if (id >= measures_.size()) return MeasureSpan{}; const MeasureSlot& slot = measures_[id]; if (slot.cursor_a >= cursors_.size() || slot.cursor_b >= cursors_.size()) return MeasureSpan{}; + // A band is drawn between two *x* coordinates, and a horizontal ruler has none + // (the same dead field `measurement()` refuses under R8): the span says invalid + // rather than naming a band one edge of which sits at x=0. + if (cursors_[slot.cursor_a].is_horizontal() || cursors_[slot.cursor_b].is_horizontal()) { + return MeasureSpan{}; + } return MeasureSpan{slot.cursor_a, slot.cursor_b, true}; } const PlotLayout& Plot::layout() { + // Belt and braces for R1: `set_viewport()` normalises a hostile rect, but every + // `std::clamp` below needs `lo <= hi` to be defined, so the dimensions the chrome + // is clamped against are read non-negative here too -- layout() is on the path of + // every host, every frame, and must be undefined nowhere. + const f32 vw = viewport_.w > 0.0f ? viewport_.w : 0.0f; + const f32 vh = viewport_.h > 0.0f ? viewport_.h : 0.0f; // Live x windows are placed first, so everything below that derives from a range // -- the ticks, the grid, the transforms the controller hit-tests through, and the // range a caller reads back -- describes the live edge of *this* frame rather than @@ -883,10 +973,10 @@ const PlotLayout& Plot::layout() { // left of the allowance -- which is what keeps axes.w and axes.h positive // however long the labels and titles are. `viewport_` is what a huge label set // is clamped to first, so neither strip can push the rect outside it. - y_width = std::clamp(y_width, 0.0f, viewport_.w); - x_height = std::clamp(x_height, 0.0f, viewport_.h); - const f32 y_budget = (1.0f - kMinAxesShare) * viewport_.w; - const f32 x_budget = (1.0f - kMinAxesShare) * viewport_.h; + y_width = std::clamp(y_width, 0.0f, vw); + x_height = std::clamp(x_height, 0.0f, vh); + const f32 y_budget = (1.0f - kMinAxesShare) * vw; + const f32 x_budget = (1.0f - kMinAxesShare) * vh; // The figure's common gutter, when one was imposed: a floor on `y_width + y_title_w` // so every row's axes starts at the same x. It is served by the *label* strip -- the // title column is one text line per titled axis and cannot be widened to fill a gutter @@ -903,8 +993,8 @@ const PlotLayout& Plot::layout() { layout_.y_title_width = y_title_width; layout_.x_title_height = x_title_height; layout_.axes = Rect{viewport_.x + y_width + y_title_width, viewport_.y, - viewport_.w - (y_width + y_title_width), - viewport_.h - (x_height + x_title_height)}; + vw - (y_width + y_title_width), + vh - (x_height + x_title_height)}; layout_.y_title_area = Rect{viewport_.x, layout_.axes.y, y_title_width, layout_.axes.h}; layout_.y_label_area = Rect{layout_.y_title_area.right(), layout_.axes.y, y_width, layout_.axes.h}; @@ -917,11 +1007,11 @@ const PlotLayout& Plot::layout() { if (text_ == nullptr || pass + 1 >= kMaxLayoutPasses) break; // The same caps as above, so this compares like with like and the loop still // reaches its fixed point on the second pass. - const f32 need_y = std::min(std::clamp(y_label_width(), 0.0f, viewport_.w), y_budget); + const f32 need_y = std::min(std::clamp(y_label_width(), 0.0f, vw), y_budget); // Overridden, not merely unmeasured: the strip is not reserved, so the fixed // point has to be zero for a row that gave its strip away. const f32 need_x = - x_labels ? std::min(std::clamp(text_->height() + 2.0f * pad, 0.0f, viewport_.h), x_budget) + x_labels ? std::min(std::clamp(text_->height() + 2.0f * pad, 0.0f, vh), x_budget) : 0.0f; // What the strip *wants* is the label's own width or the figure's floor, whichever is // greater -- the floor is a maximum over sources, settled by one `max` and not by @@ -987,8 +1077,10 @@ const data::Series* Plot::snap_series(u32 axis_x) const { f64 Plot::snapped_x(f64 x, u32 axis_x) const { const data::Series* s = snap_series(axis_x); if (s == nullptr) return x; - const u64 i = s->pyramid().snap(x, s->x0(), s->dx()); - return s->x0() + static_cast(i) * s->dx(); + // Through the series' own index arithmetic (`snap_index`/`x_at`), so a series + // whose samples are not on a uniform grid snaps to the sample that is really + // nearest, not to where a `dx()` grid would have put one. + return s->x_at(s->snap_index(x)); } f64 Plot::snapped_x(const Cursor& c) const { @@ -1211,52 +1303,59 @@ void Plot::build_regions() { CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept { CrosshairSpan span; const u64 n = series.size(); - const f64 x0 = series.x0(); - const f64 dx = series.dx(); - // No samples, no grid, no finite origin: nothing to interpolate between, and `index` - // below would be a division by zero or a NaN -- whose floor is undefined -- rather than a - // sample index. `!(dx > 0.0)` is also the NaN test for `dx`. - if (n == 0u || !(dx > 0.0) || !std::isfinite(x0) || !std::isfinite(x)) return span; + // No samples and no finite query: nothing to interpolate between. + if (n == 0u || !std::isfinite(x)) return span; - // The window is closed at both ends -- the oldest and the newest sample are samples too -- - // and a query outside it has no neighbours to interpolate between. Answering with the - // nearest sample's value instead is the clamped lie this refuses: the readout would report - // a number as if it were under the pointer when the series does not reach that far. - const f64 newest = x0 + static_cast(n - 1u) * dx; - if (x < x0 || x > newest) return span; + // The bracketing samples, straight from the series (`neighbours()`/`x_at()`): + // the window is closed at both ends -- the oldest and the newest sample are + // samples too -- and a query outside it has no neighbours to interpolate + // between. Answering with the nearest sample's value instead is the clamped lie + // this refuses: the readout would report a number as if it were under the + // pointer when the series does not reach that far. The series owns where its + // samples are, so an XY series is bracketed by its own x array exactly, where + // the old uniform-grid index arithmetic would have read samples that do not + // exist at the positions it assumed. + u64 i0 = 0; + u64 i1 = 0; + if (!series.neighbours(x, i0, i1)) return span; - // Index arithmetic, not a search: the grid is uniform, so the sample at or before `x` is - // `floor((x - x0) / dx)` and there is nothing to look up. The two bounds are checked - // *before* the cast, which is also what keeps it defined: `index` is a quotient of two - // caller-supplied doubles and can overflow to infinity for an extreme origin and spacing, - // and casting that to `u64` is undefined. `!(index > 0.0)` is the NaN test as well. - const f64 index = (x - x0) / dx; - if (!(index > 0.0)) { - span.begin = span.end = 0u; // the oldest sample, or a rounding step below it + // A query that landed **on** a sample is a span of one, whichever side the + // series' bracket reported: a sample at the query is the answer, and the two + // ends of the window are samples too -- a bracket of (0, 1) at a query sitting + // on sample 0 would read as an interpolation with no width. Decided on the + // samples' own coordinates, so it holds for any `neighbours()` implementation. + if (series.x_at(i0) == x) { + span.begin = span.end = i0; span.t = 0.0; span.valid = true; return span; } - if (index >= static_cast(n - 1u)) { - span.begin = span.end = n - 1u; // the newest sample, or a step past it + if (series.x_at(i1) == x) { + span.begin = span.end = i1; span.t = 0.0; span.valid = true; return span; } - // `index` is finite and inside `(0, n-1)`, so truncating it is a floor on a value a `u64` - // holds. Two rounding cases keep the span honest at a sample: a `t` that lands on 0 is the - // query sitting *on* sample `i`, and one that rounds up to 1.0 is it sitting on the sample - // after it -- neither is an interpolation with no width. - const u64 i = static_cast(index); - const f64 t = index - static_cast(i); - if (!(t > 0.0)) { - span.begin = span.end = i; - } else if (t >= 1.0) { - span.begin = span.end = i + 1u; + + if (i0 == i1) { + // The series bracketed a single sample for a query that is not on it: nothing + // to interpolate with, and the sample is the honest answer. + span.begin = span.end = i0; + span.t = 0.0; } else { - span.begin = i; - span.end = i + 1u; - span.t = t; + span.begin = i0; + span.end = i1; + // `t` from the samples' own x coordinates, so the interpolation is exact on + // any grid, uniform or not. A degenerate pair (two samples sharing an x) + // interpolates nothing and reads the earlier sample. + const f64 xa = series.x_at(i0); + const f64 xb = series.x_at(i1); + span.t = xb > xa ? (x - xa) / (xb - xa) : 0.0; + if (!(span.t > 0.0)) { + span.t = 0.0; // a rounding step below the earlier sample is the sample + } else if (span.t > 1.0) { + span.t = 1.0; + } } span.valid = true; return span; diff --git a/src/plot/ticks.cpp b/src/plot/ticks.cpp index b6ea165..437b295 100644 --- a/src/plot/ticks.cpp +++ b/src/plot/ticks.cpp @@ -167,15 +167,51 @@ void generate_ticks(Range range, f32 px, f32 min_px, std::vector& out, boo const f64 minor_step = step / minor_ratio; if (!std::isfinite(minor_step) || !(minor_step > 0.0)) return; - const f64 stride = include_minors ? minor_step : step; + // Minors subdivide the major step an exact integer number of times, so the + // major/minor split is an *integer-index* test: a tick is a major when its + // minor index, counted from `first_major`, is a multiple of the ratio. Both + // operands of the index are exact for the windows axes actually show -- + // `v - first_major` is the difference of two near doubles (Sterbenz-exact), and + // rounding the small quotient lands on the index the grid defines -- so no float + // tolerance is needed and none is applied. The old `|v - round(v/step)*step| <= + // step*1e-9` test fought a ULP-sized residue that an epoch origin produces + // whenever the step is not a power of two (v/step carries a rounding error of a + // few ULP of 1.75e9, i.e. far more than step*1e-9) and misclassified its majors; + // the index here has no residue to fight. + const auto minor_index_is_major = [&](f64 v) { + const f64 index = std::round((v - first_major) / minor_step); + return std::fmod(index, minor_ratio) == 0.0; + }; + + // Mutable: once the minors are capped the walk steps the major stride (see the + // cap branch below). + f64 stride = include_minors ? minor_step : step; const f64 first = include_minors ? std::ceil(lo / minor_step) * minor_step : first_major; if (!std::isfinite(first)) return; char label[kLabelBuf]; + usize minors = 0; for (f64 v = first; v <= hi + stride * 1e-9;) { - if (out.size() >= kMaxTicks) break; - const bool major = !include_minors || - std::fabs(v - std::round(v / step) * step) <= step * 1e-9; + const bool major = !include_minors || minor_index_is_major(v); + if (!major && minors >= kMaxTicks) { + // The cap counts minors only, and the majors are never truncated: an axis + // dense enough to want more subdivisions than fit loses its *subdivisions*, + // not its labels, so the far end of the axis keeps its numbers. From here on + // the walk steps the major stride; the next major at or after `v` is + // re-seated onto the major grid explicitly, because continuing from an + // off-grid minor with the new stride would visit only off-grid points and + // silently drop every remaining label. `minors >= kMaxTicks` is also the + // guard that keeps a hostile request (px towering over min_px) from stepping + // a minor stride across the whole range after the cap; the majors-only walk + // is as long as the caller's own pixel count asked it to be. + stride = step; + const f64 major_index = std::ceil((v - first_major) / step - 1e-9); + f64 reseat = first_major + major_index * step; + if (reseat < v) reseat = first_major + (major_index + 1.0) * step; + v = reseat; + continue; + } + if (!major) ++minors; Tick tick; tick.value = v; tick.major = major; diff --git a/src/render/primitives.cpp b/src/render/primitives.cpp index ea92f22..d103256 100644 --- a/src/render/primitives.cpp +++ b/src/render/primitives.cpp @@ -93,7 +93,7 @@ void TextBatch::set_cell(Vec2 cell_uv) noexcept { cell_declared_ = true; } -void TextBatch::add_cell(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 unit_ux) { +void TextBatch::add_cell(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 unit_ux, Color color) { if (!cell_declared_) { // Nobody declared a cell: a caller staging raw glyphs, where the glyph's own // rect is the only authority there is. `add_text` and the renderer both @@ -120,14 +120,18 @@ void TextBatch::add_cell(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 unit_ux) v_.push_back(glyph.v0); v_.push_back(unit_ux.x); v_.push_back(unit_ux.y); + v_.push_back(color.r); + v_.push_back(color.g); + v_.push_back(color.b); + v_.push_back(color.a); } -void TextBatch::add_glyph(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 axis) { - add_cell(pen_px, glyph, px, unit_axis(axis)); +void TextBatch::add_glyph(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 axis, Color color) { + add_cell(pen_px, glyph, px, unit_axis(axis), color); } Vec2 TextBatch::add_text(const char* utf8, Vec2 pen_px, f32 px, const TextBackend& font, - Vec2 axis) { + Vec2 axis, Color color) { if (utf8 == nullptr) { return pen_px; } @@ -145,7 +149,7 @@ Vec2 TextBatch::add_text(const char* utf8, Vec2 pen_px, f32 px, const TextBacken if (glyph == nullptr) { continue; // no artwork for this codepoint: nothing to draw, nothing to advance } - add_cell(pen_px, *glyph, px, ux); + add_cell(pen_px, *glyph, px, ux, color); pen_px = pen_px + ux * (glyph->advance * px); } return pen_px; diff --git a/src/render/program.cpp b/src/render/program.cpp index 57abae8..e0f3cd5 100644 --- a/src/render/program.cpp +++ b/src/render/program.cpp @@ -32,6 +32,8 @@ void LineProgram::set_dash(Vec2 on_off) const noexcept { void LineProgram::set_color(Color color) const noexcept { program_.set_uniform("u_color", color); } +void LineProgram::set_aa(f32 px) const noexcept { program_.set_uniform("u_aa", px); } + bool TextProgram::create(Status* st) noexcept { program_ = gl::Program::from_source(kTextVS, kTextFS, st); if (program_.id() == 0) { @@ -54,6 +56,4 @@ void TextProgram::set_texture_size(Vec2 texels) const noexcept { program_.set_uniform("u_tex_size", texels); } -void TextProgram::set_color(Color color) const noexcept { program_.set_uniform("u_color", color); } - } // namespace plot::render diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index cab991f..f44769e 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -36,10 +36,11 @@ bool fail(Status* st, ErrorCode code, const char* message) noexcept { /// `math::Transform::to_screen_*` applies before scaling. A non-positive value on /// a log axis takes the transform's own clamp, so it lands off-scale rather than /// as NaN. -Vec2 pre_affine(f64 x, f64 y, bool log_x, bool log_y) noexcept { - return Vec2{static_cast(log_x ? Transform::log10_positive(x) : x), - static_cast(log_y ? Transform::log10_positive(y) : y)}; -} +/// One axis' pre-affine coordinate: the value itself on a linear axis, `log10` +/// of it on a log one -- exactly what `math::Transform::to_screen_*` applies +/// before scaling. A non-positive value on a log axis takes the transform's own +/// clamp, so it lands off-scale rather than as NaN. +f64 space(f64 v, bool log) noexcept { return log ? Transform::log10_positive(v) : v; } /// Sample index for a (possibly fractional, possibly non-finite) position in a /// series' index space, clamped to [0, n]: NaN and -inf to 0, +inf to n, so a @@ -190,6 +191,31 @@ u32 Renderer::AxisFont::revision() const { return font_ != nullptr ? font_->revi // ------------------------------------------------------------------ init +Renderer::~Renderer() { + // The teardown contract (see renderer.hpp): the handles go while the context + // that owns their names is still current -- which it is, for a renderer + // destroyed before its context. + release_gl(); +} + +void Renderer::release_gl() noexcept { + ready_ = false; + // Assigning fresh objects releases every GL name through the handles' + // destructors: both programs, both vertex arrays and their instance buffers, + // and the atlas texture. Safe to call more than once; every assignment is a + // no-op while the renderer is uninitialised. + atlas_ = render::TextAtlas{}; + line_program_ = render::LineProgram{}; + text_program_ = render::TextProgram{}; + line_vao_ = render::LineVertexArray{}; + text_vao_ = render::TextVertexArray{}; + lines_.clear(); + glyphs_under_.clear(); + glyphs_over_.clear(); +} + +// ------------------------------------------------------------------ init + void Renderer::set_text_backend(const render::TextBackend* font) noexcept { const render::TextBackend* next = font != nullptr ? font : &default_font_; if (next == font_) { @@ -251,7 +277,8 @@ void Renderer::begin_frame(u32 fb_w, u32 fb_h, Rect viewport_px) noexcept { fb_h_ = fb_h; viewport_ = viewport_px; lines_.clear(); - glyphs_.clear(); + glyphs_under_.clear(); + glyphs_over_.clear(); stats_ = FrameStats{}; gl_error_ = false; @@ -307,7 +334,14 @@ void Renderer::begin_frame(u32 fb_w, u32 fb_h, Rect viewport_px) noexcept { // The pipelines write straight alpha and rely on blending being on; setting it // per frame costs nothing and makes the renderer independent of what the host - // left behind. + // left behind. What the host had is saved first (R6) so `end_frame()` can hand + // the function and the enable bit back instead of leaving the frame's choice + // behind for the host's own draws to inherit. + gl::glGetIntegerv(gl::GL_BLEND_SRC_RGB, &blend_src_rgb_); + gl::glGetIntegerv(gl::GL_BLEND_SRC_ALPHA, &blend_src_a_); + gl::glGetIntegerv(gl::GL_BLEND_DST_RGB, &blend_dst_rgb_); + gl::glGetIntegerv(gl::GL_BLEND_DST_ALPHA, &blend_dst_a_); + blend_was_on_ = gl::glIsEnabled(gl::GL_BLEND) != gl::GL_FALSE; gl::glEnable(gl::GL_BLEND); gl::glBlendFunc(gl::GL_SRC_ALPHA, gl::GL_ONE_MINUS_SRC_ALPHA); } @@ -366,8 +400,10 @@ bool Renderer::begin_frame_text() noexcept { font_backend_.reset(*font_, style_.device(style_.axis_font_px)); // The cell every glyph staged this frame is drawn in, declared from the backend // rather than left to whichever glyph is staged first: the instances carry rect - // origins, and the draw carries this size (see TextBackend::cell_uv). - glyphs_.set_cell(font_backend_.cell_uv()); + // origins, and the draw carries this size (see TextBackend::cell_uv). Both + // batches draw from the same atlas at the same size, so both declare it. + glyphs_under_.set_cell(font_backend_.cell_uv()); + glyphs_over_.set_cell(font_backend_.cell_uv()); // A backend whose revision moved -- a font swapped, or re-baked at another size // -- is re-uploaded here, before anything is staged from it, so the frame draws // its glyphs from the atlas they were baked into. The first sync happened in @@ -417,9 +453,10 @@ void Renderer::draw(Plot& plot) { // its tick rebuilds, and the grid build all run in here, and every helper below // reads the strips, the axes rect and the transforms it pins. draw_plot(plot, plot.layout()); - // The frame's single text flush, after every line batch of the frame: text is - // texture-bound, so it is one draw call whatever it holds (see draw_plot). - flush_text(); + // The frame's over-text flush, after every line batch and after the under batch + // has already gone out inside draw_plot: readout text, tick labels and titles -- + // what must sit over everything (see draw_plot). + flush_text(glyphs_over_); } void Renderer::draw(Figure& figure) { @@ -453,7 +490,7 @@ void Renderer::draw(Figure& figure) { // overload -- the one layout of this frame, not a second derivation of it. draw_plot(plot, static_cast(plot).layout()); } - flush_text(); + flush_text(glyphs_over_); } void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) { @@ -504,9 +541,13 @@ void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) { draw_measures(plot, layout); draw_annotations(plot); draw_legend(plot, layout); + // The under text batch goes out here, so the readout's panel below it can + // occlude what it covers: legend names, cursor labels, measure labels and + // annotation captions are all chrome the panel is allowed to sit on. + flush_text(glyphs_under_); // The crosshair's readout last of the interior passes: it is what the pointer is on, so its - // panel is drawn over the legend and every other overlay rather than under them, and its text - // joins the frame's single flush with everything else. + // panel is drawn over the legend and every other overlay rather than under them, and its + // text joins the frame's over flush with the tick labels and titles. draw_crosshair_readout(plot, layout); clear_clip(); draw_tick_labels(plot, layout); @@ -540,7 +581,15 @@ void Renderer::end_frame() noexcept { gl::glBindBuffer(gl::GL_ARRAY_BUFFER, 0); gl::glActiveTexture(gl::GL_TEXTURE0); gl::glBindTexture(gl::GL_TEXTURE_2D, 0); - gl::glDisable(gl::GL_BLEND); + // The blend state is the host's, not the frame's: what `begin_frame()` saved + // comes back here, function and enable bit both (R6). + gl::glBlendFuncSeparate(static_cast(blend_src_rgb_), + static_cast(blend_dst_rgb_), + static_cast(blend_src_a_), + static_cast(blend_dst_a_)); + if (!blend_was_on_) { + gl::glDisable(gl::GL_BLEND); + } // The scissor, too: the frame engaged it (its baseline clip) and each plot narrowed and // released it, so what is left behind is the test *off* -- the state the residue list // promises -- rather than whichever rect the last pass was clipped to. The box stays @@ -567,9 +616,9 @@ void Renderer::upload_vertices(std::span interleaved) noexcept { } void Renderer::upload_text_vertices(std::span interleaved) noexcept { - // Six floats per instance is the text pipeline's layout (see TextInstance): - // the cell's corner, its atlas rect and the basis that rotates it. - text_vao_.upload(interleaved.data(), static_cast(interleaved.size() / 6u)); + // Ten floats per instance is the text pipeline's layout (see TextInstance): + // the cell's corner, its atlas rect, the basis that rotates it and the tint. + text_vao_.upload(interleaved.data(), static_cast(interleaved.size() / 10u)); } void Renderer::flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash) noexcept { @@ -585,6 +634,11 @@ void Renderer::flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash // grid, a trace or a cursor drawn after it (see draw_crosshair_lines()). line_program_.set_dash(dash); line_program_.set_color(color); + // The AA fringe, too: the style's logical knob in device pixels, clamped so + // a 0 style still keeps a quarter pixel of ramp. 1.0 -- the default -- is + // the fringe the shaders used to hardcode, so the default frame is + // unchanged. + line_program_.set_aa(std::max(0.25f, style_.device(style_.line_aa_px))); line_vao_.bind(); // One triangle strip per instance, four corners from gl_VertexID. gl::glDrawArraysInstanced(gl::GL_TRIANGLE_STRIP, 0, 4, @@ -595,12 +649,12 @@ void Renderer::flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash lines_.clear(); // keeps the capacity, so the next group re-uses the store } -void Renderer::flush_text() noexcept { - const u32 glyphs = glyphs_.glyph_count(); +void Renderer::flush_text(render::TextBatch& batch) noexcept { + const u32 glyphs = batch.glyph_count(); // No atlas is no text: a backend that publishes none has nothing to sample, and // drawing its instances would read whatever texture happened to be bound. if (glyphs != 0 && ready_ && !atlas_.empty()) { - upload_text_vertices(glyphs_.data()); + upload_text_vertices(batch.data()); text_program_.bind(); text_program_.set_viewport({viewport_.w, viewport_.h}); // The cell comes from the backend and the atlas it uploaded -- never from a @@ -613,26 +667,35 @@ void Renderer::flush_text() noexcept { text_program_.set_px_range({cell_uv.x * atlas_texels.x * font_backend_.scale(), cell_uv.y * atlas_texels.y * font_backend_.scale()}); text_program_.set_texture_size(atlas_texels); - text_program_.set_color(style_.axis_text); + // The tint rides on the instance (`a_colour`), so one draw call carries runs + // of several colours -- there is no colour uniform to set. atlas_.bind(); text_vao_.bind(); gl::glDrawArraysInstanced(gl::GL_TRIANGLE_STRIP, 0, 4, static_cast(text_vao_.instance_count())); ++stats_.draw_calls; } - glyphs_.clear(); + batch.clear(); } // ----------------------------------------------------------- coordinates -Renderer::Mapping Renderer::mapping(const Plot& plot, u32 axis_x, u32 axis_y) const noexcept { +Renderer::Mapping Renderer::mapping(const Plot& plot, u32 axis_x, u32 axis_y, + f64 origin_x) const noexcept { const Transform tx = plot.x_transform(axis_x); const Transform ty = plot.y_transform(axis_y); // The plot's transforms already map data to framebuffer pixels; the shaders // emit NDC relative to the GL viewport, so the viewport origin comes off here // -- once per mapping, never per primitive. + // + // The staged x is f32(to_space(x) - to_space(origin_x)) (B1), so the origin is + // folded back into the offset here -- in f64, once, before the single + // narrowing -- and the pair reproduces `to_screen_x` exactly however large the + // absolute x is. y is staged absolute (in pre-affine space), so its offset is + // the transform's own. + const f64 origin_s = space(origin_x, tx.log_x); return Mapping{Vec2{static_cast(tx.sx), static_cast(ty.sy)}, - Vec2{static_cast(tx.tx) - viewport_.x, + Vec2{static_cast(tx.sx * origin_s + tx.tx - viewport_.x), static_cast(ty.ty) - viewport_.y}}; } @@ -666,25 +729,42 @@ void Renderer::fill_pixels(const Rect& rect, Color color) { flush_lines(pixel_mapping(), color, rect.h); } -void Renderer::stage_segment(f64 x0, f64 y0, f64 x1, f64 y1, bool log_x, bool log_y) { - lines_.add_segment(pre_affine(x0, y0, log_x, log_y), pre_affine(x1, y1, log_x, log_y)); +void Renderer::stage_segment(f64 origin_x, f64 x0, f64 y0, f64 x1, f64 y1, bool log_x, + bool log_y) { + // Staged relative to `origin_x` in the pre-affine space (B1): the mapping folds + // the origin back in, in f64, so this f32 is O(visible pixels). + lines_.add_segment(Vec2{static_cast(space(x0, log_x) - space(origin_x, log_x)), + static_cast(space(y0, log_y))}, + Vec2{static_cast(space(x1, log_x) - space(origin_x, log_x)), + static_cast(space(y1, log_y))}); } -void Renderer::stage_xy(const f32* xy, u32 n, bool log_x, bool log_y) { +void Renderer::stage_xy(const f32* xy, u32 n, f64 series_x0, f64 origin_x, bool log_x, + bool log_y) { if (!log_x && !log_y) { - // The common case: gathered points are already interleaved `x,y`, which is - // the batch's own payload, so this is a straight copy. - lines_.add_polyline_xy(xy, n); + // The common case: reconstruct the absolute data x in f64 (`x0 + rel` -- + // the gather contract's own reconstruction), stage it relative to the + // draw's origin, and the f32 is O(visible pixels), not O(epoch). + staged_.clear(); + staged_.reserve(usize(n) * 2u); + const f64 origin_s = space(origin_x, log_x); + for (u32 i = 0; i < n; ++i) { + staged_.push_back(static_cast(xy[2u * i] + series_x0 - origin_s)); + staged_.push_back(xy[2u * i + 1u]); + } + lines_.add_polyline_xy(staged_.data(), n); return; } for (u32 i = 0; i + 1u < n; ++i) { - stage_segment(xy[2u * i], xy[2u * i + 1u], xy[2u * i + 2u], xy[2u * i + 3u], log_x, log_y); + stage_segment(origin_x, xy[2u * i] + series_x0, xy[2u * i + 1u], xy[2u * i + 2u] + series_x0, + xy[2u * i + 3u], log_x, log_y); } } -Vec2 Renderer::stage_text(Vec2 pos_px, const char* utf8) { +Vec2 Renderer::stage_text(render::TextBatch& batch, Vec2 pos_px, const char* utf8, Color color) { // Text is staged in viewport pixels: the text program has no offset uniform. - return glyphs_.add_text(utf8, to_viewport(pos_px), font_backend_.scale(), *font_); + return batch.add_text(utf8, to_viewport(pos_px), font_backend_.scale(), *font_, {1.0f, 0.0f}, + color); } f32 Renderer::clipped_text_width(const char* utf8, f32 budget_px, Vec2 axis) const { @@ -692,11 +772,12 @@ f32 Renderer::clipped_text_width(const char* utf8, f32 budget_px, Vec2 axis) con [](Vec2, const render::Glyph&) {}); } -f32 Renderer::stage_text_clipped(Vec2 pos_px, const char* utf8, f32 budget_px, Vec2 axis) { +f32 Renderer::stage_text_clipped(render::TextBatch& batch, Vec2 pos_px, const char* utf8, + f32 budget_px, Vec2 axis, Color color) { const f32 scale = font_backend_.scale(); return walk_text_clipped(to_viewport(pos_px), utf8, budget_px, *font_, scale, axis, - [this, scale, axis](Vec2 pen, const render::Glyph& glyph) { - glyphs_.add_glyph(pen, glyph, scale, axis); + [this, &batch, scale, axis, color](Vec2 pen, const render::Glyph& glyph) { + batch.add_glyph(pen, glyph, scale, axis, color); }); } @@ -704,9 +785,12 @@ f32 Renderer::stage_text_clipped(Vec2 pos_px, const char* utf8, f32 budget_px, V void Renderer::draw_grid(const Plot& plot) { const Grid& grid = plot.grid(); - const Mapping map = mapping(plot, 0, 0); // The grid is built from the primary axis pair, so it is mapped with their - // transforms even when a trace uses another pair. + // transforms even when a trace uses another pair. Its lines are staged + // relative to the x range's own minimum -- the natural origin for a whole-axis + // draw -- and the mapping folds it back in (B1). + const Range x_range = plot.x_range(0); + const Mapping map = mapping(plot, 0, 0, x_range.min); const bool log_x = plot.x_axis(0).log; const bool log_y = plot.y_axis(0).log; @@ -725,7 +809,7 @@ void Renderer::draw_grid(const Plot& plot) { } for (const GridLine& line : grid.lines()) { if (line.major != major) continue; // grid.build() already dropped non-finite ones - stage_segment(line.x0, line.y0, line.x1, line.y1, log_x, log_y); + stage_segment(x_range.min, line.x0, line.y0, line.x1, line.y1, log_x, log_y); } flush_lines(map, color, width); }; @@ -745,18 +829,20 @@ void Renderer::draw_axis_lines(const Plot& plot) { // An x axis draws along its edge of the axes rect, spanning its own range; the // perpendicular extent is the primary y axis' range, which is exactly what - // maps to the rect's top and bottom. + // maps to the rect's top and bottom. Staged relative to each range's own + // minimum and folded back in by the mapping (B1). for (u32 i = 0; i < plot.x_axis_count(); ++i) { const Axis& axis = plot.x_axis(i); const f64 y = axis.place == AxisPlace::Top ? y_range.max : y_range.min; - stage_segment(axis.range.min, y, axis.range.max, y, axis.log, log_y0); - flush_lines(mapping(plot, i, 0), style_.axis_line, style_.device(style_.line_width_px)); + stage_segment(axis.range.min, axis.range.min, y, axis.range.max, y, axis.log, log_y0); + flush_lines(mapping(plot, i, 0, axis.range.min), style_.axis_line, + style_.device(style_.line_width_px)); } for (u32 i = 0; i < plot.y_axis_count(); ++i) { const Axis& axis = plot.y_axis(i); const f64 x = axis.place == AxisPlace::Right ? x_range.max : x_range.min; - stage_segment(x, axis.range.min, x, axis.range.max, log_x0, axis.log); - flush_lines(mapping(plot, 0, i), style_.axis_line, style_.device(style_.line_width_px)); + stage_segment(x, x, axis.range.min, x, axis.range.max, log_x0, axis.log); + flush_lines(mapping(plot, 0, i, x), style_.axis_line, style_.device(style_.line_width_px)); } } @@ -777,9 +863,10 @@ void Renderer::draw_tick_labels(const Plot& plot, const PlotLayout& layout) { for (const Tick& tick : plot.x_axis(i).ticks) { if (!tick.major || tick.label.empty()) continue; const f32 width = font_backend_.measure(tick.label.c_str()); - stage_text({static_cast(transform.to_screen_x(tick.value)) - 0.5f * width, + stage_text(glyphs_over_, + {static_cast(transform.to_screen_x(tick.value)) - 0.5f * width, layout.x_label_area.y + 0.5f * (layout.x_label_area.h - line_h)}, - tick.label.c_str()); + tick.label.c_str(), style_.axis_text); } } } @@ -796,8 +883,9 @@ void Renderer::draw_tick_labels(const Plot& plot, const PlotLayout& layout) { const f32 width = font_backend_.measure(tick.label.c_str()); const f32 centre = static_cast(transform.to_screen_y(tick.value)); const f32 top = std::clamp(centre - 0.5f * line_h, layout.y_label_area.y, top_limit); - stage_text({layout.y_label_area.right() - style_.device(style_.grid_label_pad) - width, top}, - tick.label.c_str()); + stage_text(glyphs_over_, + {layout.y_label_area.right() - style_.device(style_.grid_label_pad) - width, top}, + tick.label.c_str(), style_.axis_text); } } } @@ -841,8 +929,8 @@ void Renderer::draw_titles(const Plot& plot, const PlotLayout& layout) { const f32 width = clipped_text_width(axis.label.c_str(), budget, kTextRight); // Centred on the strip: the tick labels have their own rows above it, so the // title needs no alignment of its own. - stage_text_clipped({strip.x + 0.5f * (strip.w - width), y}, axis.label.c_str(), budget, - kTextRight); + stage_text_clipped(glyphs_over_, {strip.x + 0.5f * (strip.w - width), y}, + axis.label.c_str(), budget, kTextRight, style_.axis_text); ++row; } @@ -872,8 +960,8 @@ void Renderer::draw_titles(const Plot& plot, const PlotLayout& layout) { if (x + line_h > strip.right()) break; const f32 budget = strip.h - 2.0f * pad; const f32 length = clipped_text_width(axis.label.c_str(), budget, kTextUp); - stage_text_clipped({x, strip.bottom() - 0.5f * (strip.h - length)}, axis.label.c_str(), budget, - kTextUp); + stage_text_clipped(glyphs_over_, {x, strip.bottom() - 0.5f * (strip.h - length)}, + axis.label.c_str(), budget, kTextUp, style_.axis_text); ++column; } } @@ -915,7 +1003,7 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) { : 0u; gather_.clear(); - series.gather(level, first, last, gather_); + series.gather(level, first, last, gather_, trace.mode); if (gather_.size() < 4u) continue; // fewer than two points is no segment stats_.visible_samples += visible; @@ -925,14 +1013,58 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) { ++stats_.lod_traces; } - stage_xy(gather_.data(), static_cast(gather_.size() / 2u), - plot.x_axis(trace.axis_x).log, plot.y_axis(trace.axis_y).log); + // Gathered x is relative to `series.x0()` (the gather contract); the staging + // reconstructs the absolute data x in f64 and emits `to_space(x_abs) - + // to_space(origin)` -- the visible x range's minimum is the natural origin + // for a trace -- so the f32 is O(visible pixels), not O(epoch) (B1). The + // mapping folds the origin back into the offset uniform, in f64. + const bool log_x = plot.x_axis(trace.axis_x).log; + const bool log_y = plot.y_axis(trace.axis_y).log; + const Mapping map = mapping(plot, trace.axis_x, trace.axis_y, x_range.min); + const u32 points = static_cast(gather_.size() / 2u); + + // A trace's dash rides on the same per-draw uniform the crosshair uses, in + // device pixels; `dash_off <= 0` is the solid default. + const Vec2 dash = trace.dash_off > 0.0f + ? Vec2{style_.device(trace.dash_on), style_.device(trace.dash_off)} + : Vec2{}; + // A trace's width is a logical size like every other stroke here, so it is // converted too: left raw, a 1.5 px trace would be drawn 1.5 device px against a // 3 device px axis line at scale 2 -- half the logical weight, which inverts the - // trace/axis hierarchy on a high-DPI panel. - flush_lines(mapping(plot, trace.axis_x, trace.axis_y), trace.color, - style_.device(trace.width)); + // trace/axis hierarchy on a high-DPI panel. `width == 0` is scatter: the + // markers below are the whole trace. + if (trace.width > 0.0f) { + stage_xy(gather_.data(), points, series.x0(), x_range.min, log_x, log_y); + flush_lines(map, trace.color, style_.device(trace.width), dash); + } + + // Markers, staged as line segments through this same batch in *pixel* space + // (so their size is device pixels whatever the data scale is). The stride is + // the trace's own `marker_every` or -- when it is 0, the auto mode -- about + // one marker per pixel column: ceil(samples per pixel), so markers only + // appear where the samples are sparse enough to need them. + if (trace.marker != Marker::None) { + const u32 stride = + trace.marker_every != 0 + ? trace.marker_every + : static_cast(std::ceil(samples_per_pixel > 1.0 ? samples_per_pixel : 1.0)); + const f32 half = style_.device(trace.marker_size_px); + const f64 origin_s = space(x_range.min, log_x); + u32 staged = 0; + for (u32 p = 0; p < points; p += stride) { + const f64 x_abs = static_cast(gather_[2u * p]) + series.x0(); + const f32 rx = static_cast(space(x_abs, log_x) - origin_s); + const f32 ry = static_cast(space(gather_[2u * p + 1u], log_y)); + const Vec2 centre{rx * map.scale.x + map.offset.x, ry * map.scale.y + map.offset.y}; + stage_marker(trace.marker, centre, half); + ++staged; + } + if (staged != 0) { + flush_lines(pixel_mapping(), trace.color, style_.device(1.0f)); + stats_.staged_markers += staged; + } + } } } @@ -985,16 +1117,19 @@ void Renderer::draw_cursors(const Plot& plot, const PlotLayout& layout) { if (horizontal) { const f64 y_drawn = plot.y_transform(cursor.axis_y).to_data_y(static_cast(drawn)); - stage_segment(x_range.min, y_drawn, x_range.max, y_drawn, log_x, + stage_segment(x_range.min, x_range.min, y_drawn, x_range.max, y_drawn, log_x, plot.y_axis(cursor.axis_y).log); + flush_lines(mapping(plot, 0, cursor.axis_y, x_range.min), + on ? style_.cursor : cursor.color, + style_.device(on ? style_.handle_width_px : style_.line_width_px)); } else { const f64 x_drawn = plot.x_transform(cursor.axis_x).to_data_x(static_cast(drawn)); - stage_segment(x_drawn, y_range.min, x_drawn, y_range.max, plot.x_axis(cursor.axis_x).log, - log_y); + stage_segment(x_drawn, x_drawn, y_range.min, x_drawn, y_range.max, + plot.x_axis(cursor.axis_x).log, log_y); + flush_lines(mapping(plot, cursor.axis_x, 0, x_drawn), + on ? style_.cursor : cursor.color, + style_.device(on ? style_.handle_width_px : style_.line_width_px)); } - flush_lines(horizontal ? mapping(plot, 0, cursor.axis_y) : mapping(plot, cursor.axis_x, 0), - on ? style_.cursor : cursor.color, - style_.device(on ? style_.handle_width_px : style_.line_width_px)); draw_cursor_label(plot, layout, cursor, drawn); } } @@ -1012,15 +1147,17 @@ void Renderer::draw_cursor_label(const Plot& plot, const PlotLayout& layout, con char value[32]; plot.y_axis(cursor.axis_y).format_value(cursor.y, value, sizeof value); if (cursor.label.empty()) { - stage_text(pen, value); + stage_text(glyphs_under_, pen, value, style_.axis_text); return; } - stage_text(pen, cursor.label.c_str()); - stage_text({pen.x + font_backend_.measure(cursor.label.c_str()) + gap, pen.y}, value); + stage_text(glyphs_under_, pen, cursor.label.c_str(), style_.axis_text); + stage_text(glyphs_under_, {pen.x + font_backend_.measure(cursor.label.c_str()) + gap, pen.y}, + value, style_.axis_text); return; } if (!cursor.label.empty()) { - stage_text({at + gap, layout.axes.y + style_.device(2.0f)}, cursor.label.c_str()); + stage_text(glyphs_under_, {at + gap, layout.axes.y + style_.device(2.0f)}, + cursor.label.c_str(), style_.axis_text); } } @@ -1051,7 +1188,8 @@ void Renderer::draw_regions(const Plot& plot, const PlotLayout& layout) { hot_right ? region.right_handle.w : edge_w); if (!config.label.empty() && region.label.w > 0.0f) { - stage_text({region.label.x, region.label.y}, config.label.c_str()); + stage_text(glyphs_under_, {region.label.x, region.label.y}, config.label.c_str(), + style_.axis_text); } } } @@ -1087,17 +1225,19 @@ void Renderer::draw_measures(const Plot& plot, const PlotLayout& layout) { // The shaded band: one vertical stroke per pixel column of the interval. // The line pipeline has no filled primitive, and the band is bounded by the // plot's width, so a per-column fill stays O(pixels) and needs no second - // pipeline. Strokes are staged in data space like everything else, so the - // band follows a pan or a zoom through the uniform alone. + // pipeline. Strokes are staged in data space relative to the axis' visible + // minimum and folded back by the mapping (B1), so the band follows a pan or a + // zoom through the uniform alone. + const f64 origin = plot.x_range(a.axis_x).min; for (f32 px = x0; px < x1; px += 1.0f) { const f64 x = transform.to_data_x(static_cast(px) + 0.5); - stage_segment(x, y_range.min, x, y_range.max, plot.x_axis(a.axis_x).log, log_y); + stage_segment(origin, x, y_range.min, x, y_range.max, plot.x_axis(a.axis_x).log, log_y); } // One device pixel, not a logical one: the band is a *fill* made of one // stroke per framebuffer column (`px += 1.0f` above), so its width is the // column pitch itself. Scaling it would overlap the columns it does not // match and leave gaps in the ones it does. - flush_lines(mapping(plot, a.axis_x, 0), style_.measure_fill, 1.0f); + flush_lines(mapping(plot, a.axis_x, 0, origin), style_.measure_fill, 1.0f); } char dx[kNumberChars]; @@ -1118,14 +1258,15 @@ void Renderer::draw_measures(const Plot& plot, const PlotLayout& layout) { // cursor labels, and a wide readout clamped to the left edge would overprint // them. The y axis title is not a consideration here -- it lives in its own // strip outside the axes. - stage_text({left, axes.y + line_h + style_.device(2.0f)}, readout); + stage_text(glyphs_under_, {left, axes.y + line_h + style_.device(2.0f)}, readout, + style_.axis_text); } } void Renderer::draw_annotations(const Plot& plot) { const f32 line_h = font_backend_.height(); // The clip every other overlay is drawn under, read here because the caption is staged - // into the frame's single text batch -- flushed after `clear_clip()` -- so the scissor that + // into the under-text batch -- flushed after `clear_clip()` -- so the scissor that // keeps the rest of this pass inside the axes does not reach it. const Rect& axes = plot.layout().axes; for (u32 i = 0; i < plot.annotation_count(); ++i) { @@ -1141,13 +1282,19 @@ void Renderer::draw_annotations(const Plot& plot) { flush_lines(pixel_mapping(), annotation.color, style_.device(1.0f)); // A caption whose anchor is outside the axes is skipped with its cross, which the clip // has already removed: a caption with no cross is a label for a point the frame does not - // show, and -- because the text batch is drawn *after* the clip is released -- it would - // otherwise paint outside the axes it is measured in, into the neighbouring row of a - // `Figure` whose rows tile the figure. Splitting the frame's one text draw call to clip - // it instead would cost a draw call per row and mis-clip the tick labels, which are - // staged outside `axes` on purpose and share the batch after this one. + // show, and -- because the under-batch text is drawn *after* the clip is released -- it + // would otherwise paint outside the axes it is measured in, into the neighbouring row of a + // `Figure` whose rows tile the figure. The caption box is also clamped inside `axes` + // (R6), and skipped entirely when it cannot fit the rect at all -- an annotation pinned + // inside a sliver of axes keeps its cross but not a caption that would overhang it. if (!annotation.text.empty() && axes.contains(px, py)) { - stage_text({px + style_.device(5.0f), py - 0.5f * line_h}, annotation.text.c_str()); + const f32 width = font_backend_.measure(annotation.text.c_str()); + const f32 pad = style_.device(5.0f); + if (width <= axes.w && line_h <= axes.h) { + const f32 x = std::clamp(px + pad, axes.x, axes.right() - width); + const f32 y = std::clamp(py - 0.5f * line_h, axes.y, axes.bottom() - line_h); + stage_text(glyphs_under_, {x, y}, annotation.text.c_str(), annotation.color); + } } } } @@ -1166,11 +1313,52 @@ void Renderer::draw_legend(const Plot& plot, const PlotLayout& layout) { // the marker itself, so both branches read their string from one place -- the // layout measured it, this stages it. if (row.ellipsis) { - stage_text({row.name.x, row.name.y}, kLegendEllipsis); + stage_text(glyphs_under_, {row.name.x, row.name.y}, kLegendEllipsis, style_.axis_text); continue; } draw_swatch(plot, row.trace, row.swatch); - stage_text({row.name.x, row.name.y}, plot.trace(row.trace).name.c_str()); + stage_text(glyphs_under_, {row.name.x, row.name.y}, plot.trace(row.trace).name.c_str(), + style_.axis_text); + } +} + +void Renderer::stage_marker(plot::Marker marker, Vec2 centre_px, f32 size_px) { + // Marker shapes as line segments, in viewport pixels: the trace's flush maps + // them with `pixel_mapping()`, so the size is device pixels whatever the data + // scale is. Cross: the two diagonals. Circle: an 8-gon. Square: the four sides. + switch (marker) { + case plot::Marker::Cross: + lines_.add_segment({centre_px.x - size_px, centre_px.y - size_px}, + {centre_px.x + size_px, centre_px.y + size_px}); + lines_.add_segment({centre_px.x - size_px, centre_px.y + size_px}, + {centre_px.x + size_px, centre_px.y - size_px}); + break; + case plot::Marker::Circle: { + // An octagon inscribed in the size box: 8 vertices, 8 segments. + Vec2 prev{}; + for (u32 k = 0; k <= 8u; ++k) { + const f32 angle = static_cast(k) * (3.14159265f / 4.0f); + const Vec2 v{centre_px.x + size_px * std::cos(angle), + centre_px.y + size_px * std::sin(angle)}; + if (k != 0) { + lines_.add_segment(prev, v); + } + prev = v; + } + break; + } + case plot::Marker::Square: + lines_.add_segment({centre_px.x - size_px, centre_px.y - size_px}, + {centre_px.x + size_px, centre_px.y - size_px}); + lines_.add_segment({centre_px.x + size_px, centre_px.y - size_px}, + {centre_px.x + size_px, centre_px.y + size_px}); + lines_.add_segment({centre_px.x + size_px, centre_px.y + size_px}, + {centre_px.x - size_px, centre_px.y + size_px}); + lines_.add_segment({centre_px.x - size_px, centre_px.y + size_px}, + {centre_px.x - size_px, centre_px.y - size_px}); + break; + case plot::Marker::None: + break; } } @@ -1201,8 +1389,19 @@ void Renderer::draw_swatch(const Plot& plot, u32 trace, const Rect& box) { // Opaque whatever the trace's own alpha is: a swatch drawn at a trace's 15% would be the least // legible thing in the frame, and the swatch is the one element that says which colour an entry or // a readout row *is*. One rule, used by the legend and by the crosshair's readout alike. - Color swatch = plot.trace(trace).color; + const Trace& source = plot.trace(trace); + Color swatch = source.color; swatch.a = 1.0f; + if (source.dash_off > 0.0f && box.w > 0.0f && box.h > 0.0f) { + // A dashed trace's swatch *samples the dash*: a solid fill would describe the + // one thing the trace is not. One dashed stroke across the box's midline, in + // the trace's own pattern (converted to device pixels, as the trace itself is). + const f32 mid = box.y + 0.5f * box.h; + lines_.add_segment({box.x, mid}, {box.right(), mid}); + flush_lines(pixel_mapping(), swatch, std::min(box.h, style_.device(source.width)), + Vec2{style_.device(source.dash_on), style_.device(source.dash_off)}); + return; + } fill_pixels(box, swatch); } @@ -1221,19 +1420,24 @@ void Renderer::draw_crosshair_lines(const Plot& plot, const PlotLayout& layout) // // The dash rides on the same segment: `v_along` is measured from the axes edge the line is // staged from, so no geometry is added and the phase does not depend on the transform. - const Mapping data = mapping(plot, 0u, 0u); + const Mapping data = mapping(plot, 0u, 0u, plot.x_range(0u).min); if (crosshair.vertical.w > 0.0f) { const f64 x = plot.x_transform(0u).to_data_x( static_cast(crosshair.vertical.x + 0.5f * crosshair.vertical.w)); const Range y_range = plot.y_range(0u); - stage_segment(x, y_range.min, x, y_range.max, plot.x_axis(0u).log, plot.y_axis(0u).log); + // Both lines share the horizontal's origin (the visible x minimum), so the + // staged values and the one mapping agree (B1): the vertical is staged as + // to_space(x) - to_space(min), not relative to itself. + stage_segment(plot.x_range(0u).min, x, y_range.min, x, y_range.max, plot.x_axis(0u).log, + plot.y_axis(0u).log); flush_lines(data, style_.crosshair, stroke, crosshair.dash); } if (crosshair.horizontal.h > 0.0f) { const f64 y = plot.y_transform(0u).to_data_y( static_cast(crosshair.horizontal.y + 0.5f * crosshair.horizontal.h)); const Range x_range = plot.x_range(0u); - stage_segment(x_range.min, y, x_range.max, y, plot.x_axis(0u).log, plot.y_axis(0u).log); + stage_segment(x_range.min, x_range.min, y, x_range.max, y, plot.x_axis(0u).log, + plot.y_axis(0u).log); flush_lines(data, style_.crosshair, stroke, crosshair.dash); } @@ -1265,10 +1469,14 @@ void Renderer::draw_crosshair_readout(const Plot& plot, const PlotLayout& layout for (const CrosshairRow& row : crosshair.rows) { if (!row.listed) continue; draw_swatch(plot, row.trace, row.swatch); - stage_text({row.label_box.x, row.label_box.y}, row.label.c_str()); + stage_text(glyphs_over_, {row.label_box.x, row.label_box.y}, row.label.c_str(), + style_.axis_text); // The value is a number, or `kCrosshairNoValue` for a trace with no sample at this x (the plot - // put it there): either way there is a run to stage, so a row is never a blank cell. - stage_text({row.value_box.x, row.value_box.y}, row.value.c_str()); + // put it there): either way there is a run to stage, so a row is never a blank cell. It is + // drawn in the row trace's own colour, resolved here from the plot -- the one tint the text + // pipeline carries per instance, so labels and values of one draw call can differ. + stage_text(glyphs_over_, {row.value_box.x, row.value_box.y}, row.value.c_str(), + plot.trace(row.trace).color); } } diff --git a/src/render/shaders.cpp b/src/render/shaders.cpp index cbce72f..d585aca 100644 --- a/src/render/shaders.cpp +++ b/src/render/shaders.cpp @@ -19,6 +19,7 @@ uniform vec2 u_scale; // data -> pixels uniform vec2 u_offset; // pixels uniform vec2 u_viewport; // render target size, pixels uniform float u_width; // stroke width, pixels +uniform float u_aa; // AA fringe half-width, pixels out float v_dist; // signed distance to the segment axis, pixels // Distance travelled from a_p0, pixels -- and the dash's phase anchor. Both of the @@ -33,7 +34,7 @@ void main() { vec2 p1 = a_p1 * u_scale + u_offset; vec2 d = p1 - p0; float len = length(d); - float half_w = u_width * 0.5 + 1.0; // + one pixel of AA fringe + float half_w = u_width * 0.5 + u_aa; // + the AA fringe // A degenerate segment has no direction: pin the normal, and give the quad // area by stretching its two ends apart along x. Collapsing all four corners // onto p0 would rasterise nothing, and a flat trace staged as p == p (the @@ -64,6 +65,7 @@ in float v_dist; // signed distance to the segment axis, pixels in float v_along; // distance travelled from a_p0, pixels uniform float u_width; // stroke width, pixels +uniform float u_aa; // AA fringe half-width, pixels uniform vec4 u_color; uniform vec2 u_dash; // dash pattern in pixels: x = ink, y = gap; y <= 0 is solid @@ -85,12 +87,15 @@ void main() { } float half_w = u_width * 0.5; float d = abs(v_dist); - if (d > half_w + 1.0) { + if (d > half_w + u_aa) { discard; // outside the fringe; nothing to blend } - // Ramp over the last pixel of the half width so a thin stroke keeps its - // weight instead of aliasing into a dashed line. - float alpha = 1.0 - smoothstep(half_w - 0.5, half_w + 0.5, d); + // Ramp over the fringe so a thin stroke keeps its weight instead of aliasing + // into a dashed line: the ramp is centred on `half_w` and spans `u_aa`, so + // `u_aa == 1.0` -- the style default -- reproduces the historical single-pixel + // ramp exactly. `u_aa` is the style's `line_aa_px` in device pixels, clamped + // at 0.25 by the renderer. + float alpha = 1.0 - smoothstep(half_w - 0.5 * u_aa, half_w + 0.5 * u_aa, d); o_color = vec4(u_color.rgb, u_color.a * alpha); } )"; @@ -99,12 +104,14 @@ const char* const kTextVS = R"(#version 330 core layout(location = 0) in vec2 a_pos; // per-instance glyph cell top-left, pixels layout(location = 1) in vec2 a_uv; // per-instance glyph rect top-left, normalised layout(location = 2) in vec2 a_axis; // per-instance cell +x axis, unit, pixels +layout(location = 3) in vec4 a_colour; // per-instance tint, straight alpha uniform vec2 u_viewport; // render target size, pixels uniform vec2 u_px_range; // glyph cell on screen, pixels (width, height) uniform vec2 u_glyph_uv; // glyph cell in the atlas, normalised (width, height) -out vec2 v_uv; // normalised atlas coordinate of this fragment +out vec2 v_uv; // normalised atlas coordinate of this fragment +out vec4 v_colour; // the instance's tint void main() { // The cell's two axes: the run direction the instance carries and its @@ -123,15 +130,16 @@ void main() { // The same handedness as the cell, so the glyph's rect lands on it the right // way round: u runs along the basis, v down the glyph. v_uv = a_uv + corner * u_glyph_uv; + v_colour = a_colour; } )"; const char* const kTextFS = R"(#version 330 core -in vec2 v_uv; // normalised atlas coordinate of this fragment +in vec2 v_uv; // normalised atlas coordinate of this fragment +in vec4 v_colour; // the instance's tint uniform sampler2D u_atlas; // GL_R8 glyph atlas, GL_NEAREST uniform vec2 u_tex_size; // atlas size in texels -uniform vec4 u_color; out vec4 o_color; @@ -141,7 +149,7 @@ void main() { // glyph cell; rounding to a centre removes the tie by construction. vec2 texel = clamp(floor(v_uv * u_tex_size), vec2(0.0), u_tex_size - vec2(1.0)); float coverage = texture(u_atlas, (texel + vec2(0.5)) / u_tex_size).r; - o_color = vec4(u_color.rgb, u_color.a * coverage); + o_color = vec4(v_colour.rgb, v_colour.a * coverage); } )"; diff --git a/src/render/vertex_array.cpp b/src/render/vertex_array.cpp index 564114a..066403b 100644 --- a/src/render/vertex_array.cpp +++ b/src/render/vertex_array.cpp @@ -72,15 +72,18 @@ bool TextVertexArray::create(Status* st, UploadMode mode) noexcept { } vao_.bind(); buffer_.bind(); - // Three attributes now: the cell's corner, its atlas rect and the basis that - // rotates it (see TextInstance). All of them advance once per instance, like - // the line pipeline's, because the corners come from gl_VertexID. + // Four attributes now: the cell's corner, its atlas rect, the basis that + // rotates it and the run's tint (see TextInstance). All of them advance once + // per instance, like the line pipeline's, because the corners come from + // gl_VertexID. vao_.attrib(0u, 2, sizeof(TextInstance), offsetof(TextInstance, x)); vao_.attrib(1u, 2, sizeof(TextInstance), offsetof(TextInstance, u)); vao_.attrib(2u, 2, sizeof(TextInstance), offsetof(TextInstance, ax)); + vao_.attrib(3u, 4, sizeof(TextInstance), offsetof(TextInstance, r)); gl::glVertexAttribDivisor(0u, kPerInstance); gl::glVertexAttribDivisor(1u, kPerInstance); gl::glVertexAttribDivisor(2u, kPerInstance); + gl::glVertexAttribDivisor(3u, kPerInstance); gl::glBindVertexArray(0); epoch_ = buffer_.epoch(); return true; @@ -99,6 +102,7 @@ void TextVertexArray::upload(const f32* interleaved, u32 glyphs) noexcept { vao_.attrib(0u, 2, sizeof(TextInstance), base + offsetof(TextInstance, x)); vao_.attrib(1u, 2, sizeof(TextInstance), base + offsetof(TextInstance, u)); vao_.attrib(2u, 2, sizeof(TextInstance), base + offsetof(TextInstance, ax)); + vao_.attrib(3u, 4, sizeof(TextInstance), base + offsetof(TextInstance, r)); epoch_ = buffer_.epoch(); } instances_ = glyphs; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0883058..8300fe4 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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 /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: must compile on its own.\n#include \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) diff --git a/tests/test_controller.cpp b/tests/test_controller.cpp index 5cd99c3..0e07373 100644 --- a/tests/test_controller.cpp +++ b/tests/test_controller.cpp @@ -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(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 ys(101); + for (u64 i = 0; i <= 100u; ++i) ys[static_cast(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}); +} diff --git a/tests/test_figure.cpp b/tests/test_figure.cpp index 2cab564..a23682e 100644 --- a/tests/test_figure.cpp +++ b/tests/test_figure.cpp @@ -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 { diff --git a/tests/test_gl_context.cpp b/tests/test_gl_context.cpp index 1604f5f..f89459f 100644 --- a/tests/test_gl_context.cpp +++ b/tests/test_gl_context.cpp @@ -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(ctx.width), static_cast(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(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); diff --git a/tests/test_glfw_host.cpp b/tests/test_glfw_host.cpp index dde64d5..4976c49 100644 --- a/tests/test_glfw_host.cpp +++ b/tests/test_glfw_host.cpp @@ -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); diff --git a/tests/test_gtk_input.cpp b/tests/test_gtk_input.cpp index e221738..df0b46a 100644 --- a/tests/test_gtk_input.cpp +++ b/tests/test_gtk_input.cpp @@ -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('+')) == plot::input::kKeyZoomIn); + PLOT_CHECK(key_from(0x003Du, static_cast('=')) == plot::input::kKeyZoomIn); + PLOT_CHECK(key_from(0x002Du, static_cast('-')) == plot::input::kKeyZoomOut); + PLOT_CHECK(key_from(0x005Fu, static_cast('_')) == plot::input::kKeyZoomOut); + PLOT_CHECK(key_from(0x0066u, static_cast('f')) == plot::input::kKeyFit); + PLOT_CHECK(key_from(0x0046u, static_cast('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('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('x')) == 0); + PLOT_CHECK(key_from(0u, 0u) == 0); +} diff --git a/tests/test_imgui_widget.cpp b/tests/test_imgui_widget.cpp index 16f2184..087a642 100644 --- a/tests/test_imgui_widget.cpp +++ b/tests/test_imgui_widget.cpp @@ -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); +} diff --git a/tests/test_measure.cpp b/tests/test_measure.cpp index 2692e9e..d23817b 100644 --- a/tests/test_measure.cpp +++ b/tests/test_measure.cpp @@ -3,6 +3,7 @@ #include "harness.hpp" #include +#include #include #include #include @@ -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 ys(100); + for (u64 i = 0; i < 100u; ++i) ys[static_cast(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(f32(10) * 0.01f), 1e-12); + PLOT_CHECK_NEAR(m.ymax, static_cast(f32(20) * 0.01f), 1e-12); +} diff --git a/tests/test_minmax_pyramid.cpp b/tests/test_minmax_pyramid.cpp index 2dc8610..20c3dbf 100644 --- a/tests/test_minmax_pyramid.cpp +++ b/tests/test_minmax_pyramid.cpp @@ -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); +} diff --git a/tests/test_plot.cpp b/tests/test_plot.cpp index 58841b1..4b6a753 100644 --- a/tests/test_plot.cpp +++ b/tests/test_plot.cpp @@ -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& out) const override { + void gather(u32 level, u64 begin, u64 end, std::vector& 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 x, std::vector 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& 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(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(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(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 x_; + std::vector 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::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::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); +} diff --git a/tests/test_primitives.cpp b/tests/test_primitives.cpp index 0124271..f450717 100644 --- a/tests/test_primitives.cpp +++ b/tests/test_primitives.cpp @@ -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_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. diff --git a/tests/test_qt_pointer.cpp b/tests/test_qt_pointer.cpp index 8b9cd2e..e88f602 100644 --- a/tests/test_qt_pointer.cpp +++ b/tests/test_qt_pointer.cpp @@ -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); +} diff --git a/tests/test_render_smoke.cpp b/tests/test_render_smoke.cpp index f3eb2a4..c4f2d58 100644 --- a/tests/test_render_smoke.cpp +++ b/tests/test_render_smoke.cpp @@ -18,6 +18,7 @@ #include #include +#include #include #include #include @@ -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(cfg.swatch_w) * static_cast(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 trace_column_mask(plottest::HeadlessContext& ctx, const plot::Rect& a) { + const plot::u32 first_row = static_cast(a.y); + const plot::u32 rows = static_cast(a.h); + std::vector mask(a.w > 0.0f ? static_cast(a.w) : 1u, false); + for (plot::u32 x = 0; x < static_cast(a.w); ++x) { + const plot::u32 px_x = static_cast(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 ys(640u); + for (plot::u32 i = 0; i < ys.size(); ++i) { + ys[i] = 0.8f * std::sin(0.05f * static_cast(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(kW), static_cast(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(kW), static_cast(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(kW), static_cast(kH)}; + r.begin_frame(kW, kH, vp); + r.draw(p0); + r.end_frame(); + const std::vector columns_zero = trace_column_mask(ctx, p0.layout().axes); + + r.begin_frame(kW, kH, vp); + r.draw(p1); + r.end_frame(); + const std::vector 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(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& frame, const plot::Rect& area, + const std::vector& exclude) { + plot::u32 hit = 0; + const plot::u32 x0 = static_cast(std::max(0.0f, area.x)); + const plot::u32 y0 = static_cast(std::max(0.0f, area.y)); + const plot::u32 x1 = static_cast(std::min(area.right(), kFrameRect.right())); + const plot::u32 y1 = static_cast(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(x) + 0.5f, static_cast(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(kW), static_cast(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(640u, 0.7f), 0.0, 1.0); + plot::data::ArraySeries b(std::vector(640u, 0.5f), 0.0, 1.0); + plot::data::ArraySeries c(std::vector(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 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 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 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 ys(32u); + for (plot::u32 i = 0; i < ys.size(); ++i) { + ys[i] = 0.5f + 0.4f * std::sin(0.9f * static_cast(i)); + } + plot::data::ArraySeries s(ys, 0.0, 20.0); + + plot::Plot plain_p; + plain_p.set_viewport({0, 0, static_cast(kW), static_cast(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(kW), static_cast(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(kW), static_cast(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(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 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(box.y); y < static_cast(box.bottom()); ++y) { + for (plot::u32 x = static_cast(box.x); x < static_cast(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(labels.y); y < static_cast(labels.bottom()); + ++y) { + for (plot::u32 x = static_cast(labels.x); x < static_cast(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(kW), static_cast(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(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(kW), static_cast(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::GL_ONE)); + PLOT_CHECK(dst == static_cast(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(kW), static_cast(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(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(kW), static_cast(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(); +} diff --git a/tests/test_ring_buffer.cpp b/tests/test_ring_buffer.cpp index f0a2286..9313677 100644 --- a/tests/test_ring_buffer.cpp +++ b/tests/test_ring_buffer.cpp @@ -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(100).capacity() == 128); + PLOT_CHECK(plot::data::RingBuffer(1).capacity() == 1); + PLOT_CHECK(plot::data::RingBuffer(0).capacity() == 1); + PLOT_CHECK(plot::data::RingBuffer(64).capacity() == 64); // already a power + + plot::data::RingBuffer 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 +} diff --git a/tests/test_series.cpp b/tests/test_series.cpp index c203c85..b900662 100644 --- a/tests/test_series.cpp +++ b/tests/test_series.cpp @@ -26,9 +26,10 @@ PLOT_TEST(array_series_bounds_and_gather) { std::vector 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 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 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 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(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(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(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{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{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{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 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 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 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 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 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(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 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 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 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 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 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 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 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 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 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 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 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)); + } + } +} diff --git a/tests/test_ticks.cpp b/tests/test_ticks.cpp index b4d3cd7..2bb0c5a 100644 --- a/tests/test_ticks.cpp +++ b/tests/test_ticks.cpp @@ -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 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 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 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) { diff --git a/tests/test_xy_series.cpp b/tests/test_xy_series.cpp new file mode 100644 index 0000000..8681dc1 --- /dev/null +++ b/tests/test_xy_series.cpp @@ -0,0 +1,273 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "harness.hpp" +#include +#include +#include +#include +#include +#include + +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 spread_x(u64 n) { + std::vector x; + x.reserve(static_cast(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 saw_y(u64 n) { + std::vector y; + y.reserve(static_cast(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 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(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 epoch = spread_x(500); + const std::vector 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 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 xs = x; + plot::data::XYSeries s(std::move(x), saw_y(1000)); + const auto at = [&](u64 i) { return xs[static_cast(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 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 xs = x; + plot::data::XYSeries s(std::move(x), saw_y(1000)); + const auto at = [&](u64 i) { return xs[static_cast(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 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 d(-50.0f, 50.0f); + std::vector y(kN); + for (auto& v : y) v = d(rng); + std::vector 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 xs = x; + const std::vector ys = saw_y(kN); + plot::data::XYSeries s(std::move(x), ys); + const auto at = [&](u64 i) { return xs[static_cast(i)]; }; + const auto val = [&](u64 i) { return ys[static_cast(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 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 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(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 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. +}