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

Breaking: Series::gather emits x relative to x0() and takes a GatherMode;
consumers reconstruct absolute x in f64 before mapping (series.hpp documents
the contract).

Bugs, each pinned by a fail-before regression test:
- render: f32 staging quantised epoch-time x into 128 px columns; every
  data-space draw now stages to_space(x) - to_space(origin) with the origin
  folded into the offset uniform in f64 (epoch/zero-origin twins rasterise
  identically)
- ticks: major test was ULP-fragile and blanked epoch axes; majors are an
  integer-index test and are never truncated by the minor cap
- gather: LOD envelope emitted buckets straddling [begin,end); ceil/floor
  bucket edges + raw lead/trail spans
- live follow: log x axes held a linear span and zoomed every frame; span and
  offset now live in axis space (plot + both figure link-group paths)
- input: a zero-notch wheel disarmed auto_scale on every axis; dy==0 pans x,
  factor == 1 / inf refused in wheel and drag zoom
- render: the single text flush painted legend names over the crosshair
  readout panel; two batches, under flushed before the panel
- data: NaN dropped from min/max unless it seeded the fold; non-finite
  samples are now excluded from every aggregate and from cached bounds

Robustness: viewport normalisation (no clamp UB), non-finite range refusal,
pyramid level cap at 57 (no shift/div UB through public gather), rounded ring
capacity documented + pre-sized, GL blend func and UNPACK_ALIGNMENT
save/restore, Renderer::release_gl() teardown, u64 buffer epochs, annotation
captions clamped, button-aware GLFW row capture, cached figure row rects (no
per-event layout), ruler-pair measurement refused, span-nonfinite guards in
axis_ends.

Features: XY series (sorted x, binary-search neighbours/snap, LOD over sample
index); StreamSeries::freeze(); ArraySeries::push(); per-trace dash and
markers (scatter at width 0) with auto or literal stride; GatherMode
Envelope/Nearest/Mean via bucket_sum/sumsq; Style::line_aa_px; per-glyph text
colour (readout values in trace colour); wheel-dx x pan; arrow-key pan;
Escape cancels a drag and restores captured ranges; modifier-masked box zoom
(Shift both, Ctrl x, Alt y); double-click reset via PointerEvent::time_s;
ImGui key repeat unified; pinch gestures on Qt and GTK; pixelDelta wheel path
and DevicePixelRatioChange handling; GNUInstallDirs, SOVERSION,
SameMinorVersion, generated plot/version.hpp, relocatable plot.pc, licence
install, FILES_MATCHING, GitHub Actions CI (debug/coverage/asan).

Suite 283 -> 350 cases, 0 failed; coverage 93.9% (3730/3971) over 24 files,
floor 80/90 held; ASAN preset suite green; all four demos run headless (the
GTK scripted-resize check needs a window manager and fails identically on the
parent commit); readme_main.cpp committed and compiles with the README
command; pkg-config and find_package consumption verified from a relocated
install prefix.
This commit is contained in:
Martino Ferrari committed 2026-09-30 17:32:06 +02:00
1 parent f132a5ec92
commit 38ef459897
77 files changed
+5865 -648

No files matched your search

+92
View File
@@ -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
+79 -36
View File
@@ -1,8 +1,19 @@
cmake_minimum_required(VERSION 3.24) 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") list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
include(plot_options) 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 add_library(plot_core
src/core/status.cpp src/core/status.cpp
@@ -11,6 +22,7 @@ add_library(plot_core
src/data/series.cpp src/data/series.cpp
src/data/array_series.cpp src/data/array_series.cpp
src/data/stream_series.cpp src/data/stream_series.cpp
src/data/xy_series.cpp
src/gl/loader.cpp src/gl/loader.cpp
src/gl/handle.cpp src/gl/handle.cpp
src/render/shaders.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. # and every consumer, and this project's own comments -- says plot::core.
set_target_properties(plot_core PROPERTIES EXPORT_NAME 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.<SOVERSION>, 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 target_include_directories(plot_core PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include> $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
# 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/.
$<BUILD_INTERFACE:${CMAKE_CURRENT_BINARY_DIR}/include>
$<INSTALL_INTERFACE:include>) $<INSTALL_INTERFACE:include>)
target_compile_features(plot_core PUBLIC cxx_std_20) target_compile_features(plot_core PUBLIC cxx_std_20)
target_link_libraries(plot_core PRIVATE ${CMAKE_DL_LIBS}) target_link_libraries(plot_core PRIVATE ${CMAKE_DL_LIBS})
plot_apply_warnings(plot_core) plot_apply_warnings(plot_core)
# The optional runtime-font backend: plot::text::TrueTypeFont, which bakes a real # The optional runtime-font backend: plot::text::TrueTypeFont. The option and
# TrueType/OpenType file into a fixed-cell atlas at run time (see # its guard live in cmake/plot_options.cmake with every other PLOT_* option;
# include/plot/text/true_type_font.hpp). It is the one target that compiles the # what is left here is the one thing an option cannot do for itself: adding the
# vendored third_party/stb_truetype.h, so it is opt-in and separable -- the core # target. See the option's comment there for why it is opt-in and separable.
# 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()
if(PLOT_BUILD_TTF_FONT) if(PLOT_BUILD_TTF_FONT)
add_subdirectory(src/text) add_subdirectory(src/text)
endif() endif()
@@ -162,35 +170,70 @@ if(PLOT_INSTALL)
# asking for it twice puts the same directory in the export twice. # asking for it twice puts the same directory in the export twice.
install(TARGETS plot_core ${PLOT_ADAPTER_EXPORT_TARGETS} install(TARGETS plot_core ${PLOT_ADAPTER_EXPORT_TARGETS}
EXPORT plotTargets EXPORT plotTargets
ARCHIVE DESTINATION lib ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
LIBRARY DESTINATION lib LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
RUNTIME DESTINATION bin) RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
# The optional backend's header goes in only when that backend was built: an # The public headers, and the generated version header with them. The
# installed header with no library behind it is a promise the prefix cannot keep, # directory rule takes the source tree (FILES_MATCHING, so the version.hpp.in
# and which configuration a prefix came from is not something a consumer can see. # 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) if(PLOT_BUILD_TTF_FONT)
install(DIRECTORY include/plot DESTINATION include) install(DIRECTORY include/plot
DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
FILES_MATCHING PATTERN "*.hpp")
else() 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) PATTERN "true_type_font.hpp" EXCLUDE)
endif() 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 install(EXPORT plotTargets
NAMESPACE plot:: NAMESPACE plot::
FILE plotTargets.cmake FILE plotTargets.cmake
DESTINATION lib/cmake/plot) DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/plot)
configure_package_config_file( configure_package_config_file(
cmake/plotConfig.cmake.in cmake/plotConfig.cmake.in
"${CMAKE_CURRENT_BINARY_DIR}/plotConfig.cmake" "${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( write_basic_package_version_file(
"${CMAKE_CURRENT_BINARY_DIR}/plotConfigVersion.cmake" "${CMAKE_CURRENT_BINARY_DIR}/plotConfigVersion.cmake"
VERSION ${PROJECT_VERSION} VERSION ${PROJECT_VERSION}
COMPATIBILITY SameMajorVersion) COMPATIBILITY SameMinorVersion)
install(FILES install(FILES
"${CMAKE_CURRENT_BINARY_DIR}/plotConfig.cmake" "${CMAKE_CURRENT_BINARY_DIR}/plotConfig.cmake"
"${CMAKE_CURRENT_BINARY_DIR}/plotConfigVersion.cmake" "${CMAKE_CURRENT_BINARY_DIR}/plotConfigVersion.cmake"
DESTINATION lib/cmake/plot) DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/plot)
endif() endif()
+49 -15
View File
@@ -16,7 +16,7 @@ built on top of it.
Per-frame rendering cost is O(visible pixels + samples produced since the last frame), 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 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 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 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 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 hover crosshair with a per-signal, interpolated readout;
- a multi-plot `Figure` of stacked rows sharing one synchronised x axis; - a multi-plot `Figure` of stacked rows sharing one synchronised x axis;
- live follow (`XFollow::Live`) with relative (multiplicative) zoom and mouse panning; - 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 - DPI-consistent sizing: metrics are logical units converted once through
`Style::ui_scale`; `Style::ui_scale`;
- an anti-aliased embedded monospaced font, plus an optional runtime TrueType backend - 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 cmake --install build/release --prefix /where/you/want
``` ```
Install is on by default (`PLOT_INSTALL=ON`) and installs the `plot_core` archive, the Install is on by default (`PLOT_INSTALL=ON`) and, via `GNUInstallDirs`, installs the
public headers under `include/plot/`, and a CMake package config under `lib/cmake/plot/`. `plot_core` archive under `lib/`, the public headers under `include/plot/` — including the
A consumer then needs only: 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 ```cmake
find_package(plot 0.1 REQUIRED) find_package(plot 0.1 REQUIRED)
target_link_libraries(my_app PRIVATE plot::core) 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.hpp>`: `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 ## Embedding
A host owns the window, the GL context and the event loop; `plot` owns none of them. This 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 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 ```cpp
#include <glfw/glfw_host.hpp> // the optional GLFW adapter #include <glfw/glfw_host.hpp> // 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) 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 ```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 \ build/debug/adapters/glfw/libplot_glfw.a build/debug/libplot_core.a \
$(pkg-config --libs glfw3) -ldl -lpthread -o plot_readme $(pkg-config --libs glfw3) -ldl -lpthread -o plot_readme
``` ```
@@ -124,18 +153,18 @@ documentation.
```sh ```sh
cmake --preset linux-debug cmake --preset linux-debug
cmake --build build/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 tools/coverage.sh # configure+build+run+capture+floor check
``` ```
The `linux-debug` suite runs **283 cases**; the `linux-coverage` preset builds **272** of The `linux-debug` suite runs **350 cases**; the `linux-coverage` preset builds **333** of
them, because its adapters are off and 11 cases need an adapter target (8 in them, because its adapters are off and 17 cases need an adapter target (11 in
`test_imgui_widget.cpp`, 3 in `test_glfw_host.cpp`). Coverage is enforced by `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 `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 **90%** aggregate. The measured aggregate and per-file table live in
23 files, every file at or above 80%; the per-file table is in [docs/coverage.md](docs/coverage.md), which the command regenerates — it is the single
[docs/coverage.md](docs/coverage.md), which the command regenerates. source for those numbers, so this page carries none.
## Licence and third-party provenance ## 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. - **macOS and Windows are untested.** Development and CI are Linux/EGL + X11 only.
- **Fractional-scale compositing and touchpad scroll calibration are unverified** — the - **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 `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: - **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 stb_truetype does no range checking, so `from_file()` must only be handed files the host
trusts. trusts.
+53 -19
View File
@@ -111,6 +111,10 @@ void Host::close() noexcept {
// hands each row its rect every frame). Dropping them would silently stop routing // hands each row its rect every frame). Dropping them would silently stop routing
// gestures to the rows on the second run. // gestures to the rows on the second run.
have_pointer_ = false; 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 // A closed host reports no size: a second run() must not see the previous
// window's framebuffer size before its first refresh_sizes(). // window's framebuffer size before its first refresh_sizes().
fb_width_ = 0; 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 // 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(). // controller is untouched -- each row has its own, see controller_at().
figure_->set_viewport(viewport); figure_->set_viewport(viewport);
// Every row's controller gets that row's rect, so a gesture -- routed by // The frame's one layout of the figure, into the cache every event handler
// pointer_target() or injected by a scripted run -- is hit-tested against the // between now and the next frame reads (R7): the rows below, and the routing of
// geometry the last frame drew, and the two readers of a row's geometry cannot // whatever the next `glfwPollEvents()` batch delivers, all take *these* rects
// disagree about it. The rows come off the one recomputing layout of this frame -- // rather than each paying for another layout.
// calling `layout()` again here, or in `controller_at()`, would lay every row out a refresh_row_rects();
// second and a third time for the same frame. const std::vector<Rect>& rows = row_rects_;
const std::vector<Rect>& rows = figure_->layout().rows;
for (u32 i = 0; i < row_controllers_.size(); ++i) { for (u32 i = 0; i < row_controllers_.size(); ++i) {
if (i < rows.size()) row_controllers_[i]->set_viewport(rows[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; have_pointer_ = true;
e.mods = mods_now(window_); e.mods = mods_now(window_);
e.down = any_button_down(window_); e.down = any_button_down(window_);
e.time_s = glfwGetTime(); // GLFW's monotonic clock; only differences are read
feed_pointer_event(e); 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.pressed = action == GLFW_PRESS;
e.released = action == GLFW_RELEASE; e.released = action == GLFW_RELEASE;
e.down = any_button_down(window_); e.down = any_button_down(window_);
e.time_s = glfwGetTime(); // the double-click detector reads differences of these
feed_pointer_event(e); 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_MINUS:
case GLFW_KEY_KP_SUBTRACT: mapped = input::kKeyZoomOut; break; case GLFW_KEY_KP_SUBTRACT: mapped = input::kKeyZoomOut; break;
case GLFW_KEY_F: mapped = input::kKeyFit; break; case GLFW_KEY_F: mapped = input::kKeyFit; break;
case GLFW_KEY_ESCAPE: // Arrows pan (10% of the span per press, repeats included) and Escape cancels
if (action == GLFW_PRESS) glfwSetWindowShouldClose(window_, GLFW_TRUE); // the active drag: translated, exactly like the ASCII set above, and the
return; // 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; default: return;
} }
input::KeyEvent e; input::KeyEvent e;
@@ -438,12 +450,27 @@ void Host::set_figure(Figure* figure) noexcept {
spread_y_ = 0.0; spread_y_ = 0.0;
captured_ = false; captured_ = false;
captured_row_ = 0u; 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(); const u32 rows = figure_->row_count();
row_controllers_.reserve(rows); row_controllers_.reserve(rows);
for (u32 i = 0; i < rows; ++i) { for (u32 i = 0; i < rows; ++i) {
row_controllers_.push_back(std::make_unique<input::Controller>(figure_->plot(i))); row_controllers_.push_back(std::make_unique<input::Controller>(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<Rect>{};
} }
input::Controller* Host::row_controller(u32 row) noexcept { 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 { input::Controller& Host::controller_at(f32 x, f32 y) noexcept {
if (figure_ == nullptr || row_controllers_.empty()) return controller_; 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 // The cached rects `refresh_row_rects()` filled -- the same ones the last frame
// event lays the figure out once and not once per reader -- this is the hottest path a multi-row // drew -- and not a fresh `Figure::layout()`: an event pays no layout, which is
// figure has, and a layout now rebuilds every row's ticks, grid and crosshair geometry. // what made a burst of pointer moves on a multi-row figure re-lay every row out
const std::vector<Rect>& rows = figure_->layout().rows; // 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<Rect>& rows = row_rects_;
if (rows.empty() || rows.size() != row_controllers_.size()) return controller_; 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() (void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in()
const u32 row = row_in(rows, y); 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); controller_.on_pointer(e);
return; 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() (void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in()
if (figure_ == nullptr || row_controllers_.empty()) return controller_; if (figure_ == nullptr || row_controllers_.empty()) return controller_;
const std::vector<Rect>& rows = figure_->layout().rows; const std::vector<Rect>& rows = row_rects_;
if (rows.empty() || rows.size() != row_controllers_.size()) return controller_; if (rows.empty() || rows.size() != row_controllers_.size()) return controller_;
u32 row = 0u; 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 // 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). // for ever (and let a later gesture resume it from the old anchor).
row = captured_row_; 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 { } else {
row = row_in(rows, y); row = row_in(rows, y);
if (pressed) { if (pressed) {
captured_ = true; captured_ = true;
captured_row_ = row; captured_row_ = row;
captured_button_ = button;
} }
} }
// The captured row is re-read against this frame's rects: the figure can be resized // The captured row is re-read against this frame's rects: the figure can be resized
+51 -12
View File
@@ -128,9 +128,10 @@ class Host {
input::Controller* row_controller(u32 row) noexcept; input::Controller* row_controller(u32 row) noexcept;
/// The controller an event at framebuffer pixel (x, y) belongs to: `controller_` -- /// 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 /// 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 /// row that pixel lands in otherwise. The row rects come from the cache
/// same ones the renderer draws, so a gesture lands on the row it was aimed at; a /// `refresh_row_rects()` fills -- the same ones the renderer draws -- so a gesture
/// pixel inside no row -- the row gap a host asked for with `Figure::set_row_gap()`, /// 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 /// 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. /// 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 /// 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 /// 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 /// 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 /// 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 /// 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. /// 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; 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. /// The gesture controller the host feeds. Valid for the host's whole life.
input::Controller& controller() noexcept { return controller_; } input::Controller& controller() noexcept { return controller_; }
@@ -198,11 +224,12 @@ class Host {
static u32 row_in(const std::vector<Rect>& rows, f32 y) noexcept; static u32 row_in(const std::vector<Rect>& rows, f32 y) noexcept;
/// The controller a pointer event at (x, y) belongs to, applying the button capture: /// 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 /// a press claims the row it landed in (`captured_row_` + `captured_button_`) and
/// release until the button comes up are routed to it; `controller_at()` is the pure /// every motion and the release until *that button* comes up are routed to it;
/// query that never captures. Also hands the target its row's viewport, so the /// `controller_at()` is the pure query that never captures. Also hands the target
/// hit-test geometry is the last frame's. /// 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; 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 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 /// 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; f64 spread_y_ = 0.0;
bool spread_active_ = false; bool spread_active_ = false;
/// The row a button-down drag has captured: the row its press landed in, which /// The row a button-down drag has captured, and the button that owns the capture:
/// owns its motions and its release until the button comes up. `captured_` is /// the row its press landed in owns that button's motions and its release until
/// false between gestures, and cleared when another figure is set. /// 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; bool captured_ = false;
u32 captured_row_ = 0u; 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<Rect> row_rects_;
u32 fb_width_ = 0; ///< framebuffer size of the last refresh_sizes() u32 fb_width_ = 0; ///< framebuffer size of the last refresh_sizes()
u32 fb_height_ = 0; u32 fb_height_ = 0;
+111
View File
@@ -229,4 +229,115 @@ inline f64 scroll_notches(f64 dy, ScrollUnit unit) noexcept {
return -dy; 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<u32>(input::kKeyZoomIn):
case static_cast<u32>('='): return input::kKeyZoomIn;
case static_cast<u32>(input::kKeyZoomOut):
case static_cast<u32>('_'): return input::kKeyZoomOut;
case static_cast<u32>(input::kKeyFit):
case static_cast<u32>(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 } // namespace plot::gtk4
+86 -23
View File
@@ -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 << 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 << 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((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 plot::gtk4 {
namespace { namespace {
@@ -158,6 +163,18 @@ PlotArea* PlotArea::create(Plot& plot) noexcept {
g_signal_connect(drag, "drag-end", G_CALLBACK(&PlotArea::on_drag_end), self); g_signal_connect(drag, "drag-end", G_CALLBACK(&PlotArea::on_drag_end), self);
gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(drag)); 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 // 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 // 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 // reports the unit per event, so a discrete wheel arrives in notches and a
@@ -279,6 +296,7 @@ void PlotArea::unrealize() noexcept {
renderer_.reset(); renderer_.reset();
press_open_ = false; press_open_ = false;
drag_.have = false; drag_.have = false;
zoom_.end();
} }
void PlotArea::render() noexcept { void PlotArea::render() noexcept {
@@ -364,7 +382,8 @@ void PlotArea::on_resize(GtkGLArea*, int width, int height, void* self) noexcept
// --- pointer input -------------------------------------------------------- // --- 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 if (press_open_) return; // one press per button-down sequence: see drag_begin
press_open_ = true; press_open_ = true;
press_button_ = button; press_button_ = button;
@@ -383,11 +402,12 @@ void PlotArea::pointer_press(input::Button button, u32 mods, double x, double y)
e.mods = mods; e.mods = mods;
e.down = true; e.down = true;
e.pressed = true; e.pressed = true;
e.time_s = pointer_time_s(time_ms); // the double-click detector differences these
controller_.on_pointer(e); controller_.on_pointer(e);
gtk_gl_area_queue_render(GTK_GL_AREA(widget_)); 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; if (!press_open_) return;
press_open_ = false; press_open_ = false;
@@ -400,6 +420,7 @@ void PlotArea::pointer_release(Vec2 position) noexcept {
e.mods = press_mods_; e.mods = press_mods_;
e.down = false; e.down = false;
e.released = true; e.released = true;
e.time_s = pointer_time_s(time_ms);
controller_.on_pointer(e); controller_.on_pointer(e);
gtk_gl_area_queue_render(GTK_GL_AREA(widget_)); 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 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 // 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. // 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, 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<PlotArea*>(self); auto* area = static_cast<PlotArea*>(self);
// The first release closes the gesture; a drag's own `drag-end` may already have // 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. // 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 PlotArea::on_click_stopped(GtkGestureClick* gesture, void* self) noexcept {
(void)gesture;
auto* area = static_cast<PlotArea*>(self); auto* area = static_cast<PlotArea*>(self);
// A sequence can be cancelled rather than released -- GTK stops the click // 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 // 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 // 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 // the press, which it has to, or the controller would stay in whatever mode the
// press began. // 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, 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<input::Button> button = const std::optional<input::Button> button =
button_from(gtk_gesture_single_get_current_button(GTK_GESTURE_SINGLE(gesture))); button_from(gtk_gesture_single_get_current_button(GTK_GESTURE_SINGLE(gesture)));
if (button) { 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.button = area->press_button_;
e.mods = area->press_mods_; e.mods = area->press_mods_;
e.down = true; 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); area->controller_.on_pointer(e);
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_)); gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
} }
void PlotArea::on_drag_end(GtkGestureDrag* gesture, double offset_x, double offset_y, void PlotArea::on_drag_end(GtkGestureDrag* gesture, double offset_x, double offset_y,
void* self) noexcept { void* self) noexcept {
(void)gesture;
auto* area = static_cast<PlotArea*>(self); auto* area = static_cast<PlotArea*>(self);
// The release carries the authoritative end of the gesture: the controller reads // 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 // 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. // and not the last update's position.
const Vec2 position = area->drag_.end(offset_x, offset_y, area->mapping_); const Vec2 position = area->drag_.end(offset_x, offset_y, area->mapping_);
area->pointer_ = position; 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<PlotArea*>(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<f32>(gtk_gesture_zoom_get_scale_delta(gesture)),
Vec2{static_cast<f32>(cx), static_cast<f32>(cy)});
}
void PlotArea::on_zoom_scale_changed(GtkGestureZoom* gesture, double scale,
void* self) noexcept {
auto* area = static_cast<PlotArea*>(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<f32>(scale), Vec2{static_cast<f32>(cx), static_cast<f32>(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<PlotArea*>(self)->zoom_.end();
} }
int PlotArea::on_scroll(GtkEventControllerScroll* scroll, double dx, double dy, 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.dx = 0.0f;
e.dy = 0.0f; e.dy = 0.0f;
e.down = false; 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); area->controller_.on_pointer(e);
} }
@@ -560,20 +629,14 @@ int PlotArea::on_key_pressed(GtkEventControllerKey* keys, unsigned int keyval,
(void)keycode; (void)keycode;
(void)keys; (void)keys;
auto* area = static_cast<PlotArea*>(self); auto* area = static_cast<PlotArea*>(self);
// The keys are named by their *symbol*: GTK reports a layout's keyval, and the // The whole translation lives in gtk_input.hpp (see `key_from`): the function
// keypad's plus and minus are the same symbol as the main row's. `'='` is mapped // keys -- arrows, Escape -- are matched on the *keyval*, which is layout- and
// with `'+'` so both spellings of "zoom in" work (on most layouts `+` *is* // level-independent for them, and the printable ones on the Unicode they decode
// shift+`=`), the pair the GLFW and Qt adapters map. // to, which is what makes `+` and shift+`=` (usually the same physical key) both
int key = 0; // zoom in, and the keypad's plus and minus come along for free. `0` is "no plot
switch (gdk_keyval_to_unicode(keyval)) { // key": GTK and the window keep it.
case static_cast<guint32>(input::kKeyZoomIn): const int key = key_from(static_cast<u32>(keyval), static_cast<u32>(gdk_keyval_to_unicode(keyval)));
case static_cast<guint32>('='): key = input::kKeyZoomIn; break; if (key == 0) return FALSE;
case static_cast<guint32>(input::kKeyZoomOut):
case static_cast<guint32>('_'): key = input::kKeyZoomOut; break;
case static_cast<guint32>(input::kKeyFit):
case static_cast<guint32>(input::kKeyFitUpper): key = input::kKeyFit; break;
default: return FALSE; // not a plot key: GTK and the window keep it
}
input::KeyEvent e; input::KeyEvent e;
e.key = key; e.key = key;
+28 -3
View File
@@ -32,10 +32,12 @@ typedef struct _GtkGLArea GtkGLArea;
typedef struct _GtkGesture GtkGesture; typedef struct _GtkGesture GtkGesture;
typedef struct _GtkGestureClick GtkGestureClick; typedef struct _GtkGestureClick GtkGestureClick;
typedef struct _GtkGestureDrag GtkGestureDrag; typedef struct _GtkGestureDrag GtkGestureDrag;
typedef struct _GtkGestureZoom GtkGestureZoom;
typedef struct _GtkEventControllerScroll GtkEventControllerScroll; typedef struct _GtkEventControllerScroll GtkEventControllerScroll;
typedef struct _GtkEventControllerKey GtkEventControllerKey; typedef struct _GtkEventControllerKey GtkEventControllerKey;
typedef struct _GtkEventControllerMotion GtkEventControllerMotion; typedef struct _GtkEventControllerMotion GtkEventControllerMotion;
typedef struct _GdkGLContext GdkGLContext; typedef struct _GdkGLContext GdkGLContext;
typedef struct _GdkEventSequence GdkEventSequence;
namespace plot::gtk4 { namespace plot::gtk4 {
@@ -134,6 +136,10 @@ using FrameFn = std::function<void(Plot&, f64 dt)>;
/// release, with the button and the modifier state, exactly as the Qt /// release, with the button and the modifier state, exactly as the Qt
/// widget's `mousePressEvent`/`mouseReleaseEvent` do. It is also what begins /// widget's `mousePressEvent`/`mouseReleaseEvent` do. It is also what begins
/// a cursor move: a ctrl+press on a cursor opens `DragMode::MoveCursor` there. /// 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 /// * `GtkEventControllerMotion` supplies hover (the cursor hit test reads the
/// last position even with no button down). Its positions are forwarded with /// 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 /// `down == false` on purpose: a motion event that claimed a button was down
@@ -271,9 +277,16 @@ class PlotArea {
void refresh_viewport() noexcept; void refresh_viewport() noexcept;
/// One pointer event, from whichever gesture saw it first. Owns the /// One pointer event, from whichever gesture saw it first. Owns the
/// press/release bookkeeping the two gestures share. /// press/release bookkeeping the two gestures share. `time_ms` is the event's
void pointer_press(input::Button button, u32 mods, double x, double y) noexcept; /// own GDK stamp, in milliseconds, forwarded as the `PointerEvent::time_s` the
void pointer_release(Vec2 position) noexcept; /// 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<f64>(time_ms) * 1.0e-3; }
/// Translates the gesture-reported position/delta in *logical* widget pixels /// Translates the gesture-reported position/delta in *logical* widget pixels
/// into device pixels. The one place that mapping exists. /// into device pixels. The one place that mapping exists.
Vec2 to_device(double x, double y) const noexcept; 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, static void on_click_released(GtkGestureClick* gesture, int n_press, double x, double y,
void* self) noexcept; void* self) noexcept;
static void on_click_stopped(GtkGestureClick* gesture, 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, static void on_drag_begin(GtkGestureDrag* gesture, double start_x, double start_y,
void* self) noexcept; void* self) noexcept;
static void on_drag_update(GtkGestureDrag* gesture, double offset_x, double offset_y, 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 /// 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. /// since the previous one, and `have` is whether a drag is recognised.
DragAnchor drag_; 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 /// The last pointer position seen, in device pixels: the wheel's anchor when a
/// scroll event carries no position of its own. /// scroll event carries no position of its own.
Vec2 pointer_{}; Vec2 pointer_{};
+23 -9
View File
@@ -201,6 +201,11 @@ void PlotWidget::forward_input(bool hovered) {
e.down = any_down; e.down = any_down;
e.pressed = pressed; e.pressed = pressed;
e.released = released; 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); 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 // 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 // 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. // 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) { if (hovered && !io.WantTextInput) {
const auto key = [&](int code) { const auto key = [&](int code) {
input::KeyEvent e; input::KeyEvent e;
@@ -239,15 +253,15 @@ void PlotWidget::forward_input(bool hovered) {
e.down = true; // ImGui already edge-detected the press e.down = true; // ImGui already edge-detected the press
controller_->on_key(e); controller_->on_key(e);
}; };
if (ImGui::IsKeyPressed(ImGuiKey_Equal, false) || const auto pressed = [](ImGuiKey k) { return ImGui::IsKeyPressed(k, true); };
ImGui::IsKeyPressed(ImGuiKey_KeypadAdd, false)) { if (pressed(ImGuiKey_Equal) || pressed(ImGuiKey_KeypadAdd)) key(input::kKeyZoomIn);
key(input::kKeyZoomIn); if (pressed(ImGuiKey_Minus) || pressed(ImGuiKey_KeypadSubtract)) key(input::kKeyZoomOut);
} if (pressed(ImGuiKey_F)) key(input::kKeyFit);
if (ImGui::IsKeyPressed(ImGuiKey_Minus, false) || if (pressed(ImGuiKey_LeftArrow)) key(input::kKeyLeft);
ImGui::IsKeyPressed(ImGuiKey_KeypadSubtract, false)) { if (pressed(ImGuiKey_RightArrow)) key(input::kKeyRight);
key(input::kKeyZoomOut); if (pressed(ImGuiKey_UpArrow)) key(input::kKeyUp);
} if (pressed(ImGuiKey_DownArrow)) key(input::kKeyDown);
if (ImGui::IsKeyPressed(ImGuiKey_F, false)) key(input::kKeyFit); if (pressed(ImGuiKey_Escape)) key(input::kKeyEscape);
} }
prev_down_[0] = down[0]; prev_down_[0] = down[0];
+105 -11
View File
@@ -11,6 +11,9 @@
#include <QOpenGLContext> #include <QOpenGLContext>
#include <QPointF> #include <QPointF>
#include <QWheelEvent> #include <QWheelEvent>
#include <QEvent>
#include <QGestureEvent>
#include <QPinchGesture>
#include <chrono> #include <chrono>
#include <cmath> #include <cmath>
@@ -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 // The controller's keys (+, - and f) are input to the plot, and a widget without
// a focus policy never receives a key event. // a focus policy never receives a key event.
setFocusPolicy(Qt::StrongFocus); 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() { PlotWidget::~PlotWidget() {
@@ -243,6 +250,10 @@ void PlotWidget::forward_pointer(QMouseEvent* event, bool pressed, bool released
e.down = any_plot_button(event->buttons()); e.down = any_plot_button(event->buttons());
e.pressed = pressed; e.pressed = pressed;
e.released = released; 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<f64>(event->timestamp()) * 1.0e-3;
controller_.on_pointer(e); controller_.on_pointer(e);
event->accept(); event->accept();
update(); 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::mouseDoubleClickEvent(QMouseEvent* event) { forward_pointer(event, true, false); }
void PlotWidget::wheelEvent(QWheelEvent* event) { void PlotWidget::wheelEvent(QWheelEvent* event) {
// Qt reports eighths of a degree, 120 to a notch; the controller takes notches, // Two units arrive, one per axis: Qt's `angleDelta()` is in eighths of a degree,
// positive = zoom in, which is Qt's positive angleDelta().y() as well (the wheel // 120 to a notch, while `pixelDelta()` is in (high-resolution) scroll pixels and
// pushed away from the user). Passing the raw delta would zoom 120x too far. // is the only non-zero delta a device that reports pixels produces. A pixel
const f32 notches = static_cast<f32>(event->angleDelta().y()) / 120.0f; // delta has no notch size Qt defines, so the divisor is this adapter's
if (notches == 0.0f) { // **calibration**, not a conversion: 40 scroll pixels are taken to be one notch,
// A horizontal-only wheel is not a zoom, and it must not reach the controller: // the order of a deliberate one- or two-finger scroll gesture, so such a gesture
// a zero-notch zoom changes no range but still runs the range setters, which // zooms about one detent instead of a fraction of one (the same reasoning, and
// clear auto scale -- so a stray horizontal scroll would silently stop a live // the same role, as the GTK adapter's kSurfacePixelsPerNotch; a value measured
// axis from following its stream. Ignored, so an enclosing scroll area may // on real hardware should replace it).
// still act on 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<f32>(pixels.y()) / 40.0f
: static_cast<f32>(angle.y()) / 120.0f;
const f32 notches_x = pixel_scrolled ? static_cast<f32>(pixels.x()) / 40.0f
: static_cast<f32>(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(); event->ignore();
return; return;
} }
@@ -273,7 +294,10 @@ void PlotWidget::wheelEvent(QWheelEvent* event) {
input::WheelEvent e; input::WheelEvent e;
e.x = p.x; e.x = p.x;
e.y = p.y; e.y = p.y;
e.dx = static_cast<f32>(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.dy = notches;
e.mods = mods_from(event->modifiers()); e.mods = mods_from(event->modifiers());
controller_.on_wheel(e); controller_.on_wheel(e);
@@ -292,6 +316,16 @@ void PlotWidget::keyPressEvent(QKeyEvent* event) {
case Qt::Key_Minus: case Qt::Key_Minus:
case Qt::Key_Underscore: mapped = input::kKeyZoomOut; break; case Qt::Key_Underscore: mapped = input::kKeyZoomOut; break;
case Qt::Key_F: mapped = input::kKeyFit; 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: default:
QOpenGLWidget::keyPressEvent(event); // not a plot key: nobody else wants it QOpenGLWidget::keyPressEvent(event); // not a plot key: nobody else wants it
return; return;
@@ -307,4 +341,64 @@ void PlotWidget::keyPressEvent(QKeyEvent* event) {
update(); 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<QGestureEvent*>(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<QPinchGesture*>(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<f64>(devicePixelRatioF());
const Vec2 logical{static_cast<f32>(logical_center.x()), static_cast<f32>(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<f32>(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<f32>(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 } // namespace plot::qt
+25 -4
View File
@@ -34,6 +34,9 @@ class QKeyEvent;
class QMouseEvent; class QMouseEvent;
class QPointF; class QPointF;
class QWheelEvent; class QWheelEvent;
class QEvent;
class QGestureEvent;
class QPinchGesture;
namespace plot::qt { namespace plot::qt {
@@ -106,10 +109,15 @@ namespace plot::qt {
/// reinterpretation rather than the edge synthesis the ImGui adapter needs. /// 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 /// 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 /// 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 /// = zoom in -- which is Qt's positive `angleDelta().y()` too. A wheel event whose
/// no vertical component is refused before it reaches the controller, because a /// deltas both vanish -- no angle and no pixels -- is refused before it reaches the
/// zero-notch zoom changes no range but still runs the range setters, which clear /// controller, because a zero-notch, zero-pan event changes nothing and must not
/// auto scale: a horizontal scroll must not silently stop a live axis following. /// 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 /// ## Threading
/// ///
@@ -170,6 +178,12 @@ class PlotWidget : public QOpenGLWidget, protected QOpenGLFunctions {
void initializeGL() override; void initializeGL() override;
void resizeGL(int w, int h) override; void resizeGL(int w, int h) override;
void paintGL() 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 mousePressEvent(QMouseEvent* event) override;
void mouseMoveEvent(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, /// Sets the plot's viewport to the current device rect (through the controller,
/// which refreshes the layout with it). /// which refreshes the layout with it).
void refresh_viewport(); 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 Plot* plot_ = nullptr; ///< declared first: the controller is built over it
input::Controller controller_; 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 /// 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). /// (see `PointerAnchor` for why the anchor must not be device pixels).
PointerAnchor anchor_; 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; u64 frames_ = 0;
f32 last_frame_ms_ = 0.0f; f32 last_frame_ms_ = 0.0f;
+66
View File
@@ -4,6 +4,7 @@
#pragma once #pragma once
#include <plot/core/types.hpp> #include <plot/core/types.hpp>
#include <plot/input/events.hpp>
#include <plot/math/vec2.hpp> #include <plot/math/vec2.hpp>
namespace plot::qt { 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<f32>(static_cast<f64>(logical_center.x) * dpr);
g.y = static_cast<f32>(static_cast<f64>(logical_center.y) * dpr);
g.dx = static_cast<f32>((static_cast<f64>(logical_center.x) - static_cast<f64>(x)) * dpr);
g.dy = static_cast<f32>((static_cast<f64>(logical_center.y) - static_cast<f64>(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 } // namespace plot::qt
+39 -2
View File
@@ -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_IMGUI "Build the Dear ImGui adapter and demo" OFF)
option(PLOT_BUILD_QT6 "Build the Qt6 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) 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) option(PLOT_INSTALL "Generate install and export rules (find_package(plot))" ON)
set(PLOT_SANITIZE "" CACHE STRING set(PLOT_SANITIZE "" CACHE STRING
"Sanitizers to build with, as the list a -fsanitize= flag takes (e.g. address); empty for none") "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 # presets leave it off, so their target lists, configure time and build product
# are exactly what they were before this option existed. # are exactly what they were before this option existed.
option(PLOT_COVERAGE "Add the `coverage` target (needs a --coverage build tree)" OFF) 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_<CONFIG>) 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 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.") "Configure with --preset linux-coverage, or drop -DPLOT_COVERAGE=ON.")
endif() endif()
if(PLOT_COVERAGE AND NOT PLOT_BUILD_TESTS) if(PLOT_COVERAGE AND NOT PLOT_BUILD_TESTS)
+41
View File
@@ -32,6 +32,21 @@
# to the list below; a caller may override it, and # to the list below; a caller may override it, and
# emptying it is how a test proves the exemption is what # emptying it is how a test proves the exemption is what
# keeps a header out of the "missing" report. # 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) if(NOT DEFINED PLOT_OPTIONAL_HEADERS)
set(PLOT_OPTIONAL_HEADERS set(PLOT_OPTIONAL_HEADERS
@@ -42,6 +57,18 @@ if(NOT DEFINED PLOT_OPTIONAL_HEADERS)
"plot/text/true_type_font.hpp") "plot/text/true_type_font.hpp")
endif() endif()
if(NOT DEFINED PLOT_EXTRA_PUBLIC_HEADERS)
set(PLOT_EXTRA_PUBLIC_HEADERS
# <plot/version.hpp>, 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) if(NOT DEFINED PLOT_INCLUDE_DIR)
message(FATAL_ERROR "plot: PLOT_INCLUDE_DIR is not set") message(FATAL_ERROR "plot: PLOT_INCLUDE_DIR is not set")
endif() endif()
@@ -52,6 +79,9 @@ endif()
file(GLOB_RECURSE plot_umbrella_headers file(GLOB_RECURSE plot_umbrella_headers
RELATIVE "${PLOT_INCLUDE_DIR}" RELATIVE "${PLOT_INCLUDE_DIR}"
"${PLOT_INCLUDE_DIR}/plot/*.hpp") "${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) list(SORT plot_umbrella_headers)
file(READ "${PLOT_UMBRELLA}" plot_umbrella_text) file(READ "${PLOT_UMBRELLA}" plot_umbrella_text)
@@ -100,9 +130,20 @@ string(REGEX MATCHALL "#include <plot/[^>]+>" plot_umbrella_includes "${plot_umb
set(plot_umbrella_dangling "") set(plot_umbrella_dangling "")
foreach(plot_umbrella_include IN LISTS plot_umbrella_includes) foreach(plot_umbrella_include IN LISTS plot_umbrella_includes)
string(REGEX REPLACE "#include <([^>]+)>" "\\1" plot_umbrella_header "${plot_umbrella_include}") 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}") if(NOT EXISTS "${PLOT_INCLUDE_DIR}/${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}") list(APPEND plot_umbrella_dangling "${plot_umbrella_header}")
endif() endif()
endif()
endforeach() endforeach()
if(plot_umbrella_dangling) if(plot_umbrella_dangling)
+31 -13
View File
@@ -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 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 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` `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 `283 cases, 0 failed` summary line. 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/`. 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/<adapter>/`, their targets in the same package).
## Headless demo modes ## 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 | | every file under `src/` | **80%** of its instrumented lines |
| `src/` as a whole | **90%** of instrumented lines | | `src/` as a whole | **90%** of instrumented lines |
Branch coverage is deliberately off. The current measurement (from `build/cov/coverage.info`, Branch coverage is deliberately off. This page carries no measured totals — a number written
re-read with `python3 tools/coverage_floor.py build/cov/coverage.info`) is an **aggregate here would be stale the moment `src/` moved, and the report is reproducible: the method, the
95.3% (3284/3447 lines)** over 23 files, every file at or above 80% — the floor is satisfied. per-file table and the floor verdict live in [docs/coverage.md](coverage.md), which
The method, the full per-file table and the reproducibility notes are in `tools/coverage.sh` rewrites from the very run that measured them. Re-run the command for the
[docs/coverage.md](coverage.md); re-run the command for the live numbers. live numbers.
**One thing to know about the coverage preset:** it builds **272 of the 283 cases**. The 11 **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 — 8 in that are missing are the adapter cases that need an adapter target to exist — 11 in
`test_imgui_widget.cpp` and 3 in `test_glfw_host.cpp` — and the coverage preset turns the `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` (2 cases) adapters off. The toolkit-free adapter seams still run there: `test_qt_pointer.cpp` (5 cases)
and `test_gtk_input.cpp` (8 cases) compile without Qt or GTK. So the coverage number measures 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). the library plus its unit suite, not the adapter bodies (which the headless demo runs cover).
+15 -14
View File
@@ -43,35 +43,36 @@ when one is missing.
<!-- BEGIN GENERATED: tools/coverage.sh --> <!-- BEGIN GENERATED: tools/coverage.sh -->
``` ```
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 | | File | Lines | Line rate | Functions |
| --- | ---: | ---: | ---: | | --- | ---: | ---: | ---: |
| `src/data/array_series.cpp` | 43/43 | 100.0% | 100.0% | | `src/data/array_series.cpp` | 56/56 | 100.0% | 100.0% |
| `src/data/minmax_pyramid.cpp` | 163/163 | 100.0% | 100.0% | | `src/data/minmax_pyramid.cpp` | 170/171 | 99.4% | 96.4% |
| `src/data/series.cpp` | 105/107 | 98.1% | 100.0% | | `src/data/series.cpp` | 161/164 | 98.2% | 100.0% |
| `src/data/stream_series.cpp` | 39/39 | 100.0% | 100.0% | | `src/data/stream_series.cpp` | 45/45 | 100.0% | 100.0% |
| `src/gl/handle.cpp` | 256/256 | 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/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/math/transform.cpp` | 28/28 | 100.0% | 100.0% |
| `src/plot/axis.cpp` | 12/12 | 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/grid.cpp` | 15/16 | 93.8% | 100.0% |
| `src/plot/measure.cpp` | 21/21 | 100.0% | 100.0% | | `src/plot/measure.cpp` | 21/21 | 100.0% | 100.0% |
| `src/plot/plot.cpp` | 743/763 | 97.4% | 98.9% | | `src/plot/plot.cpp` | 767/793 | 96.7% | 98.9% |
| `src/plot/ticks.cpp` | 93/99 | 93.9% | 100.0% | | `src/plot/ticks.cpp` | 105/111 | 94.6% | 100.0% |
| `src/render/buffer.cpp` | 88/98 | 89.8% | 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/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/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.cpp` | 27/30 | 90.0% | 100.0% |
| `src/render/text_atlas.cpp` | 40/41 | 97.6% | 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/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%** |
<!-- END GENERATED: tools/coverage.sh --> <!-- END GENERATED: tools/coverage.sh -->
+74 -12
View File
@@ -2,20 +2,27 @@
## The sample stores ## The sample stores
All three series derive from `plot::data::Series` (`include/plot/data/series.hpp`), which All 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()`. 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 ```cpp
class Series { class Series {
public: public:
virtual u64 size() const; virtual u64 size() const;
virtual f64 x0() const; // x of sample 0 (the oldest retained sample) 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 x_bounds() const;
virtual Range y_bounds() const; virtual Range y_bounds() const;
virtual const MinMaxPyramid& pyramid() const; virtual const MinMaxPyramid& pyramid() const;
virtual bool streaming() const noexcept; // default false virtual bool streaming() const noexcept; // default false
virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& 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<f32>& out_xy,
GatherMode mode = GatherMode::Envelope) const;
Range reduce(u64 begin, u64 end) const; // exact min/max over [begin, end) 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) 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<f32>` of y samples plus an **`ArraySeries`** (`array_series.hpp`) owns a `std::vector<f32>` of y samples plus an
`x0`/`dx` grid. Construct with `ArraySeries(std::vector<f32> y, f64 x0, f64 dx)`. The vector `x0`/`dx` grid. Construct with `ArraySeries(std::vector<f32> y, f64 x0, f64 dx)`. The vector
is taken by value and owned, so the samples cannot change behind the series' back; the store 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()`, 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`. 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 **`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 ## 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 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 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. 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 - An entry is 32 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 over and the finite-sample `count` the mean folds by), so level 0 costs `0.5n` bytes and
about **`0.75n` bytes — roughly 19% of the `4n` bytes of raw samples** (`f32`). A the geometric tail brings the whole pyramid to about **`1n` bytes — roughly 25% of the
min/max-only index would be `0.25n` (~6%); the two doubles are the price of answering `4n` bytes of raw samples** (`f32`). A min/max-only index would be `0.25n` (~6%); the
mean/RMS/integral from whole buckets instead of raw samples. 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`, 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 `sum_buckets`, `sumsq_buckets`): a pyramid stores aggregates, so it can only answer exactly
+98 -13
View File
@@ -41,6 +41,48 @@ scale:
| Qt6 | `devicePixelRatioF()` | re-read every `paintGL()` | | Qt6 | `devicePixelRatioF()` | re-read every `paintGL()` |
| GTK4 | `mapping_.factor` = device size / logical allocation | the two sizes `::resize` reports, not `gtk_widget_get_scale_factor()` | | 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=<ms>` (stop the **Headless verification.** Every demo supports `PLOT_DEMO_EXIT_AFTER_MS=<ms>` (stop the
loop after that long) and `PLOT_DEMO_SCREENSHOT=<path>` (write the final frame as a PPM). loop after that long) and `PLOT_DEMO_SCREENSHOT=<path>` (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 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 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 `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 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. Wheel from the key state, because GLFW's pointer and scroll callbacks carry no mask, and
notches are forwarded as `WheelEvent`, and keys as `KeyEvent`. `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, 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 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 one hover over every row (`sync_figure_crosshair`), since only the host knows which row the
pointer is in. 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 `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 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. 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 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 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 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 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 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` / Input translation: Qt reports press/release edges directly, so `mousePressEvent` /
`mouseMoveEvent` / `mouseReleaseEvent` / `mouseDoubleClickEvent` / `wheelEvent` / `mouseMoveEvent` / `mouseReleaseEvent` / `mouseDoubleClickEvent` / `wheelEvent` /
`keyPressEvent` become `PointerEvent`/`WheelEvent`/`KeyEvent`. Every position is multiplied `keyPressEvent` become `PointerEvent`/`WheelEvent`/`KeyEvent`, each stamped with
by `devicePixelRatioF()`; the *anchor* a move's delta is measured from is kept in logical `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 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 moved rather than a cross-scale jump (`PointerAnchor`, `qt_pointer.hpp`). The wheel is
`angleDelta()` is in eighths of a degree (120 per notch) and is converted; a wheel with no converted from eighths of a degree, 120 to a notch; when a device reports
vertical component is refused before it reaches the controller. `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 `<QOpenGLWidget>`, Include-order note: this header includes `plot/gl/api.hpp` before `<QOpenGLWidget>`,
because Qt reaches `<GL/gl.h>` whose macros would rewrite `plot::gl`'s enumerators. A because Qt reaches `<GL/gl.h>` 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. 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, 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 hover (forwarded with `down == false`, so a drag is not applied twice), and
`GtkEventControllerScroll` (vertical) and `GtkEventControllerKey` supply the wheel and the `GtkEventControllerScroll` (vertical) and `GtkEventControllerKey` supply the wheel and
`+`/`-`/`f` keys. GTK emits `drag-begin` before the click's `pressed`, so the adapter opens 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 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). render after every input event that can change the picture (not on hover).
+26 -2
View File
@@ -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 `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. 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 `<plot/version.hpp>`, 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 <plot/version.hpp>
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<includedir> -L<libdir> -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: Every public component is reachable through the umbrella header:
```cpp ```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 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). 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 ```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 $(pkg-config --libs glfw3) -ldl -lpthread -o plot_quickstart
``` ```
@@ -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<f32>& 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).
+11 -1
View File
@@ -22,6 +22,10 @@ class ArraySeries final : public Series {
void reset(std::vector<f32> y, f64 x0, f64 dx); void reset(std::vector<f32> y, f64 x0, f64 dx);
/// Rebuilds the pyramid and cached bounds from y_ as it stands now. /// Rebuilds the pyramid and cached bounds from y_ as it stands now.
void commit(); 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; u64 size() const override;
f64 x0() const override; f64 x0() const override;
@@ -29,7 +33,8 @@ class ArraySeries final : public Series {
Range x_bounds() const override; Range x_bounds() const override;
Range y_bounds() const override; Range y_bounds() const override;
const MinMaxPyramid& pyramid() const override; const MinMaxPyramid& pyramid() const override;
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const override; void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
GatherMode mode = GatherMode::Envelope) const override;
protected: protected:
void copy_raw(u64 begin, u64 end, f32* out) const override; void copy_raw(u64 begin, u64 end, f32* out) const override;
@@ -41,6 +46,11 @@ class ArraySeries final : public Series {
MinMaxPyramid pyramid_; MinMaxPyramid pyramid_;
Range x_bounds_{}; Range x_bounds_{};
Range y_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 } // namespace plot::data
+36 -11
View File
@@ -19,6 +19,12 @@ struct MinMax {
/// series and the level maths is a shift. /// series and the level maths is a shift.
inline constexpr u32 kBucket = 64; 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. /// Incremental min/max LOD pyramid, indexed in absolute bucket space.
/// ///
/// Level `l` bucket `k` covers the absolute samples /// 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 /// `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 /// 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. /// 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 /// A bucket entry is 32 bytes -- `MinMax` plus the exact `sum`/`sumsq` the exact
/// range queries fold over -- so level 0 costs 0.375n bytes and the geometric tail /// range queries fold over and the finite-sample `count` the mean folds by -- so
/// brings the whole pyramid to about 0.75n bytes, ~19% of the 4n bytes of raw /// level 0 costs 0.5n bytes and the geometric tail brings the whole pyramid to
/// samples. (A min/max-only index would be 0.25n, ~6%: the two doubles are the /// about 1n bytes, ~25% of the 4n bytes of raw samples. (A min/max-only index
/// price of answering mean/RMS/integral from whole buckets instead of raw /// would be 0.25n, ~6%: the extra words are the price of answering mean/RMS/
/// samples.) Every stored entry is the complete aggregate of its own span and /// integral from whole buckets instead of raw samples.) Every stored entry is
/// every stored span lies inside the retained window, so no aggregate is ever /// the complete aggregate of its own span and every stored span lies inside the
/// recomputed or invalidated. /// retained window, so no aggregate is ever recomputed or invalidated.
/// ///
/// Queries are in *window* coordinates: index 0 is the oldest retained sample, /// Queries are in *window* coordinates: index 0 is the oldest retained sample,
/// `size()` is the retained sample count, and `window_base()` is the absolute /// `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 /// appends never grows the level vector. The bucket deques themselves grow in
/// chunks: a sliding window cannot know their final length up front. /// chunks: a sliding window cannot know their final length up front.
void reserve(u64 n); 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); void append(f32 v);
/// Slides the retained window's leading edge to absolute sample index /// Slides the retained window's leading edge to absolute sample index
/// `absolute_sample`, dropping every aggregate that covered a sample before it. /// `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: /// Aggregate of the absolute level-`l` bucket `i`. Precondition:
/// level_base(l) <= i < level_base(l) + level_size(l). /// level_base(l) <= i < level_base(l) + level_size(l).
MinMax bucket(u32 l, u64 i) const; 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 /// Min/max over the whole level-0 buckets fully contained in the window range
/// [begin, end). Partial edge buckets are excluded, never approximated; an /// [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; u64 snap(f64 x, f64 x0, f64 dx) const;
private: private:
/// One bucket: its extremes plus its own exact sum and sum of squares. The two /// One bucket: its extremes plus its own exact sum and sum of squares, and
/// doubles take a bucket from 8 bytes to 24, which is what lets mean/RMS/ /// how many finite samples produced them. The extra words take a bucket from
/// integral come from whole buckets instead of raw samples. /// 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 { struct Entry {
MinMax mm; MinMax mm;
f64 sum = 0.0; f64 sum = 0.0;
f64 sumsq = 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. /// One level's complete buckets, in absolute bucket-index order.
struct Level { struct Level {
+7 -2
View File
@@ -12,8 +12,13 @@ namespace plot::data {
/// Single-producer / single-consumer ring of at most `capacity` elements. /// 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 /// The capacity is rounded up to a power of two -- `capacity()` reports the
/// instead of a modulo. The producer writes a slot and then publishes the new /// *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 /// 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. /// acquire load is guaranteed to see the slot contents written before it.
/// ///
+61 -15
View File
@@ -9,6 +9,20 @@
namespace plot::data { 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(). /// 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 /// 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. /// so the series that owns the sliding window is the one that answers.
virtual bool streaming() const noexcept { return false; } 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) /// **x is relative to `x0()`**: the emitted value is `x_at(i) - x0()` as
/// envelope pair per *complete* level bucket wholly inside the retained window, /// f32, never the absolute coordinate. A consumer reconstructs the absolute
/// bounded by the two partial buckets at the edges of the requested range — the /// x in f64 (`x0() + emitted`) before mapping it to the screen; staging an
/// bucket the window starts inside and the bucket the newest samples are in — /// absolute epoch-time x as f32 would quantise it to a grid coarser than
/// which it emits as raw samples through copy_raw(). A bucket that is only partly /// the pixel (f32 carries 24 bits -- 1 s at 1.7e9), while the relative
/// retained holds aggregates of samples the window no longer has and a partial /// value `i*dx` is small and exact. y is unchanged.
/// trailing bucket is not stored at all, so neither may be drawn as an envelope.
/// ///
/// The emitted geometry therefore always spans [begin, end) exactly, for at most /// Level 0 emits the raw samples of [begin, end). Level >= 1 emits one
/// two buckets of extra raw work (about two pixels at the level level_for() /// geometry element per *complete* level bucket wholly inside the retained
/// picks), and never reaches outside the retained window. Pairs are appended to /// window, bounded by the two partial buckets at the edges of the requested
/// out_xy, which is never cleared. /// range -- the bucket the window starts inside and the bucket the newest
virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const = 0; /// 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<f32>& 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). /// Exact min/max, sum and sum-of-squares over [begin, end).
/// Preconditions: begin < end, end <= size(); a degenerate range yields a /// 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; 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, /// The shared body of the concrete gather() overrides: raw samples at level 0,
/// bucket min/max envelope above it. /// bucket geometry above it (per `mode`).
void gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const; void gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
GatherMode mode) const;
private: private:
/// [begin, end) split into the bucket-aligned interior the pyramid can answer /// [begin, end) split into the bucket-aligned interior the pyramid can answer
+19 -1
View File
@@ -5,6 +5,7 @@
#include <vector> #include <vector>
#include <plot/core/types.hpp> #include <plot/core/types.hpp>
#include <plot/data/array_series.hpp>
#include <plot/data/minmax_pyramid.hpp> #include <plot/data/minmax_pyramid.hpp>
#include <plot/data/ring_buffer.hpp> #include <plot/data/ring_buffer.hpp>
#include <plot/data/series.hpp> #include <plot/data/series.hpp>
@@ -57,6 +58,11 @@ namespace plot::data {
/// class specifies. /// class specifies.
class StreamSeries final : public Series { class StreamSeries final : public Series {
public: 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); explicit StreamSeries(u64 capacity, f64 x0, f64 dx);
/// Producer thread only. /// Producer thread only.
@@ -68,6 +74,17 @@ class StreamSeries final : public Series {
/// Consumer thread only: snapshots the produced count and brings the pyramid /// Consumer thread only: snapshots the produced count and brings the pyramid
/// and bounds up to it. /// and bounds up to it.
void acquire(); 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; u64 size() const override;
f64 x0() const override; f64 x0() const override;
@@ -75,7 +92,8 @@ class StreamSeries final : public Series {
Range x_bounds() const override; Range x_bounds() const override;
Range y_bounds() const override; Range y_bounds() const override;
const MinMaxPyramid& pyramid() const override; const MinMaxPyramid& pyramid() const override;
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const override; void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
GatherMode mode = GatherMode::Envelope) const override;
/// Always true: this series' `x_bounds()` is the retained ring window, which /// Always true: this series' `x_bounds()` is the retained ring window, which
/// slides with every `acquire()` -- the property `XFollow::Live` follows. /// slides with every `acquire()` -- the property `XFollow::Live` follows.
bool streaming() const noexcept override { return true; } bool streaming() const noexcept override { return true; }
+87
View File
@@ -0,0 +1,87 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <vector>
#include <plot/core/types.hpp>
#include <plot/data/minmax_pyramid.hpp>
#include <plot/data/series.hpp>
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<f64> x, std::vector<f32> y);
/// Replaces both arrays, then rebuilds derived state (== commit()). Same
/// preconditions as the constructor.
void reset(std::vector<f64> x, std::vector<f32> 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<f32>& 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<f64>& x() const noexcept { return x_; }
protected:
void copy_raw(u64 begin, u64 end, f32* out) const override;
private:
std::vector<f64> x_;
std::vector<f32> 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
+8
View File
@@ -58,8 +58,14 @@ enum : GLenum {
// Blending. // Blending.
GL_BLEND = 0x0BE2, GL_BLEND = 0x0BE2,
GL_ZERO = 0x0000,
GL_ONE = 0x0001,
GL_SRC_ALPHA = 0x0302, GL_SRC_ALPHA = 0x0302,
GL_ONE_MINUS_SRC_ALPHA = 0x0303, 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. // Pixel store state.
GL_UNPACK_ALIGNMENT = 0x0CF5, GL_UNPACK_ALIGNMENT = 0x0CF5,
@@ -127,7 +133,9 @@ enum : GLenum {
X(void, glClearColor, (float, float, float, float)) \ X(void, glClearColor, (float, float, float, float)) \
X(void, glClear, (GLbitfield)) \ X(void, glClear, (GLbitfield)) \
X(void, glEnable, (GLenum)) \ X(void, glEnable, (GLenum)) \
X(GLboolean, glIsEnabled, (GLenum)) \
X(void, glBlendFunc, (GLenum, GLenum)) \ X(void, glBlendFunc, (GLenum, GLenum)) \
X(void, glBlendFuncSeparate, (GLenum, GLenum, GLenum, GLenum)) /* 1.4 */ \
X(void, glPixelStorei, (GLenum, GLint)) \ X(void, glPixelStorei, (GLenum, GLint)) \
X(void, glDisable, (GLenum)) \ X(void, glDisable, (GLenum)) \
X(void, glScissor, (GLint, GLint, GLsizei, GLsizei)) \ X(void, glScissor, (GLint, GLint, GLsizei, GLsizei)) \
+8 -2
View File
@@ -36,7 +36,10 @@ class Handle {
/// already used, so state that captured the old object -- the attribute /// already used, so state that captured the old object -- the attribute
/// pointers of a vertex array above all -- must compare this counter rather /// pointers of a vertex array above all -- must compare this counter rather
/// than the name (see `render::detail::InstanceStore::epoch()`). /// 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. /// Deletes the GL object, if any, and empties the handle.
void reset() noexcept { void reset() noexcept {
@@ -73,7 +76,7 @@ class Handle {
GLuint id_ = 0; GLuint id_ = 0;
private: private:
u32 generation_ = 0; u64 generation_ = 0;
}; };
/// Vertex buffer object. All fallible operations report through `Status`; /// Vertex buffer object. All fallible operations report through `Status`;
@@ -172,6 +175,9 @@ class Texture final : public Handle<Texture> {
bool create_r8(u32 width, u32 height, Status* st = nullptr) noexcept; bool create_r8(u32 width, u32 height, Status* st = nullptr) noexcept;
/// Replaces the whole image with `width * height * 4` bytes of RGBA data. /// 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; void upload_rgba(u32 width, u32 height, const void* pixels) const noexcept;
/// Replaces the whole image with `width * height` bytes of single-channel /// Replaces the whole image with `width * height` bytes of single-channel
+80 -4
View File
@@ -3,6 +3,8 @@
#pragma once #pragma once
#include <vector>
#include <plot/core/rect.hpp> #include <plot/core/rect.hpp>
#include <plot/core/types.hpp> #include <plot/core/types.hpp>
#include <plot/input/events.hpp> #include <plot/input/events.hpp>
@@ -47,14 +49,21 @@ using plot::RegionGrab;
/// | gesture | mode | effect | /// | gesture | mode | effect |
/// |---|---|---| /// |---|---|---|
/// | shift + left drag | `BoxZoom` | both ranges from the selection rect | /// | 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 on a handle | `MoveCursor` / `MoveRegion` / `ResizeRegion*` | move the handle the press grabbed |
/// | left drag elsewhere | `Pan` | translate both ranges | /// | left drag elsewhere | `Pan` | translate both ranges |
/// | right drag | `Pan` | same, for hosts that reserve left | /// | right drag | `Pan` | same, for hosts that reserve left |
/// | middle drag on the x label strip | `AxisZoomX` | x-only zoom | /// | middle drag on the x label strip | `AxisZoomX` | x-only zoom |
/// | middle drag on the y label strip | `AxisZoomY` | y-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 (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 | /// | `+` / `-` | -- | zoom about the plot centre |
/// | `f` | -- | auto-fit every axis | /// | `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 /// 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 /// 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, /// 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 /// 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 /// 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 /// keeps panning. `Ctrl` and `Alt` no longer fall through to that press: they are
/// what a press on one does; it only means the same thing it always did (a press /// the modifier box zoom's masks now (see the gesture table -- Ctrl frames the x
/// away from a handle pans, with or without it). /// 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: /// 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 /// 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 /// 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 /// 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_wheel(const WheelEvent& e);
void on_key(const KeyEvent& 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_; } DragMode drag_mode() const noexcept { return mode_; }
/// The current box-zoom selection in pixels; meaningful while BoxZoom. /// The current box-zoom selection in pixels; meaningful while BoxZoom.
@@ -237,6 +256,35 @@ class Controller {
void update_drag(const PointerEvent& e); void update_drag(const PointerEvent& e);
void end_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 pan(const PointerEvent& e);
void box_zoom_update(const PointerEvent& e); void box_zoom_update(const PointerEvent& e);
void apply_box_zoom(); void apply_box_zoom();
@@ -317,6 +365,34 @@ class Controller {
Rect selection_{}; Rect selection_{};
f32 origin_x_ = 0.0f; f32 origin_x_ = 0.0f;
f32 origin_y_ = 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<SavedX> saved_x_;
std::vector<SavedY> 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; u64 drag_cursor_ = kInvalid;
/// The region drag's state. `drag_region_` is the region and `drag_zone_` the zone /// 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 /// the press resolved, kept for the whole gesture so the highlight a grab started
+31
View File
@@ -35,6 +35,26 @@ struct PointerEvent {
bool down = false; ///< the button is held after this event bool down = false; ///< the button is held after this event
bool pressed = false; ///< a press transition happened on this event bool pressed = false; ///< a press transition happened on this event
bool released = false; ///< a release 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 /// 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. /// Hosts that report shifted keys uppercase can pass this unchanged.
inline constexpr int kKeyFitUpper = 'F'; 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 } // namespace plot::input
+7
View File
@@ -37,6 +37,7 @@
#include <plot/data/ring_buffer.hpp> #include <plot/data/ring_buffer.hpp>
#include <plot/data/series.hpp> #include <plot/data/series.hpp>
#include <plot/data/stream_series.hpp> #include <plot/data/stream_series.hpp>
#include <plot/data/xy_series.hpp>
#include <plot/render/embedded_mono_font.hpp> #include <plot/render/embedded_mono_font.hpp>
#include <plot/render/buffer.hpp> #include <plot/render/buffer.hpp>
@@ -64,3 +65,9 @@
#include <plot/input/controller.hpp> #include <plot/input/controller.hpp>
#include <plot/input/events.hpp> #include <plot/input/events.hpp>
// 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 <plot/version.hpp>
+15 -9
View File
@@ -56,15 +56,21 @@ struct Axis {
/// a y axis has no live edge -- and `Off` on both, so nothing changes for a /// a y axis has no live edge -- and `Off` on both, so nothing changes for a
/// caller who never asks for it. See XFollow. /// caller who never asks for it. See XFollow.
XFollow follow = XFollow::Off; XFollow follow = XFollow::Off;
/// How far behind the live edge the visible window's right edge sits, in data /// How far behind the live edge the visible window's right edge sits, in **axis
/// units, under `XFollow::Live`. Stored rather than implied by the range, /// space**, under `XFollow::Live`: data units on a linear axis, `log10` decades
/// because the range is recomputed from the series' newest x on every frame: /// on a logarithmic one -- the same space the controller's pan/zoom gestures run
/// this is the part of the view the user chose, so it is the part that survives /// in (`to_space`/`from_space`), so a log axis scrolls and holds *decades* the
/// the recompute. Three things write it, all of them through one clamp: a pan or /// way a linear axis scrolls and holds units. (`to_space(live.max) -
/// a zoom through `Plot::set_x_range()`, which derives it from the range the /// to_space(hi)` is the definition: the distance the axis' own transform
/// gesture asked for, `Plot::set_x_offset()` for a caller that states it /// measures between the live edge and the window's right edge.) Stored rather
/// directly, and `Plot::layout()`'s placement, which re-clamps it against the /// than implied by the range, because the range is recomputed from the series'
/// retained window every frame. `Plot::x_offset()` reads it back. /// 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; f64 offset = 0.0;
/// Set by an *owner* that is following this axis' window on the plot's behalf: a /// 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. /// `Figure` link group, while it follows, writes its own live edge here every frame.
+10 -2
View File
@@ -152,8 +152,16 @@ class Plot {
// --- viewport, style, text metrics ------------------------------------ // --- viewport, style, text metrics ------------------------------------
/// The rectangle this plot occupies on screen, in framebuffer pixels. /// The rectangle this plot occupies on screen, in framebuffer pixels. A
void set_viewport(Rect px) noexcept { viewport_ = px; } /// 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_; } const Rect& viewport() const noexcept { return viewport_; }
/// Set from the renderer, which owns presentation: `Renderer::draw()` copies /// Set from the renderer, which owns presentation: `Renderer::draw()` copies
+4 -2
View File
@@ -68,8 +68,10 @@ inline constexpr usize kMaxTicks = 256;
/// `include_minors` adds, for grid geometry, the minor ticks subdividing each /// `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` /// 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 /// 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 /// numbers for, with no unlabelled entries to filter. The cap counts **minor**
/// capped at kMaxTicks. /// 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` /// Hostile input clears `out` and returns: a non-finite endpoint, `max <= min`
/// (a zero or negative span, which includes the collapsed range of a /// (a zero or negative span, which includes the collapsed range of a
+35 -2
View File
@@ -9,6 +9,16 @@
namespace plot { 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 /// 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. /// the plot's use of it; `axis_x`/`axis_y` index the plot's x and y axis lists.
struct TraceDesc { struct TraceDesc {
@@ -16,7 +26,8 @@ struct TraceDesc {
Color color = Color::hex(0x4FA3FF); Color color = Color::hex(0x4FA3FF);
/// The stroke's width in **logical** pixels, like every stroke in `Style` (see /// 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 /// 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; f32 width = 1.5f;
u32 axis_x = 0, axis_y = 0; u32 axis_x = 0, axis_y = 0;
/// What the trace is called, for the legend. Empty means "do not list it", which /// 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 /// unchanged, exactly as `AxisDesc`'s trailing format does (and, in a build with
/// `-Wextra`, warning-clean). /// `-Wextra`, warning-clean).
std::string name{}; 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 /// 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; const data::Series* series = nullptr;
Color color = Color::hex(0x4FA3FF); Color color = Color::hex(0x4FA3FF);
/// Logical pixels, as in `TraceDesc::width`: converted to device pixels by the /// 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; f32 width = 1.5f;
u32 axis_x = 0, axis_y = 0; u32 axis_x = 0, axis_y = 0;
/// The legend's entry for this trace, empty when it has none. A name does not /// 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 /// 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. /// null series is skipped there exactly as it is by every other consumer.
std::string name{}; 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. /// True when the trace has a series with at least one sample to draw.
bool visible() const noexcept { return series != nullptr && series->size() > 0; } bool visible() const noexcept { return series != nullptr && series->size() > 0; }
+15 -8
View File
@@ -35,10 +35,11 @@ struct LineInstance {
/// rect onto the two the same way round, so a cell is never mirrored. `(1, 0)` /// 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 /// 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. /// 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 /// Ten floats, still one instanced draw call per frame.
/// frame with rotation free: a matrix per glyph would be three times the bytes
/// for the same pixels.
struct TextInstance { struct TextInstance {
f32 x; f32 x;
f32 y; f32 y;
@@ -46,10 +47,14 @@ struct TextInstance {
f32 v; f32 v;
f32 ax; f32 ax;
f32 ay; 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(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 /// How a store's staged instances reach the GPU. Semantics are identical either
/// way -- only the transfer differs, and `Renderer::init()` picks one from /// 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_` /// 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 /// 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. /// 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(); } void bind() const noexcept { buffer_.bind(); }
bool persistent() const noexcept { return mode_ == UploadMode::PersistentMapped; } bool persistent() const noexcept { return mode_ == UploadMode::PersistentMapped; }
@@ -165,7 +172,7 @@ class InstanceStore {
u32 frame_uploads_ = 0; u32 frame_uploads_ = 0;
/// Times an upload landed in a new slot. Kept apart from the buffer's own /// 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. /// 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 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 /// Guards every slot the frame just ended wrote. Recorded once, after its last
/// draw; waited on once, at the start of the next frame. /// 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(); } bool mark_drawn() noexcept { return store_.mark_drawn(); }
usize slot_offset() const noexcept { return store_.slot_offset(); } 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(); } bool persistent() const noexcept { return store_.persistent(); }
void bind() const noexcept { store_.bind(); } void bind() const noexcept { store_.bind(); }
@@ -206,7 +213,7 @@ class TextBuffer {
} }
bool mark_drawn() noexcept { return store_.mark_drawn(); } bool mark_drawn() noexcept { return store_.mark_drawn(); }
usize slot_offset() const noexcept { return store_.slot_offset(); } 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(); } bool persistent() const noexcept { return store_.persistent(); }
void bind() const noexcept { store_.bind(); } void bind() const noexcept { store_.bind(); }
+10 -8
View File
@@ -2,6 +2,7 @@
// SPDX-FileCopyrightText: 2026 Martino Ferrari // SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <plot/core/color.hpp>
#include <plot/core/types.hpp> #include <plot/core/types.hpp>
#include <plot/math/vec2.hpp> #include <plot/math/vec2.hpp>
#include <plot/render/text.hpp> #include <plot/render/text.hpp>
@@ -60,7 +61,7 @@ class LineBatch {
/// Like `LineBatch` it keeps its capacity across frames. Each staged glyph is /// 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 /// 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 /// 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). /// buffer.hpp).
/// ///
/// Every glyph a batch stages is read through one atlas rect of one size — its /// 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 /// rasterised them. `axis` is the direction the run advances in, in pixel
/// space; it rotates the cell (see `TextInstance`), and is normalised here, so /// 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". /// 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, /// `color` tints this run's glyphs (per-vertex `a_colour`, see `TextInstance`).
Vec2 axis = {1.0f, 0.0f}); 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 /// 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 /// 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()` /// `next_codepoint()` and `glyph()`, so a run stages exactly what `measure()`
/// summed. A codepoint the backend has no glyph for stages nothing and moves /// 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 /// 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 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. /// Glyphs staged so far, one instance each.
u32 glyph_count() const noexcept { return static_cast<u32>(v_.size() / 6u); } u32 glyph_count() const noexcept { return static_cast<u32>(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<f32>& data() const noexcept { return v_; } const std::vector<f32>& data() const noexcept { return v_; }
/// The cell the staged glyphs are drawn in: what `set_cell()` declared, or -- /// The cell the staged glyphs are drawn in: what `set_cell()` declared, or --
@@ -124,7 +126,7 @@ class TextBatch {
private: private:
/// Stages one cell whose basis the caller has already normalised, so a run and /// Stages one cell whose basis the caller has already normalised, so a run and
/// the pen it advances share one rounding of the axis. /// 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<f32> v_; std::vector<f32> v_;
Vec2 cell_uv_{}; Vec2 cell_uv_{};
+5 -2
View File
@@ -35,6 +35,9 @@ class LineProgram {
/// discard on the interpolated `v_along`, so it costs no geometry, and it is the one uniform /// 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. /// that distinguishes the crosshair's stroke from every other line in the library.
void set_dash(Vec2 on_off) const noexcept; 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. /// `u_color`: stroke colour, alpha included.
void set_color(Color color) const noexcept; void set_color(Color color) const noexcept;
@@ -66,8 +69,8 @@ class TextProgram {
void set_glyph_uv(Vec2 uv) const noexcept; void set_glyph_uv(Vec2 uv) const noexcept;
/// `u_tex_size`: atlas size in texels. /// `u_tex_size`: atlas size in texels.
void set_texture_size(Vec2 texels) const noexcept; void set_texture_size(Vec2 texels) const noexcept;
/// `u_color`: tint colour, alpha included. /// The tint is per instance (`a_colour`), not a uniform, so a draw can carry
void set_color(Color color) const noexcept; /// runs of several colours -- there is deliberately no colour setter here.
private: private:
gl::Program program_; gl::Program program_;
+75 -17
View File
@@ -31,6 +31,7 @@ struct FrameStats {
u32 lod_traces = 0; ///< traces drawn from a min/max pyramid level u32 lod_traces = 0; ///< traces drawn from a min/max pyramid level
u64 visible_samples = 0; ///< samples the gathered windows covered, summed u64 visible_samples = 0; ///< samples the gathered windows covered, summed
u32 staged_segments = 0; ///< line segments staged, all colour groups 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 u32 draw_calls = 0; ///< draw calls issued, line and text
}; };
@@ -70,10 +71,26 @@ struct FrameStats {
class Renderer { class Renderer {
public: public:
Renderer() noexcept = default; 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(const Renderer&) = delete;
Renderer& operator=(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 /// Compiles both programs, uploads the font's atlas and allocates the vertex
/// buffers. The calling thread must have the context current and /// buffers. The calling thread must have the context current and
/// `plot::gl::load()` must have succeeded, otherwise the returned `Status` is /// `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: /// 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 /// 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). /// 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*: /// 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 /// 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 /// top-left corner is the origin (the shaders emit NDC relative to the GL
/// viewport, not the framebuffer). /// 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 { struct Mapping {
Vec2 scale{1.0f, 1.0f}; Vec2 scale{1.0f, 1.0f};
Vec2 offset{0.0f, 0.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. /// The identity mapping for geometry already staged in viewport pixels.
Mapping pixel_mapping() const noexcept; Mapping pixel_mapping() const noexcept;
/// Shifts one framebuffer-space pixel position into viewport space. /// 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 /// `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 /// 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 /// crosshair passes and a dashed trace -- means **solid**. It is set on every flush,
/// can never leak into the next. /// 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; 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 /// Draws every staged glyph in `batch` -- each instance carries its own tint
/// is texture-bound, so this runs once per frame, after all line geometry. /// (`a_colour`) -- and empties the batch. Text is texture-bound, so this runs
void flush_text() noexcept; /// 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 /// 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 /// `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 /// measurement band fills, is the wrong primitive here). A rect with no width or
/// no height stages nothing. /// no height stages nothing.
void fill_pixels(const Rect& rect, Color color); void fill_pixels(const Rect& rect, Color color);
/// Stages one data-space segment, taking the two axes' log flags into account. /// Stages one data-space segment as f32 values **relative to `origin_x`** in
void stage_segment(f64 x0, f64 y0, f64 x1, f64 y1, bool log_x, bool log_y); /// the axis' pre-affine space (`log10` of it on a log axis): the mapping folds
/// Stages `n` interleaved `x,y` data-space points (a gathered window). /// `origin_x` back in, in f64, before the single f32 narrowing (B1), so a
void stage_xy(const f32* xy, u32 n, bool log_x, bool log_y); /// large-magnitude x -- an epoch axis -- never loses sub-pixel precision.
/// Lays one run of text out at a framebuffer position and stages it, shifting void stage_segment(f64 origin_x, f64 x0, f64 y0, f64 x1, f64 y1, bool log_x, bool log_y);
/// it into viewport space. Returns the pen past the run. /// Stages `n` interleaved `x,y` points of a gathered window (see
Vec2 stage_text(Vec2 pos_px, const char* utf8); /// `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 /// 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()` /// the size the text pipeline draws with: exactly what `stage_text_clipped()`
/// stages. /// stages.
@@ -430,7 +463,8 @@ class Renderer {
/// horizontal x title, which spends the strip's width, and (0,-1) for the y /// 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 /// 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. /// 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 /// 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 /// 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. /// 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); 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: /// 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 /// `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 /// when a host passes null. Every use of the face goes through the
@@ -549,8 +589,16 @@ class Renderer {
render::TextVertexArray text_vao_; render::TextVertexArray text_vao_;
render::LineBatch lines_; 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<f32> gather_; ///< scratch for one gathered window, re-used std::vector<f32> gather_; ///< scratch for one gathered window, re-used
std::vector<f32> staged_; ///< scratch for one staged window, re-used
mutable std::vector<u8> readback_; ///< dump_frame_ppm's row buffer mutable std::vector<u8> readback_; ///< dump_frame_ppm's row buffer
/// The host's `GL_COLOR_CLEAR_VALUE`, queried on the first `begin_frame()` (GL /// The host's `GL_COLOR_CLEAR_VALUE`, queried on the first `begin_frame()` (GL
@@ -559,6 +607,16 @@ class Renderer {
Color host_clear_{}; Color host_clear_{};
bool host_clear_saved_ = false; 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 viewport_; ///< the frame's region, top-left origin, framebuffer px
Rect gl_viewport_; ///< the same rect flipped for GL, and for readback Rect gl_viewport_; ///< the same rect flipped for GL, and for readback
u32 fb_w_ = 0; u32 fb_w_ = 0;
+6 -3
View File
@@ -16,7 +16,9 @@ namespace plot::render {
/// `v_along`, the distance travelled from `a_p0` in pixels. /// `v_along`, the distance travelled from `a_p0` in pixels.
/// ///
/// Uniforms: `u_scale` (vec2, data space to pixels), `u_offset` (vec2, 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 /// `plot::Rect` is top-left origin while GL is bottom-left, so the y axis is
/// flipped exactly once, here. /// 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 /// 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 /// turned. The fragment shader converts back to texels and samples the
/// single-channel `GL_R8` atlas at the nearest texel centre, so `GL_NEAREST` /// 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 /// 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 /// 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 /// 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 /// 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 kTextVS;
extern const char* const kTextFS; extern const char* const kTextFS;
+6
View File
@@ -102,6 +102,12 @@ struct Style {
bool grid_minor_visible = true; ///< stage the minor grid at all. 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 f32 line_width_px = 1.5f; ///< logical width of the strokes framing the data
///< (axis lines, cursor markers). ///< (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 /// 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 /// 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 /// as -- the latter sized by `Plot::layout()` with this same metric, so the two
+6 -5
View File
@@ -54,20 +54,21 @@ class LineVertexArray {
gl::VertexArray vao_; gl::VertexArray vao_;
u32 instances_ = 0; u32 instances_ = 0;
/// Instance-buffer generation the attribute pointers currently point into. /// 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 /// 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 /// (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, /// location 1, its rotation basis `a_axis` at location 2 and the run's tint
/// every one advancing once per instance. The layout is the one `TextInstance` /// `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 /// declares and shaders.hpp consumes; a caller never writes a location, stride or
/// offset, it stages a `TextBatch` and uploads its `data()`. /// offset, it stages a `TextBatch` and uploads its `data()`.
class TextVertexArray { class TextVertexArray {
public: public:
bool create(Status* st = nullptr, UploadMode mode = UploadMode::Orphan) noexcept; 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 upload(const f32* interleaved, u32 glyphs) noexcept;
void bind() const noexcept { vao_.bind(); } void bind() const noexcept { vao_.bind(); }
@@ -84,7 +85,7 @@ class TextVertexArray {
TextBuffer buffer_; TextBuffer buffer_;
gl::VertexArray vao_; gl::VertexArray vao_;
u32 instances_ = 0; u32 instances_ = 0;
u32 epoch_ = 0; u64 epoch_ = 0;
}; };
} // namespace plot::render } // namespace plot::render
+28
View File
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
// Template for <plot/version.hpp>: configure_file() expands it into
// <build>/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
+26
View File
@@ -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 <build>/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}
+30
View File
@@ -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 <glfw/glfw_host.hpp> // the optional GLFW adapter
#include <plot/plot.hpp>
#include <cmath>
#include <vector>
int main() {
plot::glfw::Host host;
plot::Plot& p = host.plot();
std::vector<plot::f32> y(512);
for (plot::usize i = 0; i < y.size(); ++i)
y[i] = static_cast<plot::f32>(std::sin(0.05 * static_cast<double>(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;
}
+33 -9
View File
@@ -4,6 +4,7 @@
#include <plot/data/array_series.hpp> #include <plot/data/array_series.hpp>
#include <algorithm> #include <algorithm>
#include <cmath>
#include <cstddef> #include <cstddef>
#include <utility> #include <utility>
@@ -23,23 +24,45 @@ void ArraySeries::commit() {
pyramid_.clear(); pyramid_.clear();
pyramid_.reserve(n); 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; f32 lo = 0.0f, hi = 0.0f;
bool first = true; bool finite = false;
for (const f32 v : y_) { for (const f32 v : y_) {
pyramid_.append(v); pyramid_.append(v);
if (first) { if (!std::isfinite(v)) continue;
lo = v; if (finite) {
hi = v;
first = false;
} else {
lo = std::min(lo, v); lo = std::min(lo, v);
hi = std::max(hi, 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_}; 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<u64>(y_.size()); } u64 ArraySeries::size() const { return static_cast<u64>(y_.size()); }
f64 ArraySeries::x0() const { return x0_; } 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_; } const MinMaxPyramid& ArraySeries::pyramid() const { return pyramid_; }
void ArraySeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const { void ArraySeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
gather_impl(level, begin, end, out_xy); GatherMode mode) const {
gather_impl(level, begin, end, out_xy, mode);
} }
void ArraySeries::copy_raw(u64 begin, u64 end, f32* out) const { void ArraySeries::copy_raw(u64 begin, u64 end, f32* out) const {
+39 -14
View File
@@ -12,8 +12,12 @@
namespace plot::data { namespace plot::data {
namespace { namespace {
/// Samples covered by one level-l bucket. /// Samples covered by one level-l bucket. Clamped to kMaxPyramidLevel: the
constexpr u64 level_span(u32 l) { return u64{kBucket} << l; } /// 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). /// Level-l buckets needed to hold n samples (ceiling division).
constexpr u64 buckets_for(u64 n, u32 l) { 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 /// 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 fold_range(const MinMaxPyramid& p, u64 b0, u64 b1) {
Range r{}; Range r{};
bool first = true; bool first = true;
cover_buckets(p, b0, b1, [&](u32 l, u64 i) { cover_buckets(p, b0, b1, [&](u32 l, u64 i) {
if (p.bucket_count(l, i) == 0) return;
const MinMax m = p.bucket(l, i); const MinMax m = p.bucket(l, i);
if (first) { if (first) {
r.min = f64(m.min); r.min = f64(m.min);
@@ -96,10 +102,13 @@ void MinMaxPyramid::add_level() { levels_.emplace_back(); }
void MinMaxPyramid::reserve(u64 n) { void MinMaxPyramid::reserve(u64 n) {
// Materialise every level n samples can need -- level 0 plus one per doubling // 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 // of the bucket, capped at kMaxPyramidLevel -- so a run of appends never grows
// header for why the bucket deques themselves cannot be pre-sized. // the level vector. See the header for why the bucket deques themselves cannot
// be pre-sized.
u32 want = 1; 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); 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) { 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 // 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, // only bucket any append ever touches: a bucket becomes an entry exactly once,
// complete, and is never rewritten. // complete, and is never rewritten.
if (count_ % kBucket == 0) { 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.min = v;
pending_.mm.max = v; pending_.mm.max = v;
pending_.sum = vd;
pending_.sumsq = vd * vd;
} else { } else {
pending_.mm.min = std::min(pending_.mm.min, v); pending_.mm.min = std::min(pending_.mm.min, v);
pending_.mm.max = std::max(pending_.mm.max, v); pending_.mm.max = std::max(pending_.mm.max, v);
}
pending_.sum += vd; pending_.sum += vd;
pending_.sumsq += vd * vd; pending_.sumsq += vd * vd;
++pending_.count;
} }
++count_; ++count_;
if (count_ % kBucket != 0) return; // the bucket is still partial: nothing to push 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 // 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 // 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). // 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); const u64 span = level_span(l);
if (count_ % span != 0) break; if (count_ % span != 0) break;
const u64 k = count_ / span - 1; // the level-l bucket that just completed 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& a = at(l - 1, child);
const Entry& b = at(l - 1, child + 1); const Entry& b = at(l - 1, child + 1);
Entry m; Entry m;
m.mm.min = std::min(a.mm.min, b.mm.min); // An empty child (count 0) has zero sums and a meaningless mm: it must not
m.mm.max = std::max(a.mm.max, b.mm.max); // 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.sum = a.sum + b.sum;
m.sumsq = a.sumsq + b.sumsq; m.sumsq = a.sumsq + b.sumsq;
m.count = a.count + b.count;
push(l, k, m); 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; } 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 { Range MinMaxPyramid::reduce_buckets(u64 begin, u64 end) const {
const u64 e = std::min(end, size()); const u64 e = std::min(end, size());
if (begin >= e) return Range{0.0, 0.0}; 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); const f64 ratio = samples_per_pixel / f64(kBucket);
if (n == 0 || !(ratio > 1.0)) return 0; if (n == 0 || !(ratio > 1.0)) return 0;
const f64 lg = std::floor(std::log2(ratio)); const f64 lg = std::floor(std::log2(ratio));
u32 l = lg >= f64(62) ? 62u : static_cast<u32>(lg); u32 l = lg >= f64(kMaxPyramidLevel) ? kMaxPyramidLevel : static_cast<u32>(lg);
while (l > 0) { while (l > 0) {
const bool exists = l < level_count() && level_size(l) >= 2; const bool exists = l < level_count() && level_size(l) >= 2;
if (exists && buckets_for(n, l) >= 2) break; if (exists && buckets_for(n, l) >= 2) break;
+128 -22
View File
@@ -4,6 +4,7 @@
#include <plot/data/series.hpp> #include <plot/data/series.hpp>
#include <algorithm> #include <algorithm>
#include <cmath>
#include <cstddef> #include <cstddef>
namespace plot::data { namespace plot::data {
@@ -13,6 +14,9 @@ namespace {
/// gather path never allocates, and large enough to amortise the call. /// gather path never allocates, and large enough to amortise the call.
constexpr u64 kGatherChunk = 256; 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 } // namespace
Series::QueryParts Series::split_query(u64 begin, u64 end) const { 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); const u64 n = copy_edges(p, edge);
for (u64 i = 0; i < n; ++i) { for (u64 i = 0; i < n; ++i) {
const f64 v = f64(edge[i]); const f64 v = f64(edge[i]);
if (!std::isfinite(v)) continue; // non-finite samples are excluded (B7)
if (first) { if (first) {
r.min = v; r.min = v;
r.max = v; r.max = v;
@@ -84,6 +89,8 @@ Range Series::reduce(u64 begin, u64 end) const {
r.max = std::max(r.max, v); 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; return first ? Range{0.0, 0.0} : r;
} }
@@ -98,7 +105,10 @@ f64 Series::sum(u64 begin, u64 end) const {
} }
f32 edge[2 * kBucket]; f32 edge[2 * kBucket];
const u64 n = copy_edges(p, edge); 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; return total;
} }
@@ -115,27 +125,73 @@ f64 Series::sumsq(u64 begin, u64 end) const {
const u64 n = copy_edges(p, edge); const u64 n = copy_edges(p, edge);
for (u64 i = 0; i < n; ++i) { for (u64 i = 0; i < n; ++i) {
const f64 v = f64(edge[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; 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<u64>(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<u64>(k);
}
void Series::gather_raw(u64 begin, u64 end, std::vector<f32>& out_xy) const { void Series::gather_raw(u64 begin, u64 end, std::vector<f32>& out_xy) const {
const f64 x_0 = x0();
const f64 step = dx(); const f64 step = dx();
f32 raw[kGatherChunk]; f32 raw[kGatherChunk];
for (u64 i = begin; i < end;) { for (u64 i = begin; i < end;) {
const u64 take = std::min<u64>(kGatherChunk, end - i); const u64 take = std::min<u64>(kGatherChunk, end - i);
copy_raw(i, i + take, raw); copy_raw(i, i + take, raw);
for (u64 k = 0; k < take; ++k) { 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]); out_xy.push_back(raw[k]);
} }
i += take; i += take;
} }
} }
void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const { void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
GatherMode mode) const {
const u64 n = size(); const u64 n = size();
const u64 b = std::min(begin, n); const u64 b = std::min(begin, n);
const u64 e = std::min(std::max(end, b), 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<f32>& out_xy
gather_raw(b, e, out_xy); gather_raw(b, e, out_xy);
return; 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 // Level >= 1: one geometry element per *retained* bucket the range overlaps.
// the bucket's first sample (min) and at its last sample (max), which keeps x // The pyramid's buckets are absolute, so map the window range onto its grid
// monotonic while covering the bucket's span. The pyramid's buckets are // and clamp to the buckets that are both complete and wholly inside the
// absolute, so map the window range onto its grid and clamp to the buckets that // requested range: `first` is the first bucket whose span starts at or after
// are both complete and wholly inside the window: a bucket that is only partly // `begin` (ceiling, so the bucket cannot reach left of the range) and `last`
// retained aggregates samples the window no longer has, and a partial trailing // is one past the last bucket whose span ends at or before `end` (floor, so
// bucket is not stored at all. // 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 two excluded spans at the edges -- [b, lead) and [trail, e) -- are part
// the newest samples -- for a live trace, the part being watched. They are drawn // of the range, and one of them holds the newest samples -- for a live trace,
// from the raw samples instead: at most one bucket at each end, about a pixel // the part being watched. They are drawn from the raw samples instead: at
// each at the level level_for() picks, so the envelope path stays O(buckets) // most one bucket at each end, about a pixel each at the level level_for()
// and the geometry still spans the requested range exactly. // picks, so the envelope path stays O(buckets) and the geometry still spans
const f64 x_0 = x0(); // the requested range exactly, for every mode.
const f64 step = dx(); const f64 step = dx();
const u64 span = u64{kBucket} << level; const u64 span = u64{kBucket} << level;
const u64 origin = pyramid().window_base(); const u64 origin = pyramid().window_base();
const u64 lbase = pyramid().level_base(level); const u64 lbase = pyramid().level_base(level);
const u64 lend = lbase + pyramid().level_size(level); const u64 lend = lbase + pyramid().level_size(level);
const u64 first = std::max((origin + b) / span, lbase); const u64 first = std::max(ceil_div(origin + b, span), lbase);
const u64 last = std::min((origin + e - 1) / span + 1, lend); const u64 last = std::min((origin + e) / span, lend);
if (first >= last) { if (first >= last) {
// No complete bucket of this level is inside the range -- a window squeezed // 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. // 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<f32>& out_xy
} }
const u64 lead = first * span - origin; // window index the first emitted bucket starts at 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); if (b < lead) gather_raw(b, std::min(lead, e), out_xy);
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) { for (u64 bi = first; bi < last; ++bi) {
if (pyramid().bucket_count(level, bi) == 0) continue;
const MinMax m = pyramid().bucket(level, bi); const MinMax m = pyramid().bucket(level, bi);
const u64 first_sample = bi * span - origin; // >= 0: stored buckets start inside the window 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; 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(f32(f64(first_sample) * step)); // x relative to x0()
out_xy.push_back(m.min); out_xy.push_back(m.min);
out_xy.push_back(f32(x_0 + f64(last_sample) * step)); out_xy.push_back(f32(f64(last_sample) * step));
out_xy.push_back(m.max); 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 const u64 trail = last * span - origin; // one past the last emitted bucket's span
if (trail < e) gather_raw(trail, e, out_xy); if (trail < e) gather_raw(trail, e, out_xy);
} }
+17 -4
View File
@@ -18,8 +18,11 @@ constexpr u64 kReadChunk = 256;
StreamSeries::StreamSeries(u64 capacity, f64 x0, f64 dx) StreamSeries::StreamSeries(u64 capacity, f64 x0, f64 dx)
: ring_(capacity), x0_(x0), dx_(dx) { : ring_(capacity), x0_(x0), dx_(dx) {
// Size the pyramid's level table for a full window up front: the ring never // 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. // holds more than capacity() samples, so appending never grows it. The ring
pyramid_.reserve(capacity); // 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); } void StreamSeries::push(f32 y) { ring_.push(y); }
@@ -60,6 +63,15 @@ void StreamSeries::acquire() {
y_bounds_ = reduce(0, size()); 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<f32> y(static_cast<usize>(n));
if (n > 0) copy_raw(0, n, y.data());
return ArraySeries(std::move(y), x0(), dx());
}
u64 StreamSeries::size() const { return pyramid_.size(); } u64 StreamSeries::size() const { return pyramid_.size(); }
f64 StreamSeries::x0() const { return x0_ + f64(pyramid_.window_base()) * dx_; } 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_; } const MinMaxPyramid& StreamSeries::pyramid() const { return pyramid_; }
void StreamSeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const { void StreamSeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
gather_impl(level, begin, end, out_xy); GatherMode mode) const {
gather_impl(level, begin, end, out_xy, mode);
} }
void StreamSeries::copy_raw(u64 begin, u64 end, f32* out) const { void StreamSeries::copy_raw(u64 begin, u64 end, f32* out) const {
+201
View File
@@ -0,0 +1,201 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/data/xy_series.hpp>
#include <algorithm>
#include <cassert>
#include <cmath>
#include <cstddef>
#include <utility>
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<f64> x, std::vector<f32> y) { reset(std::move(x), std::move(y)); }
void XYSeries::reset(std::vector<f64> x, std::vector<f32> 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<u64>(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<u64>(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<usize>(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<u64>(up - x_.begin()) - 1;
i1 = static_cast<u64>(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<u64>(it - x_.begin());
const u64 lo = hi - 1; // hi >= 1: x is strictly past x_.front()
return (x - x_[static_cast<usize>(lo)]) <= (x_[static_cast<usize>(hi)] - x)
? lo
: hi; // ties go to the lower index
}
void XYSeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& 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<usize>(i)] - origin));
out_xy.push_back(y_[static_cast<usize>(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<usize>(i)] - origin));
out_xy.push_back(y_[static_cast<usize>(i)]);
}
return;
}
const u64 lead = first * span;
for (u64 i = b; i < lead; ++i) {
out_xy.push_back(f32(x_[static_cast<usize>(i)] - origin));
out_xy.push_back(y_[static_cast<usize>(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<usize>(lo)] - origin));
out_xy.push_back(m.min);
out_xy.push_back(f32(x_[static_cast<usize>(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<usize>(centre)] - origin));
out_xy.push_back(y_[static_cast<usize>(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<usize>(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<usize>(i)] - origin));
out_xy.push_back(y_[static_cast<usize>(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<std::ptrdiff_t>(b),
y_.begin() + static_cast<std::ptrdiff_t>(e), out);
std::fill(out + static_cast<std::ptrdiff_t>(e - b),
out + static_cast<std::ptrdiff_t>(want), 0.0f);
}
} // namespace plot::data
+6
View File
@@ -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 { void Texture::upload_rgba(u32 width, u32 height, const void* pixels) const noexcept {
glBindTexture(GL_TEXTURE_2D, id_); 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); glPixelStorei(GL_UNPACK_ALIGNMENT, 4);
glTexImage2D(GL_TEXTURE_2D, 0, static_cast<GLint>(GL_RGBA8), static_cast<GLsizei>(width), glTexImage2D(GL_TEXTURE_2D, 0, static_cast<GLint>(GL_RGBA8), static_cast<GLsizei>(width),
static_cast<GLsizei>(height), 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels); static_cast<GLsizei>(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 { void Texture::upload_r8(u32 width, u32 height, const void* pixels) const noexcept {
+272 -16
View File
@@ -39,6 +39,21 @@ constexpr f32 kCursorHitRadiusLogical = 7.0f;
/// A box-zoom selection narrower than this on either side is a click. Logical for /// 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. /// the same reason: the gesture is the same hand movement on any display.
constexpr f32 kMinBoxLogical = 4.0f; 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 /// 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 /// 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 /// 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 plot_->layout(); // keep the chrome geometry the gestures map through current
if (e.pressed && e.down) { if (e.pressed && e.down) {
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); begin_drag(e);
}
} else if (e.released) { } else if (e.released) {
end_drag(e); end_drag(e);
} else if (e.down && mode_ != DragMode::None) { } else if (e.down && mode_ != DragMode::None) {
@@ -158,13 +179,30 @@ void Controller::on_wheel(const WheelEvent& raw) {
// like a zero-notch zoom -- a factor of exactly one -- and the zoom body would // 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 // still run, calling the range setters with the unchanged range. Those clear
// `auto_scale` unconditionally, so a single garbage wheel would silently disarm // `auto_scale` unconditionally, so a single garbage wheel would silently disarm
// auto-fit on every axis. Return before the event can mutate anything. // auto-fit on every axis. `dx` is refused by the same rule: it pans when `dy` is
if (!std::isfinite(raw.dy)) return; // 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_); const WheelEvent e = sanitized(raw, has_pointer_, pointer_x_, pointer_y_);
plot_->layout(); plot_->layout();
// 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 (e.dy != 0.0f) {
const f64 factor = zoom_factor(static_cast<f64>(e.dy)); const f64 factor = zoom_factor(static_cast<f64>(e.dy));
if (!(factor > 0.0)) return; // 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 shift = has_mod(e.mods, Mod::Shift);
const bool ctrl = has_mod(e.mods, Mod::Ctrl); const bool ctrl = has_mod(e.mods, Mod::Ctrl);
@@ -183,6 +221,17 @@ void Controller::on_wheel(const WheelEvent& raw) {
zoom_y_axis(i, plot_->y_transform(i).to_data_y(static_cast<f64>(e.y)), factor); zoom_y_axis(i, plot_->y_transform(i).to_data_y(static_cast<f64>(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<f64>(e.dx) * kPanPerNotch);
// The ranges moved under a pointer that did not, so the geometry under it changed: refresh // 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 // 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 // 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(); 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<f64>(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<f64>(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) { void Controller::on_key(const KeyEvent& e) {
if (!e.down) return; // every action below is press-only 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 zooms = e.key == kKeyZoomIn || e.key == kKeyZoomOut;
const bool fits = e.key == kKeyFit || e.key == kKeyFitUpper; 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) { if (zooms) {
const f64 notches = e.key == kKeyZoomIn ? kKeyZoomNotches : -kKeyZoomNotches; 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); const f64 hi = to_space(a.range.max, a.log);
zoom_y_axis(i, from_space(0.5 * (lo + hi), a.log), factor); zoom_y_axis(i, from_space(0.5 * (lo + hi), a.log), factor);
} }
} else { } else if (fits) {
plot_->auto_fit_x(); plot_->auto_fit_x();
for (u32 i = 0; i < plot_->y_axis_count(); ++i) plot_->auto_fit_y(i); for (u32 i = 0; i < plot_->y_axis_count(); ++i) plot_->auto_fit_y(i);
} }
@@ -363,14 +479,34 @@ void Controller::begin_drag(const PointerEvent& e) {
drag_cursor_ = kInvalid; drag_cursor_ = kInvalid;
drag_region_ = kInvalid; drag_region_ = kInvalid;
drag_zone_ = RegionGrab::None; 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 // The modifier box zoom, on the left only: a selection box is what a modified
// selection box is what it means even when it starts over a handle. // left press means even when it starts over a handle. The mask is resolved once,
if (e.button == Button::Left && has_mod(e.mods, Mod::Shift)) { // 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; 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}; selection_ = Rect{e.x, e.y, 0.0f, 0.0f};
return; return;
} }
}
if (e.button == Button::Middle) { if (e.button == Button::Middle) {
const PlotLayout& lay = std::as_const(*plot_).layout(); const PlotLayout& lay = std::as_const(*plot_).layout();
if (lay.x_label_area.contains(e.x, e.y)) { if (lay.x_label_area.contains(e.x, e.y)) {
@@ -472,10 +608,113 @@ void Controller::end_drag(const PointerEvent& e) {
drag_cursor_ = kInvalid; drag_cursor_ = kInvalid;
drag_region_ = kInvalid; drag_region_ = kInvalid;
drag_zone_ = RegionGrab::None; 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 ------------------------------------------------------------- // --- 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<int>(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<int>(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) { void Controller::pan(const PointerEvent& e) {
// The transforms map a range onto layout().axes, so the pixel-to-data scale is // 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 // 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() { void Controller::apply_box_zoom() {
const Rect sel = selection_; const Rect sel = selection_;
// A selection under a few *logical* pixels on either side is a click: no zoom. // A selection under a few *logical* pixels on the axes the mask will zoom is a
// The selection is measured in device pixels like everything the controller is // click: no zoom. The threshold is masked with the gesture -- a Ctrl drag frames
// handed, so the threshold is converted at the plot's own scale -- through the one // the x range from a selection however short it is vertically, and vice versa --
// place that conversion lives, so a change to its fallback is picked up here too. // 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); 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 // 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. // the plot area cannot ask for data outside what the box covers on screen.
@@ -528,19 +772,26 @@ void Controller::apply_box_zoom() {
const f64 r = std::clamp(static_cast<f64>(sel.right()), ax, ar); const f64 r = std::clamp(static_cast<f64>(sel.right()), ax, ar);
const f64 t = std::clamp(static_cast<f64>(sel.y), ay, ab); const f64 t = std::clamp(static_cast<f64>(sel.y), ay, ab);
const f64 b = std::clamp(static_cast<f64>(sel.bottom()), ay, ab); const f64 b = std::clamp(static_cast<f64>(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 // 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. // 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) { for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
const Transform tx = plot_->x_transform(i); const Transform tx = plot_->x_transform(i);
plot_->set_x_range(i, Range{tx.to_data_x(l), tx.to_data_x(r)}); plot_->set_x_range(i, Range{tx.to_data_x(l), tx.to_data_x(r)});
} }
}
if (box_zoom_y_) {
for (u32 i = 0; i < plot_->y_axis_count(); ++i) { for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
const Transform ty = plot_->y_transform(i); const Transform ty = plot_->y_transform(i);
plot_->set_y_range(i, Range{ty.to_data_y(b), ty.to_data_y(t)}); plot_->set_y_range(i, Range{ty.to_data_y(b), ty.to_data_y(t)});
} }
} }
}
void Controller::move_cursor(const PointerEvent& e) { void Controller::move_cursor(const PointerEvent& e) {
if (drag_cursor_ == kInvalid) return; if (drag_cursor_ == kInvalid) return;
@@ -621,7 +872,12 @@ void Controller::axis_zoom(const PointerEvent& e) {
if (!(scale > 0.0) || !std::isfinite(scale)) return; if (!(scale > 0.0) || !std::isfinite(scale)) return;
const f64 logical_dy = static_cast<f64>(e.dy) / scale; const f64 logical_dy = static_cast<f64>(e.dy) / scale;
const f64 factor = std::exp(-logical_dy * kZoomPerPixel); 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) { if (mode_ == DragMode::AxisZoomX) {
for (u32 i = 0; i < plot_->x_axis_count(); ++i) { for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
zoom_x_axis(i, x_zoom_anchor(i, e.x, e.y), factor); zoom_x_axis(i, x_zoom_anchor(i, e.x, e.y), factor);
+47 -15
View File
@@ -22,6 +22,22 @@ namespace {
/// wants a taller floor gives the figure a taller viewport, or gives the rows weights. /// wants a taller floor gives the figure a taller viewport, or gives the rows weights.
constexpr f32 kMinRowHeightLogical = 24.0f; 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 } // namespace
Figure::Figure(u32 rows) : plots_(rows) {} 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 // 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 // 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 // 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 // on a `Live` axis, applied to the group's own edge. Both the span and the
// refused rather than stored, because a following group has nothing to place a // offset are taken in the shared axis' **axis space** (log10 decades on a log
// window from otherwise. // 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 lo = std::min(r.min, r.max);
const f64 hi = std::max(r.min, r.max); const f64 hi = std::max(r.min, r.max);
if (!(hi > lo)) { 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 " "a following group's range states the window's span, so its bounds "
"must differ"}; "must differ"};
} }
g.span = hi - lo; const bool log = !plots_.empty() && plots_.back().x_axis(0u).log;
g.offset = live.max - hi; 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; g.has_range = true;
place_live_window(g); place_live_window(g);
push_link(g); push_link(g);
@@ -249,18 +268,26 @@ bool Figure::place_live_window(XLink& group) {
if (!live_edge(group, live)) return false; if (!live_edge(group, live)) return false;
// The span is the group's own -- what the user zoomed to, or what the last range // 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 // 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. // 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.span) || !(group.span > 0.0)) return false;
if (!std::isfinite(group.offset)) group.offset = 0.0; if (!std::isfinite(group.offset)) group.offset = 0.0;
// The offset can outlive the window it names: a ring that dropped samples moves the // The offset can outlive the window it names: a ring that dropped samples moves
// oldest x forward under it, so it is clamped against what the members hold *now*. // the oldest x forward under it, so it is clamped against what the members hold
// An offset past that would put the window's right edge behind the oldest sample // *now* -- measured in the same space, or a log group could hold an offset larger
// left, i.e. over data that is gone. // than everything it retains. An offset past that would put the window's right
group.offset = std::clamp(group.offset, 0.0, std::max(0.0, live.max - live.min)); // 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.live_newest = live.max;
group.has_live = true; 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; 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 // 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 // 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 // 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. // the whole of what following is.
if (adopted) { if (adopted) {
Range live{}; Range live{};
if (live_edge(group, 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) { if (span > 0.0) {
group.span = span; 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);
} }
} }
} }
+182 -83
View File
@@ -31,41 +31,69 @@ const Axis& default_axis() noexcept {
return axis; 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 /// 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 /// 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 /// statistics are over. The brackets come from `Series::neighbours()`, which names
/// corrections after it move by the one or two samples a rounded index can be off /// the samples at or either side of a bound *by the series' own x coordinates*
/// by, so which side of a bound a sample falls on is decided by the same doubles /// (`x_at()`), so a series whose samples are not on a uniform grid -- an XY series
/// the series carries rather than by a division that is exact only on a grid that /// -- is covered exactly where its samples actually are, not where a `dx()` grid
/// is. Still O(1): the seed is never more than half a sample from the answer. /// 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 /// 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 /// 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) { bool covered_samples(const data::Series& series, f64 lo, f64 hi, u64& begin, u64& end) {
const u64 n = series.size(); const u64 n = series.size();
const f64 x0 = series.x0(); if (n == 0u || !std::isfinite(lo) || !std::isfinite(hi) || !(lo <= hi)) return false;
const f64 dx = series.dx();
if (n == 0u || !(dx > 0.0) || !std::isfinite(lo) || !std::isfinite(hi) || !(lo <= hi)) { // 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; return false;
} }
const auto x_of = [x0, dx](u64 i) { return x0 + static_cast<f64>(i) * dx; }; } else if (series.x_at(i0) >= lo) {
i1 = i0;
}
const u64 b = i1;
// The first sample at or after `lo`: walk left while the one before is also // One past the last sample at or before `hi`, from the same lookup: the left
// inside, then right while this one is not yet. // neighbour of the bound, or the sample the bound landed on.
u64 b = series.pyramid().snap(lo, x0, dx); u64 j0 = 0;
while (b > 0u && x_of(b - 1u) >= lo) --b; u64 j1 = 0;
while (b < n && x_of(b) < lo) ++b; 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. if (last < b) return false; // the interval falls between two adjacent samples
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;
begin = b; begin = b;
end = e; end = last + 1u;
return true; return true;
} }
@@ -228,6 +256,12 @@ u32 Plot::add_trace(const TraceDesc& desc) {
trace.axis_x = desc.axis_x; trace.axis_x = desc.axis_x;
trace.axis_y = desc.axis_y; trace.axis_y = desc.axis_y;
trace.name = desc.name; 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); traces_.push_back(trace);
mark_measures_dirty(); mark_measures_dirty();
return static_cast<u32>(traces_.size() - 1); return static_cast<u32>(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 // 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 // and offset the axis holds. `auto_fit_x()` is how a window is framed either
// way. // 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 lo = std::min(r.min, r.max);
const f64 hi = std::max(r.min, r.max); const f64 hi = std::max(r.min, r.max);
if (!std::isfinite(lo) || !std::isfinite(hi) || !(hi > lo)) return; if (!std::isfinite(lo) || !std::isfinite(hi) || !(hi > lo)) return;
a.range = Range{lo, hi}; 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); place_live_x_window(axis);
a.auto_scale = false; // as ever: a range a caller chose is not an auto-fit a.auto_scale = false; // as ever: a range a caller chose is not an auto-fit
return; 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, // 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. // which is what every caller of this setter got before follow existed.
a.range = r; 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) { void Plot::set_y_range(u32 axis, Range r) {
if (axis >= y_axes_.size()) return; 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].range = r;
y_axes_[axis].auto_scale = false; 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) { void Plot::set_x_offset(u32 axis, f64 units_behind_live) {
if (axis >= x_axes_.size()) return; if (axis >= x_axes_.size()) return;
// No such offset: a NaN would place a window that is not a window, and the last // 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; if (!std::isfinite(units_behind_live)) return;
x_axes_[axis].offset = std::max(0.0, units_behind_live); 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 // 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; if (a.follow != XFollow::Live) return false;
Range live{}; Range live{};
if (!live_x_bounds(axis, live)) return false; if (!live_x_bounds(axis, live)) return false;
// The span is the axis' own -- what the user zoomed to, or what the last fit // The span and the offset are distances in **axis space** -- log10 when the axis
// produced -- and the follow window only moves it: the span is what makes this a // is logarithmic -- the space the controller's gestures are computed in. The span
// scroll rather than a rescale. A span that is not a span is nothing to place a // is the axis' own -- what the user zoomed to, or what the last fit produced --
// window from, so the range is left exactly as it is. // and the follow window only moves it: the span is what makes this a scroll
const f64 span = a.range.span(); // 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; if (!std::isfinite(span) || !(span > 0.0)) return false;
// The offset can outlive the window it names: a ring that dropped samples moves // 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 // 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 // *now* -- an offset past that would put the window's right edge behind the oldest
// oldest sample left, i.e. over data that is gone. // sample left, i.e. over data that is gone. In axis space, like the span: the
const f64 retained = std::max(0.0, live.max - live.min); // 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.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; return true;
} }
@@ -669,12 +732,27 @@ const Measurement& Plot::measurement(u32 id) const {
slot.result = Measurement{}; slot.result = Measurement{};
if (series != nullptr && samples > 0 && slot.cursor_a < cursors_.size() && if (series != nullptr && samples > 0 && slot.cursor_a < cursors_.size() &&
slot.cursor_b < cursors_.size()) { slot.cursor_b < cursors_.size()) {
const u64 a = series->pyramid().snap(cursors_[slot.cursor_a].x, series->x0(), series->dx()); const Cursor& ca = cursors_[slot.cursor_a];
const u64 b = series->pyramid().snap(cursors_[slot.cursor_b].x, series->x0(), series->dx()); const Cursor& cb = cursors_[slot.cursor_b];
// snap() clamps to [0, size-1], so [a, b+1) is inside the series whenever // R8: a measurement is read from two *x* coordinates, and a horizontal ruler
// the cursor pair is not inverted. An inverted pair is not an interval. // 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); if (a <= b) slot.result = Measure::evaluate(*series, a, b + 1);
} }
}
slot.last_series = series; slot.last_series = series;
slot.last_size = samples; slot.last_size = samples;
slot.last_x0 = x0; slot.last_x0 = x0;
@@ -821,10 +899,22 @@ MeasureSpan Plot::measure_span(u32 id) const noexcept {
if (id >= measures_.size()) return MeasureSpan{}; if (id >= measures_.size()) return MeasureSpan{};
const MeasureSlot& slot = measures_[id]; const MeasureSlot& slot = measures_[id];
if (slot.cursor_a >= cursors_.size() || slot.cursor_b >= cursors_.size()) return MeasureSpan{}; 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}; return MeasureSpan{slot.cursor_a, slot.cursor_b, true};
} }
const PlotLayout& Plot::layout() { 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 // 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 // -- 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 // 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 // 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 // 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. // is clamped to first, so neither strip can push the rect outside it.
y_width = std::clamp(y_width, 0.0f, viewport_.w); y_width = std::clamp(y_width, 0.0f, vw);
x_height = std::clamp(x_height, 0.0f, viewport_.h); x_height = std::clamp(x_height, 0.0f, vh);
const f32 y_budget = (1.0f - kMinAxesShare) * viewport_.w; const f32 y_budget = (1.0f - kMinAxesShare) * vw;
const f32 x_budget = (1.0f - kMinAxesShare) * viewport_.h; 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` // 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 // 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 // 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_.y_title_width = y_title_width;
layout_.x_title_height = x_title_height; layout_.x_title_height = x_title_height;
layout_.axes = Rect{viewport_.x + y_width + y_title_width, viewport_.y, layout_.axes = Rect{viewport_.x + y_width + y_title_width, viewport_.y,
viewport_.w - (y_width + y_title_width), vw - (y_width + y_title_width),
viewport_.h - (x_height + x_title_height)}; vh - (x_height + x_title_height)};
layout_.y_title_area = Rect{viewport_.x, layout_.axes.y, y_title_width, layout_.axes.h}; layout_.y_title_area = Rect{viewport_.x, layout_.axes.y, y_title_width, layout_.axes.h};
layout_.y_label_area = layout_.y_label_area =
Rect{layout_.y_title_area.right(), layout_.axes.y, y_width, layout_.axes.h}; 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; if (text_ == nullptr || pass + 1 >= kMaxLayoutPasses) break;
// The same caps as above, so this compares like with like and the loop still // The same caps as above, so this compares like with like and the loop still
// reaches its fixed point on the second pass. // 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 // 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. // point has to be zero for a row that gave its strip away.
const f32 need_x = 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; : 0.0f;
// What the strip *wants* is the label's own width or the figure's floor, whichever is // 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 // 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 { f64 Plot::snapped_x(f64 x, u32 axis_x) const {
const data::Series* s = snap_series(axis_x); const data::Series* s = snap_series(axis_x);
if (s == nullptr) return x; if (s == nullptr) return x;
const u64 i = s->pyramid().snap(x, s->x0(), s->dx()); // Through the series' own index arithmetic (`snap_index`/`x_at`), so a series
return s->x0() + static_cast<f64>(i) * s->dx(); // 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 { 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 crosshair_span(const data::Series& series, f64 x) noexcept {
CrosshairSpan span; CrosshairSpan span;
const u64 n = series.size(); const u64 n = series.size();
const f64 x0 = series.x0(); // No samples and no finite query: nothing to interpolate between.
const f64 dx = series.dx(); if (n == 0u || !std::isfinite(x)) return span;
// 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;
// The window is closed at both ends -- the oldest and the newest sample are samples too -- // The bracketing samples, straight from the series (`neighbours()`/`x_at()`):
// and a query outside it has no neighbours to interpolate between. Answering with the // the window is closed at both ends -- the oldest and the newest sample are
// nearest sample's value instead is the clamped lie this refuses: the readout would report // samples too -- and a query outside it has no neighbours to interpolate
// a number as if it were under the pointer when the series does not reach that far. // between. Answering with the nearest sample's value instead is the clamped lie
const f64 newest = x0 + static_cast<f64>(n - 1u) * dx; // this refuses: the readout would report a number as if it were under the
if (x < x0 || x > newest) return span; // 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 // A query that landed **on** a sample is a span of one, whichever side the
// `floor((x - x0) / dx)` and there is nothing to look up. The two bounds are checked // series' bracket reported: a sample at the query is the answer, and the two
// *before* the cast, which is also what keeps it defined: `index` is a quotient of two // ends of the window are samples too -- a bracket of (0, 1) at a query sitting
// caller-supplied doubles and can overflow to infinity for an extreme origin and spacing, // on sample 0 would read as an interpolation with no width. Decided on the
// and casting that to `u64` is undefined. `!(index > 0.0)` is the NaN test as well. // samples' own coordinates, so it holds for any `neighbours()` implementation.
const f64 index = (x - x0) / dx; if (series.x_at(i0) == x) {
if (!(index > 0.0)) { span.begin = span.end = i0;
span.begin = span.end = 0u; // the oldest sample, or a rounding step below it
span.t = 0.0; span.t = 0.0;
span.valid = true; span.valid = true;
return span; return span;
} }
if (index >= static_cast<f64>(n - 1u)) { if (series.x_at(i1) == x) {
span.begin = span.end = n - 1u; // the newest sample, or a step past it span.begin = span.end = i1;
span.t = 0.0; span.t = 0.0;
span.valid = true; span.valid = true;
return span; 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 if (i0 == i1) {
// query sitting *on* sample `i`, and one that rounds up to 1.0 is it sitting on the sample // The series bracketed a single sample for a query that is not on it: nothing
// after it -- neither is an interpolation with no width. // to interpolate with, and the sample is the honest answer.
const u64 i = static_cast<u64>(index); span.begin = span.end = i0;
const f64 t = index - static_cast<f64>(i); span.t = 0.0;
if (!(t > 0.0)) {
span.begin = span.end = i;
} else if (t >= 1.0) {
span.begin = span.end = i + 1u;
} else { } else {
span.begin = i; span.begin = i0;
span.end = i + 1u; span.end = i1;
span.t = t; // `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; span.valid = true;
return span; return span;
+40 -4
View File
@@ -167,15 +167,51 @@ void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out, boo
const f64 minor_step = step / minor_ratio; const f64 minor_step = step / minor_ratio;
if (!std::isfinite(minor_step) || !(minor_step > 0.0)) return; 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; const f64 first = include_minors ? std::ceil(lo / minor_step) * minor_step : first_major;
if (!std::isfinite(first)) return; if (!std::isfinite(first)) return;
char label[kLabelBuf]; char label[kLabelBuf];
usize minors = 0;
for (f64 v = first; v <= hi + stride * 1e-9;) { for (f64 v = first; v <= hi + stride * 1e-9;) {
if (out.size() >= kMaxTicks) break; const bool major = !include_minors || minor_index_is_major(v);
const bool major = !include_minors || if (!major && minors >= kMaxTicks) {
std::fabs(v - std::round(v / step) * step) <= step * 1e-9; // 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 tick;
tick.value = v; tick.value = v;
tick.major = major; tick.major = major;
+9 -5
View File
@@ -93,7 +93,7 @@ void TextBatch::set_cell(Vec2 cell_uv) noexcept {
cell_declared_ = true; 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_) { if (!cell_declared_) {
// Nobody declared a cell: a caller staging raw glyphs, where the glyph's own // 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 // 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(glyph.v0);
v_.push_back(unit_ux.x); v_.push_back(unit_ux.x);
v_.push_back(unit_ux.y); 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) { 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)); 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 TextBatch::add_text(const char* utf8, Vec2 pen_px, f32 px, const TextBackend& font,
Vec2 axis) { Vec2 axis, Color color) {
if (utf8 == nullptr) { if (utf8 == nullptr) {
return pen_px; return pen_px;
} }
@@ -145,7 +149,7 @@ Vec2 TextBatch::add_text(const char* utf8, Vec2 pen_px, f32 px, const TextBacken
if (glyph == nullptr) { if (glyph == nullptr) {
continue; // no artwork for this codepoint: nothing to draw, nothing to advance 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); pen_px = pen_px + ux * (glyph->advance * px);
} }
return pen_px; return pen_px;
+2 -2
View File
@@ -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_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 { bool TextProgram::create(Status* st) noexcept {
program_ = gl::Program::from_source(kTextVS, kTextFS, st); program_ = gl::Program::from_source(kTextVS, kTextFS, st);
if (program_.id() == 0) { if (program_.id() == 0) {
@@ -54,6 +56,4 @@ void TextProgram::set_texture_size(Vec2 texels) const noexcept {
program_.set_uniform("u_tex_size", texels); program_.set_uniform("u_tex_size", texels);
} }
void TextProgram::set_color(Color color) const noexcept { program_.set_uniform("u_color", color); }
} // namespace plot::render } // namespace plot::render
+299 -91
View File
@@ -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 /// `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 /// a log axis takes the transform's own clamp, so it lands off-scale rather than
/// as NaN. /// as NaN.
Vec2 pre_affine(f64 x, f64 y, bool log_x, bool log_y) noexcept { /// One axis' pre-affine coordinate: the value itself on a linear axis, `log10`
return Vec2{static_cast<f32>(log_x ? Transform::log10_positive(x) : x), /// of it on a log one -- exactly what `math::Transform::to_screen_*` applies
static_cast<f32>(log_y ? Transform::log10_positive(y) : y)}; /// 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 /// 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 /// 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 // ------------------------------------------------------------------ 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 { void Renderer::set_text_backend(const render::TextBackend* font) noexcept {
const render::TextBackend* next = font != nullptr ? font : &default_font_; const render::TextBackend* next = font != nullptr ? font : &default_font_;
if (next == 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; fb_h_ = fb_h;
viewport_ = viewport_px; viewport_ = viewport_px;
lines_.clear(); lines_.clear();
glyphs_.clear(); glyphs_under_.clear();
glyphs_over_.clear();
stats_ = FrameStats{}; stats_ = FrameStats{};
gl_error_ = false; 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 // 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 // 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::glEnable(gl::GL_BLEND);
gl::glBlendFunc(gl::GL_SRC_ALPHA, gl::GL_ONE_MINUS_SRC_ALPHA); 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)); font_backend_.reset(*font_, style_.device(style_.axis_font_px));
// The cell every glyph staged this frame is drawn in, declared from the backend // 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 // rather than left to whichever glyph is staged first: the instances carry rect
// origins, and the draw carries this size (see TextBackend::cell_uv). // origins, and the draw carries this size (see TextBackend::cell_uv). Both
glyphs_.set_cell(font_backend_.cell_uv()); // 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 // 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 // -- 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 // 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 // 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. // reads the strips, the axes rect and the transforms it pins.
draw_plot(plot, plot.layout()); draw_plot(plot, plot.layout());
// The frame's single text flush, after every line batch of the frame: text is // The frame's over-text flush, after every line batch and after the under batch
// texture-bound, so it is one draw call whatever it holds (see draw_plot). // has already gone out inside draw_plot: readout text, tick labels and titles --
flush_text(); // what must sit over everything (see draw_plot).
flush_text(glyphs_over_);
} }
void Renderer::draw(Figure& figure) { 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. // overload -- the one layout of this frame, not a second derivation of it.
draw_plot(plot, static_cast<const Plot&>(plot).layout()); draw_plot(plot, static_cast<const Plot&>(plot).layout());
} }
flush_text(); flush_text(glyphs_over_);
} }
void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) { 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_measures(plot, layout);
draw_annotations(plot); draw_annotations(plot);
draw_legend(plot, layout); 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 // 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 // panel is drawn over the legend and every other overlay rather than under them, and its
// joins the frame's single flush with everything else. // text joins the frame's over flush with the tick labels and titles.
draw_crosshair_readout(plot, layout); draw_crosshair_readout(plot, layout);
clear_clip(); clear_clip();
draw_tick_labels(plot, layout); draw_tick_labels(plot, layout);
@@ -540,7 +581,15 @@ void Renderer::end_frame() noexcept {
gl::glBindBuffer(gl::GL_ARRAY_BUFFER, 0); gl::glBindBuffer(gl::GL_ARRAY_BUFFER, 0);
gl::glActiveTexture(gl::GL_TEXTURE0); gl::glActiveTexture(gl::GL_TEXTURE0);
gl::glBindTexture(gl::GL_TEXTURE_2D, 0); gl::glBindTexture(gl::GL_TEXTURE_2D, 0);
// 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<gl::GLenum>(blend_src_rgb_),
static_cast<gl::GLenum>(blend_dst_rgb_),
static_cast<gl::GLenum>(blend_src_a_),
static_cast<gl::GLenum>(blend_dst_a_));
if (!blend_was_on_) {
gl::glDisable(gl::GL_BLEND); gl::glDisable(gl::GL_BLEND);
}
// The scissor, too: the frame engaged it (its baseline clip) and each plot narrowed and // 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 // 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 // promises -- rather than whichever rect the last pass was clipped to. The box stays
@@ -567,9 +616,9 @@ void Renderer::upload_vertices(std::span<const f32> interleaved) noexcept {
} }
void Renderer::upload_text_vertices(std::span<const f32> interleaved) noexcept { void Renderer::upload_text_vertices(std::span<const f32> interleaved) noexcept {
// Six floats per instance is the text pipeline's layout (see TextInstance): // Ten floats per instance is the text pipeline's layout (see TextInstance):
// the cell's corner, its atlas rect and the basis that rotates it. // the cell's corner, its atlas rect, the basis that rotates it and the tint.
text_vao_.upload(interleaved.data(), static_cast<u32>(interleaved.size() / 6u)); text_vao_.upload(interleaved.data(), static_cast<u32>(interleaved.size() / 10u));
} }
void Renderer::flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash) noexcept { 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()). // grid, a trace or a cursor drawn after it (see draw_crosshair_lines()).
line_program_.set_dash(dash); line_program_.set_dash(dash);
line_program_.set_color(color); 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(); line_vao_.bind();
// One triangle strip per instance, four corners from gl_VertexID. // One triangle strip per instance, four corners from gl_VertexID.
gl::glDrawArraysInstanced(gl::GL_TRIANGLE_STRIP, 0, 4, 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 lines_.clear(); // keeps the capacity, so the next group re-uses the store
} }
void Renderer::flush_text() noexcept { void Renderer::flush_text(render::TextBatch& batch) noexcept {
const u32 glyphs = glyphs_.glyph_count(); const u32 glyphs = batch.glyph_count();
// No atlas is no text: a backend that publishes none has nothing to sample, and // 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. // drawing its instances would read whatever texture happened to be bound.
if (glyphs != 0 && ready_ && !atlas_.empty()) { if (glyphs != 0 && ready_ && !atlas_.empty()) {
upload_text_vertices(glyphs_.data()); upload_text_vertices(batch.data());
text_program_.bind(); text_program_.bind();
text_program_.set_viewport({viewport_.w, viewport_.h}); text_program_.set_viewport({viewport_.w, viewport_.h});
// The cell comes from the backend and the atlas it uploaded -- never from a // 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(), text_program_.set_px_range({cell_uv.x * atlas_texels.x * font_backend_.scale(),
cell_uv.y * atlas_texels.y * font_backend_.scale()}); cell_uv.y * atlas_texels.y * font_backend_.scale()});
text_program_.set_texture_size(atlas_texels); 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(); atlas_.bind();
text_vao_.bind(); text_vao_.bind();
gl::glDrawArraysInstanced(gl::GL_TRIANGLE_STRIP, 0, 4, gl::glDrawArraysInstanced(gl::GL_TRIANGLE_STRIP, 0, 4,
static_cast<gl::GLsizei>(text_vao_.instance_count())); static_cast<gl::GLsizei>(text_vao_.instance_count()));
++stats_.draw_calls; ++stats_.draw_calls;
} }
glyphs_.clear(); batch.clear();
} }
// ----------------------------------------------------------- coordinates // ----------------------------------------------------------- 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 tx = plot.x_transform(axis_x);
const Transform ty = plot.y_transform(axis_y); const Transform ty = plot.y_transform(axis_y);
// The plot's transforms already map data to framebuffer pixels; the shaders // 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 // emit NDC relative to the GL viewport, so the viewport origin comes off here
// -- once per mapping, never per primitive. // -- 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<f32>(tx.sx), static_cast<f32>(ty.sy)}, return Mapping{Vec2{static_cast<f32>(tx.sx), static_cast<f32>(ty.sy)},
Vec2{static_cast<f32>(tx.tx) - viewport_.x, Vec2{static_cast<f32>(tx.sx * origin_s + tx.tx - viewport_.x),
static_cast<f32>(ty.ty) - viewport_.y}}; static_cast<f32>(ty.ty) - viewport_.y}};
} }
@@ -666,25 +729,42 @@ void Renderer::fill_pixels(const Rect& rect, Color color) {
flush_lines(pixel_mapping(), color, rect.h); 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) { void Renderer::stage_segment(f64 origin_x, f64 x0, f64 y0, f64 x1, f64 y1, bool log_x,
lines_.add_segment(pre_affine(x0, y0, log_x, log_y), pre_affine(x1, y1, log_x, log_y)); 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<f32>(space(x0, log_x) - space(origin_x, log_x)),
static_cast<f32>(space(y0, log_y))},
Vec2{static_cast<f32>(space(x1, log_x) - space(origin_x, log_x)),
static_cast<f32>(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) { if (!log_x && !log_y) {
// The common case: gathered points are already interleaved `x,y`, which is // The common case: reconstruct the absolute data x in f64 (`x0 + rel` --
// the batch's own payload, so this is a straight copy. // the gather contract's own reconstruction), stage it relative to the
lines_.add_polyline_xy(xy, n); // 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<f32>(xy[2u * i] + series_x0 - origin_s));
staged_.push_back(xy[2u * i + 1u]);
}
lines_.add_polyline_xy(staged_.data(), n);
return; return;
} }
for (u32 i = 0; i + 1u < n; ++i) { 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. // 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 { 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&) {}); [](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(); const f32 scale = font_backend_.scale();
return walk_text_clipped(to_viewport(pos_px), utf8, budget_px, *font_, scale, axis, return walk_text_clipped(to_viewport(pos_px), utf8, budget_px, *font_, scale, axis,
[this, scale, axis](Vec2 pen, const render::Glyph& glyph) { [this, &batch, scale, axis, color](Vec2 pen, const render::Glyph& glyph) {
glyphs_.add_glyph(pen, glyph, scale, axis); 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) { void Renderer::draw_grid(const Plot& plot) {
const Grid& grid = plot.grid(); 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 // 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_x = plot.x_axis(0).log;
const bool log_y = plot.y_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()) { for (const GridLine& line : grid.lines()) {
if (line.major != major) continue; // grid.build() already dropped non-finite ones 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); 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 // 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 // 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) { for (u32 i = 0; i < plot.x_axis_count(); ++i) {
const Axis& axis = plot.x_axis(i); const Axis& axis = plot.x_axis(i);
const f64 y = axis.place == AxisPlace::Top ? y_range.max : y_range.min; 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); stage_segment(axis.range.min, 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)); 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) { for (u32 i = 0; i < plot.y_axis_count(); ++i) {
const Axis& axis = plot.y_axis(i); const Axis& axis = plot.y_axis(i);
const f64 x = axis.place == AxisPlace::Right ? x_range.max : x_range.min; 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); stage_segment(x, 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)); 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) { for (const Tick& tick : plot.x_axis(i).ticks) {
if (!tick.major || tick.label.empty()) continue; if (!tick.major || tick.label.empty()) continue;
const f32 width = font_backend_.measure(tick.label.c_str()); const f32 width = font_backend_.measure(tick.label.c_str());
stage_text({static_cast<f32>(transform.to_screen_x(tick.value)) - 0.5f * width, stage_text(glyphs_over_,
{static_cast<f32>(transform.to_screen_x(tick.value)) - 0.5f * width,
layout.x_label_area.y + 0.5f * (layout.x_label_area.h - line_h)}, 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 width = font_backend_.measure(tick.label.c_str());
const f32 centre = static_cast<f32>(transform.to_screen_y(tick.value)); const f32 centre = static_cast<f32>(transform.to_screen_y(tick.value));
const f32 top = std::clamp(centre - 0.5f * line_h, layout.y_label_area.y, top_limit); 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}, stage_text(glyphs_over_,
tick.label.c_str()); {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); 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 // Centred on the strip: the tick labels have their own rows above it, so the
// title needs no alignment of its own. // title needs no alignment of its own.
stage_text_clipped({strip.x + 0.5f * (strip.w - width), y}, axis.label.c_str(), budget, stage_text_clipped(glyphs_over_, {strip.x + 0.5f * (strip.w - width), y},
kTextRight); axis.label.c_str(), budget, kTextRight, style_.axis_text);
++row; ++row;
} }
@@ -872,8 +960,8 @@ void Renderer::draw_titles(const Plot& plot, const PlotLayout& layout) {
if (x + line_h > strip.right()) break; if (x + line_h > strip.right()) break;
const f32 budget = strip.h - 2.0f * pad; const f32 budget = strip.h - 2.0f * pad;
const f32 length = clipped_text_width(axis.label.c_str(), budget, kTextUp); 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, stage_text_clipped(glyphs_over_, {x, strip.bottom() - 0.5f * (strip.h - length)},
kTextUp); axis.label.c_str(), budget, kTextUp, style_.axis_text);
++column; ++column;
} }
} }
@@ -915,7 +1003,7 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
: 0u; : 0u;
gather_.clear(); 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 if (gather_.size() < 4u) continue; // fewer than two points is no segment
stats_.visible_samples += visible; stats_.visible_samples += visible;
@@ -925,14 +1013,58 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
++stats_.lod_traces; ++stats_.lod_traces;
} }
stage_xy(gather_.data(), static_cast<u32>(gather_.size() / 2u), // Gathered x is relative to `series.x0()` (the gather contract); the staging
plot.x_axis(trace.axis_x).log, plot.y_axis(trace.axis_y).log); // 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<u32>(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 // 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 // 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 // 3 device px axis line at scale 2 -- half the logical weight, which inverts the
// trace/axis hierarchy on a high-DPI panel. // trace/axis hierarchy on a high-DPI panel. `width == 0` is scatter: the
flush_lines(mapping(plot, trace.axis_x, trace.axis_y), trace.color, // markers below are the whole trace.
style_.device(trace.width)); 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<u32>(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<f64>(gather_[2u * p]) + series.x0();
const f32 rx = static_cast<f32>(space(x_abs, log_x) - origin_s);
const f32 ry = static_cast<f32>(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) { if (horizontal) {
const f64 y_drawn = plot.y_transform(cursor.axis_y).to_data_y(static_cast<f64>(drawn)); const f64 y_drawn = plot.y_transform(cursor.axis_y).to_data_y(static_cast<f64>(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); plot.y_axis(cursor.axis_y).log);
} else { flush_lines(mapping(plot, 0, cursor.axis_y, x_range.min),
const f64 x_drawn = plot.x_transform(cursor.axis_x).to_data_x(static_cast<f64>(drawn));
stage_segment(x_drawn, y_range.min, x_drawn, y_range.max, plot.x_axis(cursor.axis_x).log,
log_y);
}
flush_lines(horizontal ? mapping(plot, 0, cursor.axis_y) : mapping(plot, cursor.axis_x, 0),
on ? style_.cursor : cursor.color, on ? style_.cursor : cursor.color,
style_.device(on ? style_.handle_width_px : style_.line_width_px)); 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<f64>(drawn));
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));
}
draw_cursor_label(plot, layout, cursor, drawn); 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]; char value[32];
plot.y_axis(cursor.axis_y).format_value(cursor.y, value, sizeof value); plot.y_axis(cursor.axis_y).format_value(cursor.y, value, sizeof value);
if (cursor.label.empty()) { if (cursor.label.empty()) {
stage_text(pen, value); stage_text(glyphs_under_, pen, value, style_.axis_text);
return; return;
} }
stage_text(pen, cursor.label.c_str()); stage_text(glyphs_under_, pen, cursor.label.c_str(), style_.axis_text);
stage_text({pen.x + font_backend_.measure(cursor.label.c_str()) + gap, pen.y}, value); stage_text(glyphs_under_, {pen.x + font_backend_.measure(cursor.label.c_str()) + gap, pen.y},
value, style_.axis_text);
return; return;
} }
if (!cursor.label.empty()) { 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); hot_right ? region.right_handle.w : edge_w);
if (!config.label.empty() && region.label.w > 0.0f) { 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 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 // 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 // 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 // pipeline. Strokes are staged in data space relative to the axis' visible
// band follows a pan or a zoom through the uniform alone. // 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) { for (f32 px = x0; px < x1; px += 1.0f) {
const f64 x = transform.to_data_x(static_cast<f64>(px) + 0.5); const f64 x = transform.to_data_x(static_cast<f64>(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 // 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 // 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 // column pitch itself. Scaling it would overlap the columns it does not
// match and leave gaps in the ones it does. // 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]; 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 // 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 // them. The y axis title is not a consideration here -- it lives in its own
// strip outside the axes. // 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) { void Renderer::draw_annotations(const Plot& plot) {
const f32 line_h = font_backend_.height(); const f32 line_h = font_backend_.height();
// The clip every other overlay is drawn under, read here because the caption is staged // 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. // keeps the rest of this pass inside the axes does not reach it.
const Rect& axes = plot.layout().axes; const Rect& axes = plot.layout().axes;
for (u32 i = 0; i < plot.annotation_count(); ++i) { 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)); 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 // 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 // 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 // show, and -- because the under-batch text is drawn *after* the clip is released -- it
// otherwise paint outside the axes it is measured in, into the neighbouring row of a // 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 // `Figure` whose rows tile the figure. The caption box is also clamped inside `axes`
// it instead would cost a draw call per row and mis-clip the tick labels, which are // (R6), and skipped entirely when it cannot fit the rect at all -- an annotation pinned
// staged outside `axes` on purpose and share the batch after this one. // inside a sliver of axes keeps its cross but not a caption that would overhang it.
if (!annotation.text.empty() && axes.contains(px, py)) { 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 // the marker itself, so both branches read their string from one place -- the
// layout measured it, this stages it. // layout measured it, this stages it.
if (row.ellipsis) { 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; continue;
} }
draw_swatch(plot, row.trace, row.swatch); 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<f32>(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 // 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 // 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. // 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; 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); 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 // 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. // 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) { if (crosshair.vertical.w > 0.0f) {
const f64 x = plot.x_transform(0u).to_data_x( const f64 x = plot.x_transform(0u).to_data_x(
static_cast<f64>(crosshair.vertical.x + 0.5f * crosshair.vertical.w)); static_cast<f64>(crosshair.vertical.x + 0.5f * crosshair.vertical.w));
const Range y_range = plot.y_range(0u); 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); flush_lines(data, style_.crosshair, stroke, crosshair.dash);
} }
if (crosshair.horizontal.h > 0.0f) { if (crosshair.horizontal.h > 0.0f) {
const f64 y = plot.y_transform(0u).to_data_y( const f64 y = plot.y_transform(0u).to_data_y(
static_cast<f64>(crosshair.horizontal.y + 0.5f * crosshair.horizontal.h)); static_cast<f64>(crosshair.horizontal.y + 0.5f * crosshair.horizontal.h));
const Range x_range = plot.x_range(0u); 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); 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) { for (const CrosshairRow& row : crosshair.rows) {
if (!row.listed) continue; if (!row.listed) continue;
draw_swatch(plot, row.trace, row.swatch); 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 // 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. // put it there): either way there is a run to stage, so a row is never a blank cell. It is
stage_text({row.value_box.x, row.value_box.y}, row.value.c_str()); // 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);
} }
} }
+15 -7
View File
@@ -19,6 +19,7 @@ uniform vec2 u_scale; // data -> pixels
uniform vec2 u_offset; // pixels uniform vec2 u_offset; // pixels
uniform vec2 u_viewport; // render target size, pixels uniform vec2 u_viewport; // render target size, pixels
uniform float u_width; // stroke width, 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 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 // 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 p1 = a_p1 * u_scale + u_offset;
vec2 d = p1 - p0; vec2 d = p1 - p0;
float len = length(d); 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 // 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 // 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 // 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 in float v_along; // distance travelled from a_p0, pixels
uniform float u_width; // stroke width, pixels uniform float u_width; // stroke width, pixels
uniform float u_aa; // AA fringe half-width, pixels
uniform vec4 u_color; uniform vec4 u_color;
uniform vec2 u_dash; // dash pattern in pixels: x = ink, y = gap; y <= 0 is solid 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 half_w = u_width * 0.5;
float d = abs(v_dist); float d = abs(v_dist);
if (d > half_w + 1.0) { if (d > half_w + u_aa) {
discard; // outside the fringe; nothing to blend discard; // outside the fringe; nothing to blend
} }
// Ramp over the last pixel of the half width so a thin stroke keeps its // Ramp over the fringe so a thin stroke keeps its weight instead of aliasing
// weight instead of aliasing into a dashed line. // into a dashed line: the ramp is centred on `half_w` and spans `u_aa`, so
float alpha = 1.0 - smoothstep(half_w - 0.5, half_w + 0.5, d); // `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); 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 = 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 = 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 = 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_viewport; // render target size, pixels
uniform vec2 u_px_range; // glyph cell on screen, pixels (width, height) 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) 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() { void main() {
// The cell's two axes: the run direction the instance carries and its // 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 // 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. // way round: u runs along the basis, v down the glyph.
v_uv = a_uv + corner * u_glyph_uv; v_uv = a_uv + corner * u_glyph_uv;
v_colour = a_colour;
} }
)"; )";
const char* const kTextFS = R"(#version 330 core 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 sampler2D u_atlas; // GL_R8 glyph atlas, GL_NEAREST
uniform vec2 u_tex_size; // atlas size in texels uniform vec2 u_tex_size; // atlas size in texels
uniform vec4 u_color;
out vec4 o_color; out vec4 o_color;
@@ -141,7 +149,7 @@ void main() {
// glyph cell; rounding to a centre removes the tie by construction. // 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)); 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; 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);
} }
)"; )";
+7 -3
View File
@@ -72,15 +72,18 @@ bool TextVertexArray::create(Status* st, UploadMode mode) noexcept {
} }
vao_.bind(); vao_.bind();
buffer_.bind(); buffer_.bind();
// Three attributes now: the cell's corner, its atlas rect and the basis that // Four attributes now: the cell's corner, its atlas rect, the basis that
// rotates it (see TextInstance). All of them advance once per instance, like // rotates it and the run's tint (see TextInstance). All of them advance once
// the line pipeline's, because the corners come from gl_VertexID. // 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(0u, 2, sizeof(TextInstance), offsetof(TextInstance, x));
vao_.attrib(1u, 2, sizeof(TextInstance), offsetof(TextInstance, u)); vao_.attrib(1u, 2, sizeof(TextInstance), offsetof(TextInstance, u));
vao_.attrib(2u, 2, sizeof(TextInstance), offsetof(TextInstance, ax)); vao_.attrib(2u, 2, sizeof(TextInstance), offsetof(TextInstance, ax));
vao_.attrib(3u, 4, sizeof(TextInstance), offsetof(TextInstance, r));
gl::glVertexAttribDivisor(0u, kPerInstance); gl::glVertexAttribDivisor(0u, kPerInstance);
gl::glVertexAttribDivisor(1u, kPerInstance); gl::glVertexAttribDivisor(1u, kPerInstance);
gl::glVertexAttribDivisor(2u, kPerInstance); gl::glVertexAttribDivisor(2u, kPerInstance);
gl::glVertexAttribDivisor(3u, kPerInstance);
gl::glBindVertexArray(0); gl::glBindVertexArray(0);
epoch_ = buffer_.epoch(); epoch_ = buffer_.epoch();
return true; 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(0u, 2, sizeof(TextInstance), base + offsetof(TextInstance, x));
vao_.attrib(1u, 2, sizeof(TextInstance), base + offsetof(TextInstance, u)); vao_.attrib(1u, 2, sizeof(TextInstance), base + offsetof(TextInstance, u));
vao_.attrib(2u, 2, sizeof(TextInstance), base + offsetof(TextInstance, ax)); vao_.attrib(2u, 2, sizeof(TextInstance), base + offsetof(TextInstance, ax));
vao_.attrib(3u, 4, sizeof(TextInstance), base + offsetof(TextInstance, r));
epoch_ = buffer_.epoch(); epoch_ = buffer_.epoch();
} }
instances_ = glyphs; instances_ = glyphs;
+22 -4
View File
@@ -32,6 +32,7 @@ add_executable(plot_tests
test_ring_buffer.cpp test_ring_buffer.cpp
test_minmax_pyramid.cpp test_minmax_pyramid.cpp
test_series.cpp test_series.cpp
test_xy_series.cpp
test_ticks.cpp test_ticks.cpp
test_measure.cpp test_measure.cpp
test_plot.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 # 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, # 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 # so there would be nothing to compile them against (see PLOT_BUILD_TTF_FONT in
# top-level CMakeLists.txt). Guarded on the target rather than on the option, like # 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. # 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 # 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}") list(APPEND plot_header_check_sources "${plot_header_check_source}")
endforeach() endforeach()
# The generated version header is public (the umbrella names it) but is not
# under the source include root -- the top-level CMakeLists.txt configures it
# into <build>/include/plot/version.hpp -- so the glob above cannot see it. It
# gets the same standalone-compile TU by hand, and reaches the header through
# plot::core's build-tree include interface.
file(GENERATE
OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/header_checks/plot_version.cpp"
CONTENT
"// Generated by tests/CMakeLists.txt: <plot/version.hpp> must compile on its own.\n#include <plot/version.hpp>\n")
list(APPEND plot_header_check_sources
"${CMAKE_CURRENT_BINARY_DIR}/header_checks/plot_version.cpp")
add_library(plot_header_self_containment OBJECT ${plot_header_check_sources}) add_library(plot_header_self_containment OBJECT ${plot_header_check_sources})
target_link_libraries(plot_header_self_containment PRIVATE plot::core) target_link_libraries(plot_header_self_containment PRIVATE plot::core)
plot_apply_warnings(plot_header_self_containment) 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 # 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 # 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_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") set(PLOT_UMBRELLA "${CMAKE_SOURCE_DIR}/include/plot/plot.hpp")
include("${CMAKE_SOURCE_DIR}/cmake/plot_umbrella_check.cmake") 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 add_test(NAME plot_umbrella_covers_public_headers
COMMAND ${CMAKE_COMMAND} COMMAND ${CMAKE_COMMAND}
-DPLOT_INCLUDE_DIR=${CMAKE_SOURCE_DIR}/include -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") -P "${CMAKE_SOURCE_DIR}/cmake/plot_umbrella_check.cmake")
add_test(NAME plot_umbrella_check COMMAND plot_umbrella_check) add_test(NAME plot_umbrella_check COMMAND plot_umbrella_check)
+352 -24
View File
@@ -187,21 +187,30 @@ PLOT_TEST(hovered_cursor_only_inside_plot_area) {
PLOT_CHECK(c.hovered_cursor() == cid); 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; plot::Plot p;
p.set_viewport({0, 0, 800, 400}); 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 ax = p.add_x_axis({"t", {0.0, 800.0}, false, plot::AxisPlace::Bottom, false});
const u32 cid = p.add_cursor(20.0, plot::Color::hex(0xFF0000)); 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); plot::input::Controller c(p);
c.set_viewport({0, 0, 800, 400}); 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}); c.on_pointer({sx, 200, 0, 0, plot::input::Button::Left, kCtrl, true, true, false});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::MoveCursor); PLOT_CHECK(c.drag_mode() == plot::input::DragMode::BoxZoom);
c.on_pointer({sx + 100, 200, 100, 0, plot::input::Button::Left, kCtrl, true, false, false}); c.on_pointer({sx + 100, 260, 100, 60, plot::input::Button::Left, kCtrl, true, false, false});
c.on_pointer({sx + 100, 200, 0, 0, plot::input::Button::Left, kCtrl, false, false, true}); 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(c.drag_mode() == plot::input::DragMode::None);
PLOT_CHECK(p.cursor(cid).x > 20.0); PLOT_CHECK_NEAR(p.x_range(ax).min, want_lo, 1e-6);
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).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) { 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, c.on_pointer({sx + outside, 200.0f * scale, 0, 0, plot::input::Button::Left, 0, false, false,
false}); false});
PLOT_CHECK(c.hovered_cursor() == plot::kInvalid); PLOT_CHECK(c.hovered_cursor() == plot::kInvalid);
// ...and a ctrl-drag at that same logical distance opens a cursor move, which is // ...and a press at that same logical distance opens a cursor move, which is
// the gesture the radius actually gates. // the gesture the radius actually gates. (A plain press: Ctrl is the x-only
c.on_pointer({sx + inside, 200.0f * scale, 0, 0, plot::input::Button::Left, kCtrl, true, true, // 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}); false});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::MoveCursor); 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.drag_mode() == plot::input::DragMode::None);
PLOT_CHECK(f.controller.grabbed_cursor() == plot::kInvalid); PLOT_CHECK(f.controller.grabbed_cursor() == plot::kInvalid);
PLOT_CHECK(f.plot.hot_handles().grabbed_cursor == plot::kInvalid); PLOT_CHECK(f.plot.hot_handles().grabbed_cursor == plot::kInvalid);
// The grab is the *plain* press's gesture: Ctrl+Left is the x-only box zoom now
// The same press with ctrl is the same gesture, not a different mode. // (see ctrl_drag_box_zooms_x_only_even_over_a_cursor), so there is no modifier
const f32 moved_to = column_of(f.plot, f.ax, f.plot.cursor(cid).x); // variant of a cursor grab to pin here.
const f64 min_before = f.plot.x_range(f.ax).min;
press(f.controller, moved_to, 200.0f, kCtrl);
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor);
PLOT_CHECK(f.controller.grabbed_cursor() == cid);
move_to(f.controller, moved_to - 40.0f, 200.0f, kCtrl);
PLOT_CHECK_NEAR(f.plot.cursor(cid).x, data_at(f.plot, f.ax, moved_to - 40.0f), 1e-9);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, min_before, 1e-9); // still no pan
release(f.controller, moved_to - 40.0f, 200.0f, kCtrl);
} }
/// A snapping cursor follows the pointer for the whole drag: the drag writes the free /// 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 cid = f.plot.add_cursor(20.0, plot::Color::hex(0x00FF00));
const u32 rid = f.plot.add_region(free_region(40.0, 60.0)); 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 f32 px = screen_x_of(f.plot, f.ax, 85.0);
const f64 grabbed = data_at(f.plot, f.ax, px); const f64 grabbed = data_at(f.plot, f.ax, px);
const f64 min_before = f.plot.x_range(f.ax).min; 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(fx.plot.crosshair().active);
PLOT_CHECK_NEAR(fx.plot.crosshair().x, fx.plot.x_transform(0u).to_data_x(far_x), 1e-9); PLOT_CHECK_NEAR(fx.plot.crosshair().x, fx.plot.x_transform(0u).to_data_x(far_x), 1e-9);
} }
// --- hardening wave: B5 zero-notch wheel and dx pan, and the new gestures ---
namespace {
/// A plot with an x and a y axis, both auto-scaled, so every "did the gesture
/// disarm the fit" question has an armed state to start from.
struct ArmedFixture {
plot::Plot plot;
u32 ax = 0;
u32 ay = 0;
plot::input::Controller controller{plot};
ArmedFixture() {
plot.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
ax = plot.add_x_axis({"t", {0.0, 100.0}, true, plot::AxisPlace::Bottom, false});
ay = plot.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
controller.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
}
};
} // namespace
/// B5: a zero-notch wheel (`dy == 0 && dx == 0`, which hosts and trackpads really
/// forward) changes nothing at all -- most importantly it does not run the range
/// setters, which clear `auto_scale`, so one empty scroll must not disarm
/// auto-fit.
PLOT_TEST(zero_notch_wheel_disarms_nothing) {
ArmedFixture f;
f.controller.on_wheel({400.0f, 200.0f, 0.0f, 0.0f, 0});
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale);
PLOT_CHECK(f.plot.y_axis(f.ay).auto_scale);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 0.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 100.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -1.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.0, 1e-9);
}
/// B5: `dy == 0` with `dx != 0` pans x only -- 5% of the x span per notch, in axis
/// space (log-aware), positive `dx` scrolling right -- and leaves the y axis,
/// including its armed auto scale, exactly as it was.
PLOT_TEST(horizontal_wheel_pans_x_only) {
ArmedFixture f;
f.controller.on_wheel({400.0f, 200.0f, 2.0f, 0.0f, 0}); // two notches right
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 10.0, 1e-9); // 2 * 5% of 100
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 110.0, 1e-9);
PLOT_CHECK(!f.plot.x_axis(f.ax).auto_scale); // a pan is a user choice
PLOT_CHECK(f.plot.y_axis(f.ay).auto_scale); // the untouched axis keeps its fit
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -1.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.0, 1e-9);
// Log-aware: the pan runs in axis space, so one notch on a log axis is a
// fraction of a *decade*, not of a linear width.
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 lg = p.add_x_axis({"f", {1.0, 100.0}, true, plot::AxisPlace::Bottom, true});
plot::input::Controller c(p);
c.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
c.on_wheel({400.0f, 200.0f, 1.0f, 0.0f, 0});
const plot::Range r = p.x_range(lg);
PLOT_CHECK_NEAR(std::log10(r.max) - std::log10(r.min), 2.0, 1e-9); // decades kept
PLOT_CHECK_NEAR(std::log10(r.max), 2.1, 1e-9); // one notch = 5% of the 2-decade span
}
/// B5: a zoom whose factor could not be applied honestly -- non-finite (a huge
/// downward `dy` explodes `exp`) -- is refused, and leaves the ranges *and* the
/// armed auto scale exactly as they were.
PLOT_TEST(wheel_zoom_refuses_a_non_finite_factor) {
ArmedFixture f;
f.controller.on_wheel({400.0f, 200.0f, 0.0f, -1e38f, 0});
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale);
PLOT_CHECK(f.plot.y_axis(f.ay).auto_scale);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 100.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.0, 1e-9);
}
/// B5: the middle-drag axis zoom carries the same guards. A motion that reports no
/// `dy` is a factor of exactly one -- refused, so it cannot clear `auto_scale` on
/// its way through the range setters -- and one that explodes the factor is
/// refused too.
PLOT_TEST(axis_zoom_refuses_degenerate_factors) {
ArmedFixture f;
const plot::PlotLayout& l = f.plot.layout();
const f32 strip_x = l.axes.x + 0.5f * l.axes.w;
const f32 strip_y = l.x_label_area.y + 0.5f * l.x_label_area.h;
f.controller.on_pointer({strip_x, strip_y, 0, 0, plot::input::Button::Middle, 0, true, true,
false});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::AxisZoomX);
// A stationary motion: no zoom, and the fits stay armed.
f.controller.on_pointer({strip_x, strip_y, 0, 0, plot::input::Button::Middle, 0, true, false,
false});
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), 100.0, 1e-9);
// An exploding motion: refused, nothing stored, nothing disarmed.
f.controller.on_pointer({strip_x, strip_y, 0, 1e38f, plot::input::Button::Middle, 0, true,
false, false});
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), 100.0, 1e-9);
f.controller.on_pointer({strip_x, strip_y, 0, 0, plot::input::Button::Middle, 0, false, false,
true});
}
/// The arrow keys pan by axis class: Left/Right every x axis, Up/Down every y
/// axis, 10% of that axis' own span per press, in axis space (log-aware). An arrow
/// says where to look: Left walks the window toward smaller x, Up toward larger y.
PLOT_TEST(arrow_keys_pan_a_tenth_of_the_span) {
ArmedFixture f;
f.plot.set_x_auto_scale(f.ax, false);
f.controller.on_key({plot::input::kKeyRight, 0, true});
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 10.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 110.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -1.0, 1e-9); // x-only
f.controller.on_key({plot::input::kKeyLeft, 0, true});
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 0.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 100.0, 1e-9);
f.controller.on_key({plot::input::kKeyUp, 0, true});
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -0.8, 1e-9); // +10% of the 2 span
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.2, 1e-9);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 0.0, 1e-9); // y-only
f.controller.on_key({plot::input::kKeyDown, 0, true});
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -1.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.0, 1e-9);
// Log-aware: an arrow on a log axis moves decades, not units.
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 lg = p.add_x_axis({"f", {1.0, 100.0}, false, plot::AxisPlace::Bottom, true});
p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
plot::input::Controller c(p);
c.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
c.on_key({plot::input::kKeyRight, 0, true});
const plot::Range r = p.x_range(lg);
PLOT_CHECK_NEAR(std::log10(r.max) - std::log10(r.min), 2.0, 1e-9);
PLOT_CHECK_NEAR(std::log10(r.max), 2.2, 1e-9); // +10% of the 2-decade span
// A key with no gesture changes nothing.
c.on_key({'q', 0, true});
PLOT_CHECK_NEAR(std::log10(p.x_range(lg).max), 2.2, 1e-9);
}
/// Escape cancels the active drag: the ranges (and auto-scale flags) the axes held
/// at `begin_drag` are restored, and the drag is closed -- the release that
/// follows applies nothing.
PLOT_TEST(escape_cancels_a_pan_and_restores_the_captured_ranges) {
ArmedFixture f;
const f64 x_min = f.plot.x_range(f.ax).min;
const f64 x_max = f.plot.x_range(f.ax).max;
const f64 y_min = f.plot.y_range(f.ay).min;
const f64 y_max = f.plot.y_range(f.ay).max;
f.controller.on_pointer({100.0f, 100.0f, 0, 0, plot::input::Button::Left, 0, true, true,
false});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::Pan);
f.controller.on_pointer({300.0f, 200.0f, 200.0f, 100.0f, plot::input::Button::Left, 0, true,
false, false});
PLOT_CHECK(f.plot.x_range(f.ax).min < x_min); // the gesture did move things
PLOT_CHECK(!f.plot.x_axis(f.ax).auto_scale);
f.controller.on_key({plot::input::kKeyEscape, 0, true});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::None);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, x_min, 1e-9);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, x_max, 1e-9);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, y_min, 1e-9);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, y_max, 1e-9);
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale); // the fit is armed again
PLOT_CHECK(f.plot.y_axis(f.ay).auto_scale);
// The release arrives after the cancel; there is no gesture left to end.
f.controller.on_pointer({300.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
true});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::None);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, x_min, 1e-9);
}
/// Escape drops a box zoom without applying it: the selection is discarded and the
/// ranges the axes held when the drag began are still there at the end.
PLOT_TEST(escape_drops_a_box_zoom_without_applying) {
ArmedFixture f;
const f64 x_max = f.plot.x_range(f.ax).max;
f.controller.on_pointer({100.0f, 50.0f, 0, 0, plot::input::Button::Left, kShift, true, true,
false});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::BoxZoom);
f.controller.on_pointer({300.0f, 150.0f, 200.0f, 100.0f, plot::input::Button::Left, kShift,
true, false, false});
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale); // nothing applies before the release
f.controller.on_key({plot::input::kKeyEscape, 0, true});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::None);
PLOT_CHECK_NEAR(f.controller.selection_rect().w, 0.0, 1e-9); // the box is gone
f.controller.on_pointer({300.0f, 150.0f, 0, 0, plot::input::Button::Left, kShift, false,
false, true});
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, x_max, 1e-9); // and nothing was framed
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale);
}
/// The box-zoom masks: Alt+Left frames the y range only -- from a selection too
/// thin horizontally to be a two-axis box, which is what makes the threshold
/// masked with the gesture.
PLOT_TEST(alt_drag_box_zooms_y_only) {
ArmedFixture f;
constexpr u32 kAlt = static_cast<u32>(plot::input::Mod::Alt);
const plot::Rect axes = f.plot.layout().axes;
const f64 want_lo = f.plot.y_transform(f.ay).to_data_y(axes.y + 150.0f);
const f64 want_hi = f.plot.y_transform(f.ay).to_data_y(axes.y + 50.0f);
// Two logical px wide: an unmasked box would call this a click.
f.controller.on_pointer({200.0f, axes.y + 50.0f, 0, 0, plot::input::Button::Left, kAlt, true,
true, false});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::BoxZoom);
f.controller.on_pointer({202.0f, axes.y + 150.0f, 2, 100, plot::input::Button::Left, kAlt,
true, false, false});
f.controller.on_pointer({202.0f, axes.y + 150.0f, 0, 0, plot::input::Button::Left, kAlt,
false, false, true});
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, want_lo, 1e-6);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, want_hi, 1e-6);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 0.0, 1e-9); // x untouched
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 100.0, 1e-9);
}
/// The Ctrl mask, applied at release from a selection that only makes sense
/// horizontally: the x range is framed from the box while the y range is left
/// alone, and a selection thin *vertically* is still a box under the mask.
PLOT_TEST(ctrl_mask_frames_x_from_a_thin_horizontal_band) {
ArmedFixture f;
const plot::Rect axes = f.plot.layout().axes;
const f64 want_lo = f.plot.x_transform(f.ax).to_data_x(axes.x + 100.0f);
const f64 want_hi = f.plot.x_transform(f.ax).to_data_x(axes.x + 300.0f);
f.controller.on_pointer({axes.x + 100.0f, 200.0f, 0, 0, plot::input::Button::Left, kCtrl,
true, true, false});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::BoxZoom);
// Two logical px tall: an unmasked box would call this a click.
f.controller.on_pointer({axes.x + 300.0f, 202.0f, 200, 2, plot::input::Button::Left, kCtrl,
true, false, false});
f.controller.on_pointer({axes.x + 300.0f, 202.0f, 0, 0, plot::input::Button::Left, kCtrl,
false, false, true});
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, want_lo, 1e-6);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, want_hi, 1e-6);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -1.0, 1e-9); // y untouched
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.0, 1e-9);
}
/// The double-click reset: two presses of the same button within 0.35 s and 4 px
/// (on `PointerEvent::time_s`) re-fit every axis -- auto scale re-armed with the
/// fit -- and the second press begins no drag.
PLOT_TEST(double_click_refits_every_axis_and_starts_no_drag) {
// A plot that has something to fit: the reset re-fits every axis to its traces,
// so this fixture carries one (the ArmedFixture above is deliberately empty --
// its tests are about auto-scale *state*, and an axis with no trace has nothing
// to fit). The axes start disarmed so the initial layout cannot pre-fit them.
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ax = p.add_x_axis({"t", {5.0, 60.0}, false, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {-0.5, 0.5}, false, plot::AxisPlace::Left, false});
std::vector<f32> ys(101);
for (u64 i = 0; i <= 100u; ++i) ys[static_cast<std::size_t>(i)] = (i % 2u == 0u) ? -1.0f : 1.0f;
plot::data::ArraySeries series(std::move(ys), 0.0, 1.0); // x = 0..100, y in [-1, 1]
p.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, ax, ay});
plot::input::Controller c(p);
c.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
// Zoom in first, so the window is somewhere a reset has to leave.
c.on_wheel({400.0f, 200.0f, 0.0f, 1.0f, 0});
PLOT_CHECK(p.x_range(ax).span() < 60.0);
PLOT_CHECK(p.y_range(ay).span() < 1.0);
c.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true, false,
/*time_s=*/1.0});
c.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false, true, 1.0});
c.on_pointer({402.0f, 201.0f, 2, 1, plot::input::Button::Left, 0, true, true, false,
1.2}); // 0.2 s and ~2.2 px later
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None); // no drag began
PLOT_CHECK(p.x_axis(ax).auto_scale); // the reset fit and re-armed
PLOT_CHECK(p.y_axis(ay).auto_scale);
// Both axes frame their traces' bounds (the 2% pad keeps the data inside them),
// which is what a fit means here.
PLOT_CHECK(p.x_range(ax).min < 0.0);
PLOT_CHECK(p.x_range(ax).max > 100.0);
PLOT_CHECK(p.y_range(ay).min < -1.0);
PLOT_CHECK(p.y_range(ay).max > 1.0);
// And the re-armed fit survives a layout: the frame the host draws next does not
// move the window the reset just chose.
const plot::Range xr = p.x_range(ax);
p.layout();
PLOT_CHECK_NEAR(p.x_range(ax).min, xr.min, 1e-9);
PLOT_CHECK_NEAR(p.x_range(ax).max, xr.max, 1e-9);
// A third press starts a fresh gesture (a pan), not a second reset.
c.on_pointer({402.0f, 201.0f, 0, 0, plot::input::Button::Left, 0, true, true, false, 1.4});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::Pan);
c.on_pointer({402.0f, 201.0f, 0, 0, plot::input::Button::Left, 0, false, false, true, 1.4});
}
/// A double-click needs the host's clock: presses that carry `time_s == 0` are
/// never one, whatever their spacing, and presses outside the window (too slow or
/// too far apart) are two ordinary gestures -- the second one a drag.
PLOT_TEST(double_click_needs_both_timestamps_and_the_window) {
ArmedFixture f;
f.plot.set_x_auto_scale(f.ax, false);
// No timestamps: two presses at the same spot do not reset.
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true,
false, 0.0});
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
true, 0.0});
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true,
false, 0.0});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::Pan);
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
true, 0.0});
// Too slow: 0.4 s apart is two gestures.
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true,
false, 10.0});
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
true, 10.0});
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true,
false, 10.4});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::Pan);
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
true, 10.4});
// Too far: 10 px apart is two gestures.
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true,
false, 20.0});
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
true, 20.0});
f.controller.on_pointer({410.0f, 200.0f, 10, 0, plot::input::Button::Left, 0, true, true,
false, 20.1});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::Pan);
f.controller.on_pointer({410.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
true, 20.1});
}
+76
View File
@@ -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) // ------------------------------------------------- the crosshair (spec §15)
namespace { namespace {
+9 -3
View File
@@ -494,13 +494,16 @@ PLOT_TEST(render_programs_are_inert_until_created) {
line.set_offset({0.0f, 0.0f}); line.set_offset({0.0f, 0.0f});
line.set_viewport({256.0f, 256.0f}); line.set_viewport({256.0f, 256.0f});
line.set_width(2.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)); line.set_color(plot::Color::hex(0xFFFFFF));
text.bind(); text.bind();
text.set_viewport({256.0f, 256.0f}); text.set_viewport({256.0f, 256.0f});
text.set_px_range({8.0f, 8.0f}); text.set_px_range({8.0f, 8.0f});
text.set_glyph_uv({1.0f / 16.0f, 1.0f / 7.0f}); text.set_glyph_uv({1.0f / 16.0f, 1.0f / 7.0f});
text.set_texture_size({128.0f, 56.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); PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
ctx.destroy(); ctx.destroy();
} }
@@ -541,6 +544,10 @@ PLOT_TEST(line_pipeline_draws_an_aa_strip) {
program.set_offset({64.0f, 100.0f}); program.set_offset({64.0f, 100.0f});
program.set_viewport({static_cast<f32>(ctx.width), static_cast<f32>(ctx.height)}); program.set_viewport({static_cast<f32>(ctx.width), static_cast<f32>(ctx.height)});
program.set_width(3.0f); 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)); program.set_color(plot::Color::hex(0xFF00FF));
lines.bind(); lines.bind();
plot::gl::glDrawArraysInstanced(plot::gl::GL_TRIANGLE_STRIP, 0, 4, 3); plot::gl::glDrawArraysInstanced(plot::gl::GL_TRIANGLE_STRIP, 0, 4, 3);
@@ -619,7 +626,7 @@ PLOT_TEST(text_pipeline_draws_a_glyph) {
// an identity. // an identity.
batch.add_text("A", {64.0f, 100.0f}, 1.0f, font); batch.add_text("A", {64.0f, 100.0f}, 1.0f, font);
PLOT_CHECK(batch.glyph_count() == 1); 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()); glyphs.upload(batch.data().data(), batch.glyph_count());
plot::gl::glViewport(0, 0, static_cast<plot::gl::GLsizei>(ctx.width), plot::gl::glViewport(0, 0, static_cast<plot::gl::GLsizei>(ctx.width),
@@ -640,7 +647,6 @@ PLOT_TEST(text_pipeline_draws_a_glyph) {
// White, so the blended pixel value *is* the coverage byte: the shader emits // 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 // `vec4(rgb, a * coverage)`, and a white tint over the black clear lands at
// `1.0 * coverage` in every channel. // `1.0 * coverage` in every channel.
program.set_color(plot::Color::hex(0xFFFFFF));
atlas.bind(); atlas.bind();
glyphs.bind(); glyphs.bind();
plot::gl::glDrawArraysInstanced(plot::gl::GL_TRIANGLE_STRIP, 0, 4, 1); plot::gl::glDrawArraysInstanced(plot::gl::GL_TRIANGLE_STRIP, 0, 4, 1);
+127
View File
@@ -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); 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_TEST(the_host_keeps_its_own_controller_while_no_figure_is_set) {
plot::glfw::Host host; plot::glfw::Host host;
PLOT_CHECK(host.figure() == nullptr); PLOT_CHECK(host.figure() == nullptr);
+105
View File
@@ -26,7 +26,9 @@ using plot::u32;
using plot::gtk4::DeviceMapping; using plot::gtk4::DeviceMapping;
using plot::gtk4::DragAnchor; using plot::gtk4::DragAnchor;
using plot::gtk4::ScrollUnit; using plot::gtk4::ScrollUnit;
using plot::gtk4::ZoomAnchor;
using plot::gtk4::button_from; using plot::gtk4::button_from;
using plot::gtk4::key_from;
using plot::gtk4::kSurfacePixelsPerNotch; using plot::gtk4::kSurfacePixelsPerNotch;
using plot::gtk4::mods_from; using plot::gtk4::mods_from;
using plot::gtk4::scroll_notches; 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(8u).has_value()); // a navigation button
PLOT_CHECK(!button_from(9u).has_value()); PLOT_CHECK(!button_from(9u).has_value());
} }
// --- the pinch --------------------------------------------------------------
// `GtkGestureZoom` reports a cumulative scale since the gesture began, while the
// controller's `GestureEvent` wants this frame's zoom factor; `ZoomAnchor` is the
// fold, and it is the same fold the Qt adapter's `PinchTracker` does -- a
// cumulative 1.0 -> 1.25 -> 1.5 spread is a 1.25 frame then a 1.2 frame, so the
// zoom the plot applies is the zoom the fingers made.
PLOT_TEST(gtk_zoom_anchor_folds_the_cumulative_scale_into_increments) {
// A 2x widget: the focal point and its movement are reported in *device* pixels,
// like every coordinate the controller sees.
const DeviceMapping at_2 = DeviceMapping::from_sizes({400.0f, 300.0f}, {800.0f, 600.0f});
plot::gtk4::ZoomAnchor zoom;
zoom.begin(1.0f, {100.0f, 75.0f});
PLOT_CHECK(zoom.active);
// The begin itself folds to "nothing happened yet", whenever the first
// scale-changed arrives with the scale it began at.
const plot::input::GestureEvent started = zoom.update(1.0f, {100.0f, 75.0f}, at_2);
PLOT_CHECK_NEAR(started.scale, 1.0, 1e-6);
PLOT_CHECK_NEAR(started.dx, 0.0, 1e-6);
PLOT_CHECK_NEAR(started.dy, 0.0, 1e-6);
// Fingers spread to 1.25 about a steady centre: one 1.25 zoom frame, anchored
// at the centre in device pixels (100 * 2, 75 * 2).
const plot::input::GestureEvent spread = zoom.update(1.25f, {100.0f, 75.0f}, at_2);
PLOT_CHECK_NEAR(spread.scale, 1.25, 1e-6);
PLOT_CHECK_NEAR(spread.x, 200.0, 1e-6);
PLOT_CHECK_NEAR(spread.y, 150.0, 1e-6);
PLOT_CHECK_NEAR(spread.dx, 0.0, 1e-6);
PLOT_CHECK_NEAR(spread.dy, 0.0, 1e-6);
// Spread on to 1.5 while the fingers drift 10 logical pixels right: the
// increment is 1.2 and the pan is 20 device pixels.
const plot::input::GestureEvent drifted = zoom.update(1.5f, {110.0f, 75.0f}, at_2);
PLOT_CHECK_NEAR(drifted.scale, 1.2, 1e-6);
PLOT_CHECK_NEAR(drifted.dx, 20.0, 1e-6);
PLOT_CHECK_NEAR(drifted.dy, 0.0, 1e-6);
}
// The focal point is cached in logical widget pixels, so a factor change between
// two updates (the window dragged to another display mid-pinch, GTK re-emitting
// `::resize`) cannot read the ratio change as a focal jump; and an update outside
// a gesture -- before any begin, or a straggler after the end -- zooms and moves
// nothing rather than zooming from a made-up anchor.
PLOT_TEST(gtk_zoom_anchor_stays_logical_and_ignores_stragglers) {
const DeviceMapping at_1 = DeviceMapping::from_sizes({400.0f, 300.0f}, {400.0f, 300.0f});
const DeviceMapping at_125 = DeviceMapping::from_sizes({400.0f, 300.0f}, {500.0f, 375.0f});
plot::gtk4::ZoomAnchor zoom;
// The orphan update, before any begin.
const plot::input::GestureEvent orphan = zoom.update(2.0f, {10.0f, 10.0f}, at_1);
PLOT_CHECK_NEAR(orphan.scale, 1.0, 1e-9);
PLOT_CHECK_NEAR(orphan.dx, 0.0, 1e-9);
PLOT_CHECK_NEAR(orphan.dy, 0.0, 1e-9);
zoom.begin(1.0f, {100.0f, 100.0f});
// Four logical pixels of drift across a 1.0 -> 1.25 factor change: 4 * 1.25 =
// 5 device pixels, and the anchor for the next frame is the new logical point.
const plot::input::GestureEvent rescaled = zoom.update(1.0f, {104.0f, 100.0f}, at_125);
PLOT_CHECK_NEAR(rescaled.scale, 1.0, 1e-6);
PLOT_CHECK_NEAR(rescaled.dx, 5.0, 1e-6);
PLOT_CHECK_NEAR(rescaled.x, 104.0 * 1.25, 1e-6);
// The gesture ends; a straggler scale-changed does nothing.
zoom.end();
PLOT_CHECK(!zoom.active);
const plot::input::GestureEvent straggler = zoom.update(2.0f, {120.0f, 100.0f}, at_125);
PLOT_CHECK_NEAR(straggler.scale, 1.0, 1e-9);
PLOT_CHECK_NEAR(straggler.dx, 0.0, 1e-9);
}
// --- the keyboard -----------------------------------------------------------
// The whole key translation, now that it names arrows and Escape too. The
// function keys are matched on the keyval, which for them is layout- and
// level-independent; the printable keys on the Unicode the keyval decodes to,
// which is what makes `+` and shift+`=` (and the keypad's plus) one key to the
// controller. Anything else is `0` -- the caller hands the event back to GTK.
PLOT_TEST(gtk_keys_map_by_keyval_first_and_unicode_second) {
PLOT_CHECK(key_from(0xFF1Bu, 0u) == plot::input::kKeyEscape); // GDK_KEY_Escape
PLOT_CHECK(key_from(0xFF51u, 0u) == plot::input::kKeyLeft); // GDK_KEY_Left
PLOT_CHECK(key_from(0xFF53u, 0u) == plot::input::kKeyRight); // GDK_KEY_Right
PLOT_CHECK(key_from(0xFF52u, 0u) == plot::input::kKeyUp);
PLOT_CHECK(key_from(0xFF54u, 0u) == plot::input::kKeyDown);
// Printable keys, by code point -- the keyval is irrelevant to this branch.
PLOT_CHECK(key_from(0x002Bu, static_cast<u32>('+')) == plot::input::kKeyZoomIn);
PLOT_CHECK(key_from(0x003Du, static_cast<u32>('=')) == plot::input::kKeyZoomIn);
PLOT_CHECK(key_from(0x002Du, static_cast<u32>('-')) == plot::input::kKeyZoomOut);
PLOT_CHECK(key_from(0x005Fu, static_cast<u32>('_')) == plot::input::kKeyZoomOut);
PLOT_CHECK(key_from(0x0066u, static_cast<u32>('f')) == plot::input::kKeyFit);
PLOT_CHECK(key_from(0x0046u, static_cast<u32>('F')) == plot::input::kKeyFit);
// The keyval wins when both could match: the order is the seam's contract, not
// an accident of GDK's tables.
PLOT_CHECK(key_from(0xFF51u, static_cast<u32>('x')) == plot::input::kKeyLeft);
// And nothing a plot has no gesture for maps to a key: an unmapped keyval with
// an unmapped code point is refused, and so is a bare zero.
PLOT_CHECK(key_from(0x0078u, static_cast<u32>('x')) == 0);
PLOT_CHECK(key_from(0u, 0u) == 0);
}
+118
View File
@@ -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: // 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 // nothing can move a range once the button is up, so the same check after the
// release could not fail and would prove nothing. // 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); f.frame(ImVec2(200.0f, 200.0f), false);
io.DeltaTime = 1.0f / 60.0f;
f.frame(ImVec2(200.0f, 200.0f), true); f.frame(ImVec2(200.0f, 200.0f), true);
const plot::Range second_press = f.x_range(); const plot::Range second_press = f.x_range();
f.frame(ImVec2(-kMaxFloat, -kMaxFloat), true); 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.renderer.axis_font_px(), cell_1x, 1e-3);
PLOT_CHECK_NEAR(f.plot.layout().y_axis_width, strip_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);
}
+39
View File
@@ -3,6 +3,7 @@
#include "harness.hpp" #include "harness.hpp"
#include <plot/plot/measure.hpp> #include <plot/plot/measure.hpp>
#include <plot/plot/plot.hpp>
#include <plot/data/array_series.hpp> #include <plot/data/array_series.hpp>
#include <plot/data/detail/cover_count.hpp> #include <plot/data/detail/cover_count.hpp>
#include <algorithm> #include <algorithm>
@@ -65,3 +66,41 @@ PLOT_TEST(empty_and_single_sample_intervals_are_valid_false) {
PLOT_CHECK(!plot::Measure::evaluate(s, 0, 0).valid); PLOT_CHECK(!plot::Measure::evaluate(s, 0, 0).valid);
PLOT_CHECK(!plot::Measure::evaluate(s, 1, 1).valid); PLOT_CHECK(!plot::Measure::evaluate(s, 1, 1).valid);
} }
PLOT_TEST(a_measure_over_a_ruler_reports_invalid) {
// R8: a measurement is read from two *x* coordinates, and a horizontal ruler has
// no x -- it is the dead field of its orientation. Evaluating the pair anyway
// silently measured from x=0; now the id `add_measure` returns is real but
// reports an invalid measurement, and names no band to shade either.
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
std::vector<f32> ys(100);
for (u64 i = 0; i < 100u; ++i) ys[static_cast<std::size_t>(i)] = f32(i) * 0.01f;
plot::data::ArraySeries series(std::move(ys), 0.0, 1.0);
p.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
const u32 ra = p.add_h_ruler(0.5, plot::Color::hex(0xFFE080));
const u32 rb = p.add_h_ruler(-0.5, plot::Color::hex(0xFFE080));
const u32 rulers = p.add_measure(ra, rb);
PLOT_CHECK(!p.measurement(rulers).valid);
PLOT_CHECK(!p.measure_span(rulers).valid); // and no band between dead x's to shade
// A ruler paired with a real marker is refused for the same reason: one half of
// the pair has no x to measure from.
const u32 ca = p.add_cursor(10.0, plot::Color::hex(0xFFFFFF));
const u32 mixed = p.add_measure(ca, ra);
PLOT_CHECK(!p.measurement(mixed).valid);
PLOT_CHECK(!p.measure_span(mixed).valid);
// Sanity: a marker pair over the same series still measures. The extremes are
// the stored f32 samples' values, so the expectation is derived from the same
// arithmetic the store used, not from the decimal literals.
const u32 cb = p.add_cursor(20.0, plot::Color::hex(0xFFFFFF));
const u32 markers = p.add_measure(ca, cb);
const plot::Measurement& m = p.measurement(markers);
PLOT_CHECK(m.valid);
PLOT_CHECK_NEAR(m.ymin, static_cast<f64>(f32(10) * 0.01f), 1e-12);
PLOT_CHECK_NEAR(m.ymax, static_cast<f64>(f32(20) * 0.01f), 1e-12);
}
+52
View File
@@ -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); const u64 slid = plot::data::detail::merge_count().load(std::memory_order_relaxed);
PLOT_CHECK(slid <= kAppends / 16); PLOT_CHECK(slid <= kAppends / 16);
} }
// --- B7: non-finite samples are excluded from every aggregate ----------------
PLOT_TEST(pyramid_excludes_non_finite_samples_from_aggregates) {
// NaN at the first, middle and last positions of the window, and a whole
// all-NaN bucket in the middle: the aggregates never see them.
MinMaxPyramid p;
p.append(std::nanf("")); // first
for (int i = 0; i < 63; ++i) p.append(1.0f);
for (int i = 0; i < kBucket; ++i) p.append(std::nanf("")); // bucket 1: all NaN
for (int i = 0; i < 63; ++i) p.append(2.0f);
p.append(std::nanf("")); // last
PLOT_CHECK(p.size() == 64 + kBucket + 64);
// Bucket 0: 63 ones; bucket 1: empty; bucket 2: 63 twos (the final NaN is
// the pending bucket's only sample, never a whole bucket).
PLOT_CHECK(p.bucket_count(0, 0) == 63);
PLOT_CHECK(p.bucket_count(0, 1) == 0);
PLOT_CHECK(p.bucket_count(0, 2) == 63);
const Range r0 = p.reduce_buckets(0, kBucket);
PLOT_CHECK_NEAR(r0.min, 1.0, 1e-6);
PLOT_CHECK_NEAR(r0.max, 1.0, 1e-6);
PLOT_CHECK(p.reduce_buckets(kBucket, 2 * kBucket).span() == 0.0); // the NaN bucket
PLOT_CHECK(p.sum_buckets(kBucket, 2 * kBucket) == 0.0);
PLOT_CHECK(p.sumsq_buckets(kBucket, 2 * kBucket) == 0.0);
// The merge above an empty child keeps the other child's extremes: level 1
// bucket 0 covers buckets 0 and 1, and must report the ones, not a 0.
PLOT_CHECK(p.level_size(1) >= 1);
const MinMax top = p.bucket(1, p.level_base(1));
PLOT_CHECK_NEAR(top.min, 1.0, 1e-6);
PLOT_CHECK_NEAR(top.max, 1.0, 1e-6);
PLOT_CHECK(p.bucket_count(1, p.level_base(1)) == 63);
// A range spanning the finite buckets on both sides of the empty one.
const Range all = p.reduce_buckets(0, 3 * kBucket);
PLOT_CHECK_NEAR(all.min, 1.0, 1e-6);
PLOT_CHECK_NEAR(all.max, 2.0, 1e-6);
PLOT_CHECK_NEAR(p.sum_buckets(0, 3 * kBucket), 63.0 + 126.0, 1e-6);
// bucket_sum / bucket_count is the mean the Mean gather mode folds.
PLOT_CHECK_NEAR(p.bucket_sum(0, 0) / f64(p.bucket_count(0, 0)), 1.0, 1e-9);
}
// --- R3: the deepest level is capped where the span shift still fits ---------
PLOT_TEST(pyramid_level_cap_keeps_the_span_shift_defined) {
// Level 57 is the last whose bucket span (kBucket << 57 == 2^63) fits a u64;
// past it the shift would be undefined, so the constant pins the cap and
// reserve() never materialises more levels than it.
PLOT_CHECK((u64{kBucket} << plot::data::kMaxPyramidLevel) == (u64{1} << 63));
MinMaxPyramid p;
p.reserve(~u64{0}); // a window no sample count can reach: still just the cap
p.append(1.0f);
PLOT_CHECK(p.level_count() >= 1);
}
+292 -2
View File
@@ -1939,10 +1939,11 @@ class CountingSeries final : public plot::data::Series {
} }
plot::Range y_bounds() const override { return {0.0, 99.0}; } plot::Range y_bounds() const override { return {0.0, 99.0}; }
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; } const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out) const override { void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out,
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override {
++gather_calls_; ++gather_calls_;
samples_read_ += end - begin; 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_; } 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. // quarter of a million of them.
PLOT_CHECK(series.size() == kSamples); PLOT_CHECK(series.size() == kSamples);
} }
// --- hardening wave: R1, R2, B4, and the Series-virtual routing -------------
namespace {
/// A minimal **XY-like** series: the sample positions are this test's own array,
/// not a uniform grid, and only the lookups the plot layer routes through the
/// `Series` virtuals -- `x_at`/`neighbours`/`snap_index` -- are overridden. Any
/// plot-layer answer that assumed `x0() + i*dx()` is wrong here by construction,
/// which is what makes the fixture a regression test for the *routing* rather than
/// for the series: the real XY series (Stream A) must behave the same way, but
/// these tests stand on their own.
class XyFixture final : public plot::data::Series {
public:
XyFixture(std::vector<f64> x, std::vector<f32> y)
: x_(std::move(x)), y_(std::move(y)) {
pyramid_.reserve(y_.size());
for (const f32 v : y_) pyramid_.append(v);
}
u64 size() const override { return y_.size(); }
f64 x0() const override { return x_.empty() ? 0.0 : x_.front(); }
f64 dx() const override { return 1.0; } // nominal; the real positions are x_
plot::Range x_bounds() const override {
return x_.empty() ? plot::Range{} : plot::Range{x_.front(), x_.back()};
}
plot::Range y_bounds() const override { return {0.0, 20.0}; }
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out,
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override {
gather_impl(level, begin, end, out, mode);
}
f64 x_at(u64 i) const override { return x_[static_cast<std::size_t>(i)]; }
bool neighbours(f64 x, u64& i0, u64& i1) const override {
if (x_.empty() || x < x_.front() || x > x_.back()) return false;
const auto upper = std::lower_bound(x_.begin(), x_.end(), x);
const auto first = static_cast<u64>(upper - x_.begin());
if (upper != x_.end() && *upper == x) {
i0 = i1 = first; // the query landed on a sample
return true;
}
i1 = first;
i0 = first == 0u ? 0u : first - 1u;
return true;
}
u64 snap_index(f64 x) const override {
if (x_.empty()) return 0u;
u64 i0 = 0;
u64 i1 = 0;
if (!neighbours(x, i0, i1)) {
return x < x_.front() ? 0u : static_cast<u64>(x_.size() - 1u);
}
if (i0 == i1) return i0;
const f64 before = x - x_at(i0);
const f64 after = x_at(i1) - x;
return after < before ? i1 : i0;
}
protected:
void copy_raw(u64 begin, u64 end, f32* out) const override {
for (u64 i = begin; i < end && i < y_.size(); ++i) out[i - begin] = y_[i];
}
private:
std::vector<f64> x_;
std::vector<f32> y_;
plot::data::MinMaxPyramid pyramid_;
};
/// The XY scene: samples at x = 0, 1, 10 (deliberately non-uniform), y = 0, 10, 20,
/// and a window wide enough to frame them all.
struct XyScene {
XyFixture series{{0.0, 1.0, 10.0}, {0.0f, 10.0f, 20.0f}};
plot::Plot plot;
u32 ax = 0;
u32 ay = 0;
XyScene() {
plot.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
ax = plot.add_x_axis({"t", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false});
ay = plot.add_y_axis({"v", {0.0, 20.0}, false, plot::AxisPlace::Left, false});
}
};
/// A streaming series with decade-wide spacing on a log axis: sample k sits at
/// x = 1 + 10k, so feeding samples walks the axis through decades.
struct LogLiveFixture {
plot::data::StreamSeries series;
plot::Plot graph;
u32 ax = 0;
explicit LogLiveFixture(u64 push) : series(256u, 1.0, 10.0) {
graph.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
ax = graph.add_x_axis({"t", {1.0, 1000.0}, false, plot::AxisPlace::Bottom, /*log=*/true});
graph.add_y_axis({"v", {0.0, 1.0}, false, plot::AxisPlace::Left, false});
graph.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, ax, 0u});
feed(push);
graph.set_x_follow(ax, plot::XFollow::Live);
graph.auto_fit_x();
graph.layout();
}
void feed(u64 n) {
for (u64 i = 0; i < n; ++i) series.push(0.5f);
series.acquire();
}
f64 newest() const { return series.x_bounds().max; }
/// The visible window in decades -- what a log follow must hold constant.
f64 decades() const {
const plot::Range r = graph.x_range(ax);
return std::log10(r.max) - std::log10(r.min);
}
};
} // namespace
/// R1: the layout clamps are defined even for a hostile viewport. `set_viewport()`
/// normalises negative dimensions at the door; the belt-and-braces guard inside
/// `layout()` means the two can never drift out of step -- and this pair is what a
/// caller that hands the plot a negative rect straight away exercises, in a build
/// where the undefined clamp would be visible (asan).
PLOT_TEST(layout_survives_a_negative_viewport) {
plot::Plot p;
p.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"v", {0.0, 1.0}, false, plot::AxisPlace::Left, false});
p.set_viewport({10.0f, 10.0f, -200.0f, -100.0f}); // normalised to 10x10 zero-size
const plot::PlotLayout& lay = p.layout();
PLOT_CHECK(lay.axes.w >= 0.0f);
PLOT_CHECK(lay.axes.h >= 0.0f);
// And a plot laid out at zero size draws nothing but says nothing either: the
// same call again on the guarded dimensions stays well-defined.
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
PLOT_CHECK(p.layout().axes.w > 0.0f);
}
/// R2: the range setters refuse non-finite endpoints. A stored NaN or infinity
/// would hand every transform a garbage mapping *and* clear `auto_scale` -- the
/// one way back -- so the call is refused outright: the axis keeps the range it
/// had and the auto-scale state it had.
PLOT_TEST(range_setters_refuse_non_finite_bounds) {
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
// A user-chosen range first, so the auto-scale state the refusal has to preserve
// is the "cleared by a gesture" one, which is the state a real caller is in.
p.set_x_range(ax, {10.0, 20.0});
PLOT_CHECK(!p.x_axis(ax).auto_scale);
p.set_x_range(ax, {std::nan(""), 30.0});
PLOT_CHECK_NEAR(p.x_range(ax).min, 10.0, 1e-9);
PLOT_CHECK_NEAR(p.x_range(ax).max, 20.0, 1e-9);
PLOT_CHECK(!p.x_axis(ax).auto_scale); // kept as it was, neither lost nor re-armed
p.set_x_range(ax, {5.0, std::numeric_limits<f64>::infinity()});
PLOT_CHECK_NEAR(p.x_range(ax).min, 10.0, 1e-9);
PLOT_CHECK_NEAR(p.x_range(ax).max, 20.0, 1e-9);
// The y setter's own refusal, with the same preservation both ways.
p.set_y_auto_scale(ay, true);
p.set_y_range(ay, {std::nan(""), 0.5});
PLOT_CHECK_NEAR(p.y_range(ay).min, -1.0, 1e-9);
PLOT_CHECK_NEAR(p.y_range(ay).max, 1.0, 1e-9);
PLOT_CHECK(p.y_axis(ay).auto_scale); // still armed: the fit is not lost
p.set_y_range(ay, {-1.0, std::numeric_limits<f64>::infinity()});
PLOT_CHECK_NEAR(p.y_range(ay).max, 1.0, 1e-9);
PLOT_CHECK(p.y_axis(ay).auto_scale);
// A finite range still works, and still disarms.
p.set_y_range(ay, {-2.0, 2.0});
PLOT_CHECK_NEAR(p.y_range(ay).min, -2.0, 1e-9);
PLOT_CHECK(!p.y_axis(ay).auto_scale);
}
/// B4, on a log axis: the live window's span and offset are *decades*, so new
/// samples slide the window without rescaling it -- the visible decades stay
/// exactly what the user zoomed to, whatever the live edge does. (A linear span on
/// a log axis is the defect: ten units of span at 1e0 is ten decades, at 1e9 it is
/// nothing.)
PLOT_TEST(live_log_axis_holds_decades_constant) {
LogLiveFixture f(5u); // newest = 41
const f64 decades = f.decades();
PLOT_CHECK(decades > 0.5);
PLOT_CHECK_NEAR(f.graph.x_range(f.ax).max, f.newest(), 1e-9);
for (int i = 0; i < 12; ++i) {
f.feed(2u); // newest += 20 per round
f.graph.layout();
PLOT_CHECK_NEAR(f.decades(), decades, 1e-9); // the zoom is preserved
PLOT_CHECK_NEAR(f.graph.x_range(f.ax).max, f.newest(), 1e-8); // the edge is too
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
}
}
/// B4, scrolled back: a window parked behind the live edge keeps its span *in
/// decades* as the stream advances, and the distance behind the edge -- the offset,
/// also in decades -- is what survives, not an absolute position.
PLOT_TEST(scrolled_back_live_window_keeps_its_decades) {
LogLiveFixture f(5u); // newest = 41
f.graph.set_x_range(f.ax, {1.0, 10.0}); // one decade wide, behind the edge
const f64 decades = f.decades();
PLOT_CHECK_NEAR(decades, 1.0, 1e-9);
const f64 offset = f.graph.x_offset(f.ax);
PLOT_CHECK(offset > 0.5); // one decade behind the 41 edge, in log10
PLOT_CHECK_NEAR(f.graph.x_range(f.ax).max, 10.0, 1e-9);
f.feed(6u); // newest = 101: the edge has moved a whole decade
f.graph.layout();
PLOT_CHECK_NEAR(f.decades(), 1.0, 1e-9); // the span the user chose
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), offset, 1e-12); // the scroll the user chose
const plot::Range r = f.graph.x_range(f.ax);
PLOT_CHECK_NEAR(std::log10(r.max), std::log10(f.newest()) - offset, 1e-9);
PLOT_CHECK(r.max < f.newest()); // genuinely parked behind the edge
}
/// The XY routing, crosshair half: the span brackets the samples the *array*
/// names, and the interpolation fraction is measured on the samples' own positions
/// -- (5-1)/(10-1) over the wide gap, where a uniform-grid guess would have read
/// samples that do not exist at the positions it assumed.
PLOT_TEST(xy_series_crosshair_brackets_its_own_samples) {
XyScene scene;
const u32 trace = scene.plot.add_trace(
{&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay});
const plot::CrosshairSpan span = plot::crosshair_span(scene.series, 5.0);
PLOT_CHECK(span.valid);
PLOT_CHECK(span.begin == 1u);
PLOT_CHECK(span.end == 2u);
PLOT_CHECK_NEAR(span.t, 4.0 / 9.0, 1e-12);
f64 y = 0.0;
PLOT_CHECK(scene.plot.crosshair_value(trace, 5.0, y));
PLOT_CHECK_NEAR(y, 10.0 + 10.0 * (4.0 / 9.0), 1e-9);
// On a sample, at both ends of the array's window, and outside it: the same
// closed-window rule a uniform series follows, on the array's own coordinates.
const plot::CrosshairSpan on = plot::crosshair_span(scene.series, 1.0);
PLOT_CHECK(on.valid);
PLOT_CHECK(on.begin == 1u);
PLOT_CHECK(on.end == 1u);
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.0, y));
PLOT_CHECK_NEAR(y, 10.0, 1e-9);
PLOT_CHECK(scene.plot.crosshair_value(trace, 10.0, y));
PLOT_CHECK_NEAR(y, 20.0, 1e-9);
PLOT_CHECK(!plot::crosshair_span(scene.series, -1e-9).valid);
PLOT_CHECK(!plot::crosshair_span(scene.series, 10.0 + 1e-9).valid);
PLOT_CHECK(scene.plot.crosshair_value(trace, 5.5, y));
PLOT_CHECK(!scene.plot.crosshair_value(trace, -1.0, y));
}
/// The XY routing, measurement half: a cursor snaps to the sample that is really
/// nearest -- 5.0 is nearer sample 1 (distance 4) than sample 2 (distance 5), where
/// the old uniform-grid index arithmetic named sample 2 -- and the measurement
/// covers exactly the samples the snapped pair brackets.
PLOT_TEST(xy_series_measurement_uses_the_true_nearest_samples) {
XyScene scene;
scene.plot.add_trace({&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay});
// Free cursors first: a cursor's stored x is verbatim under the default snap,
// and the *measurement* is what maps a cursor x onto samples -- through the
// series' own `snap_index()`, so 5.0 measures from the sample that is really
// nearest (x = 1, distance 4) and not from where a uniform `dx()` grid guessed.
const u32 ca = scene.plot.add_cursor(5.0, plot::Color::hex(0xFFFFFF));
const u32 cb = scene.plot.add_cursor(9.0, plot::Color::hex(0xFFFFFF));
PLOT_CHECK_NEAR(scene.plot.cursor(ca).x, 5.0, 1e-9); // Free: stored verbatim
const u32 measure = scene.plot.add_measure(ca, cb);
const plot::Measurement& m = scene.plot.measurement(measure);
PLOT_CHECK(m.valid);
PLOT_CHECK_NEAR(m.ymin, 10.0, 1e-9);
PLOT_CHECK_NEAR(m.ymax, 20.0, 1e-9);
PLOT_CHECK_NEAR(m.mean, 15.0, 1e-9);
// The snap, armed explicitly, lands on the same true nearest sample: 5.0 pulls
// onto x = 1 (distance 4 beats 5), 9.0 onto x = 10 (distance 1 beats 9).
scene.plot.set_cursor_snap(ca, plot::SnapMode::NearestSample);
PLOT_CHECK_NEAR(scene.plot.cursor(ca).x, 1.0, 1e-9);
scene.plot.set_cursor_snap(cb, plot::SnapMode::NearestSample);
PLOT_CHECK_NEAR(scene.plot.cursor(cb).x, 10.0, 1e-9);
// Region coverage is the same routing: [0.5, 5.0] covers exactly the sample at
// x = 1 -- the sample at x = 10 is not inside the interval, whatever a `dx()`
// grid would have guessed. The region is `Free`: a snapping region would land on
// a sample and change the interval under test.
const u32 region = scene.plot.add_region({0.5, 5.0, plot::RegionSnap::Free});
const plot::RegionStats& stats = scene.plot.region_stats(region, 0u);
PLOT_CHECK(stats.valid);
PLOT_CHECK(stats.count == 1u);
PLOT_CHECK_NEAR(stats.mean, 10.0, 1e-9);
PLOT_CHECK_NEAR(stats.min, 10.0, 1e-9);
PLOT_CHECK_NEAR(stats.max, 10.0, 1e-9);
}
+41 -14
View File
@@ -536,21 +536,21 @@ PLOT_TEST(text_batch_stages_one_quad_per_glyph) {
b.begin(); b.begin();
const plot::Vec2 end = b.add_text("12.5 ms", {0.0f, 0.0f}, 1.0f, f); 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.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.x, f.measure("12.5 ms"), 1e-6);
PLOT_CHECK_NEAR(end.y, 0.0, 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()[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()[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 // 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 // basis of an unrotated run is the screen's +x. '.' is the string's third
// glyph, so its instance is the third: floats 12..17. // glyph, so its instance is the third: floats 20..29.
const plot::render::Glyph* dot = f.glyph(static_cast<plot::u32>('.')); const plot::render::Glyph* dot = f.glyph(static_cast<plot::u32>('.'));
PLOT_CHECK(dot != nullptr); PLOT_CHECK(dot != nullptr);
PLOT_CHECK_NEAR(b.data()[14], dot->u0, 1e-6); PLOT_CHECK_NEAR(b.data()[22], dot->u0, 1e-6);
PLOT_CHECK_NEAR(b.data()[15], dot->v0, 1e-6); PLOT_CHECK_NEAR(b.data()[23], dot->v0, 1e-6);
PLOT_CHECK_NEAR(b.data()[16], 1.0, 1e-6); // ax PLOT_CHECK_NEAR(b.data()[24], 1.0, 1e-6); // ax
PLOT_CHECK_NEAR(b.data()[17], 0.0, 1e-6); // ay 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 // Every glyph a batch stages is read through one cell, and the batch publishes
// it for the draw (`u_glyph_uv`). // it for the draw (`u_glyph_uv`).
PLOT_CHECK_NEAR(b.glyph_cell_uv().x, dot->u1 - dot->u0, 1e-6); 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(b.glyph_count() == 2);
PLOT_CHECK_NEAR(scaled.x, 10.0f + 2.0f * f.measure("AW"), 1e-6); 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(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 // 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.x, pen.x, 1e-6);
PLOT_CHECK_NEAR(up.y, pen.y - width, 1e-4); PLOT_CHECK_NEAR(up.y, pen.y - width, 1e-4);
for (plot::u32 i = 0; i < b.glyph_count(); ++i) { 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()[10u * 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 + 5u], -1.0, 1e-6);
} }
// The second cell is one advance further up than the pen, which is the same rule // The second cell is one advance further up than the pen, which is the same rule
// the unrotated case follows in x. // the unrotated case follows in x.
PLOT_CHECK_NEAR(b.data()[6], pen.x, 1e-6); PLOT_CHECK_NEAR(b.data()[10], pen.x, 1e-6);
PLOT_CHECK_NEAR(b.data()[7], pen.y - f.glyph('1')->advance, 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 // A diagonal direction is normalised, so a caller can hand over any direction
// and the basis the shader receives is still unit. // 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); 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 /// 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 /// 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 /// 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. // 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()[0], pen.x, 1e-6);
PLOT_CHECK_NEAR(batch.data()[2], a->u0, 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()[10], pen.x + a->advance, 1e-6);
PLOT_CHECK_NEAR(batch.data()[8], b->u0, 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 // A run of nothing but undrawable codepoints stages nothing at all and leaves
// the pen where it was, rather than drawing at the pen. // the pen where it was, rather than drawing at the pen.
+90
View File
@@ -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.x, 0.0, 1e-9);
PLOT_CHECK_NEAR(delta.y, 0.0, 1e-9); PLOT_CHECK_NEAR(delta.y, 0.0, 1e-9);
} }
// --- the pinch --------------------------------------------------------------
// Qt reports a pinch as cumulative numbers -- `totalScaleFactor()` since the
// gesture began, `centerPoint()` where the fingers are now -- while the
// controller's `GestureEvent` wants the increments: this frame's zoom factor
// (1.0 = no change) and the focal point's movement since the previous event.
// `PinchTracker` is the fold, and the ratios it produces are what a pinch feels
// like through: a cumulative 1.0 -> 1.25 -> 1.5 spread is a 1.25 frame followed
// by a 1.2 frame, never a 1.25 then a 1.5 (which would zoom twice as far as the
// fingers did).
PLOT_TEST(pinch_tracker_folds_cumulative_scale_into_incremental_zoom) {
plot::qt::PinchTracker pinch;
// The begin anchors the fold and zooms nothing: a pinch that just started has
// moved no scale yet, so the first event the controller could see is 1.0.
pinch.begin(1.0f, {200.0f, 150.0f});
PLOT_CHECK(pinch.active);
const plot::input::GestureEvent started = pinch.update(1.0f, {200.0f, 150.0f}, 1.0);
PLOT_CHECK_NEAR(started.scale, 1.0, 1e-6);
PLOT_CHECK_NEAR(started.dx, 0.0, 1e-6);
PLOT_CHECK_NEAR(started.dy, 0.0, 1e-6);
// Fingers spread to 1.25 cumulative, focal steady: one 1.25 zoom about the
// focal point, and no pan.
const plot::input::GestureEvent spread = pinch.update(1.25f, {200.0f, 150.0f}, 1.0);
PLOT_CHECK_NEAR(spread.scale, 1.25, 1e-6);
PLOT_CHECK_NEAR(spread.dx, 0.0, 1e-6);
PLOT_CHECK_NEAR(spread.dy, 0.0, 1e-6);
// Spread on to 1.5: the *increment* is 1.5 / 1.25, not 1.5 again.
const plot::input::GestureEvent more = pinch.update(1.5f, {200.0f, 150.0f}, 1.0);
PLOT_CHECK_NEAR(more.scale, 1.2, 1e-6);
// And a pinch in (0.75 of the cumulative): an incremental shrink.
const plot::input::GestureEvent pinch_in = pinch.update(0.75f, {200.0f, 150.0f}, 1.0);
PLOT_CHECK_NEAR(pinch_in.scale, 0.5, 1e-6);
}
// The focal point's movement is the two-finger pan, and it is mapped through the
// ratio in force *now* from an anchor kept in **logical** pixels -- the same
// convention as PointerAnchor above, because the same defect applies: a pinch
// that crosses onto a display with another scale factor must not read the ratio
// change as a focal jump (which the controller would apply as a pan).
PLOT_TEST(pinch_tracker_maps_focal_movement_through_the_current_dpr) {
plot::qt::PinchTracker pinch;
pinch.begin(1.0f, {200.0f, 150.0f});
// A plain pan at 1.0: ten logical pixels right, no zoom.
const plot::input::GestureEvent moved = pinch.update(1.0f, {210.0f, 150.0f}, 1.0);
PLOT_CHECK_NEAR(moved.scale, 1.0, 1e-6);
PLOT_CHECK_NEAR(moved.dx, 10.0, 1e-6);
PLOT_CHECK_NEAR(moved.dy, 0.0, 1e-6);
PLOT_CHECK_NEAR(moved.x, 210.0, 1e-6); // the focal point the zoom anchors on
// Mid-pinch the window lands on the 1.25 display. The fingers move 4 more
// logical pixels: the movement is 4 * 1.25 = 5 device pixels, and the anchor
// the next frame is measured from is the new logical point -- not the old one
// rescaled, which would read the ratio change itself as a jump.
const plot::input::GestureEvent rescaled = pinch.update(1.0f, {214.0f, 150.0f}, 1.25);
PLOT_CHECK_NEAR(rescaled.scale, 1.0, 1e-6);
PLOT_CHECK_NEAR(rescaled.dx, 5.0, 1e-6);
PLOT_CHECK_NEAR(rescaled.dy, 0.0, 1e-6);
PLOT_CHECK_NEAR(rescaled.x, 214.0 * 1.25, 1e-6);
PLOT_CHECK_NEAR(rescaled.y, 150.0 * 1.25, 1e-6);
// A diagonal movement, both axes mapped.
const plot::input::GestureEvent diagonal = pinch.update(1.0f, {214.0f, 154.0f}, 1.25);
PLOT_CHECK_NEAR(diagonal.dx, 0.0, 1e-6);
PLOT_CHECK_NEAR(diagonal.dy, 5.0, 1e-6);
}
// An update outside a gesture -- before any begin, or after the end/cancel --
// zooms and moves nothing: a host that missed the start (or a straggler event
// Qt delivers out of order) must not zoom from a made-up anchor.
PLOT_TEST(pinch_tracker_ignores_updates_outside_a_gesture) {
plot::qt::PinchTracker pinch;
const plot::input::GestureEvent orphan = pinch.update(2.0f, {10.0f, 10.0f}, 1.0);
PLOT_CHECK_NEAR(orphan.scale, 1.0, 1e-9);
PLOT_CHECK_NEAR(orphan.dx, 0.0, 1e-9);
PLOT_CHECK_NEAR(orphan.dy, 0.0, 1e-9);
pinch.begin(1.0f, {0.0f, 0.0f});
pinch.end();
PLOT_CHECK(!pinch.active);
const plot::input::GestureEvent straggler = pinch.update(2.0f, {10.0f, 10.0f}, 1.0);
PLOT_CHECK_NEAR(straggler.scale, 1.0, 1e-9);
PLOT_CHECK_NEAR(straggler.dx, 0.0, 1e-9);
PLOT_CHECK_NEAR(straggler.dy, 0.0, 1e-9);
}
+447 -2
View File
@@ -18,6 +18,7 @@
#include <plot/render/renderer.hpp> #include <plot/render/renderer.hpp>
#include <plot/input/controller.hpp> #include <plot/input/controller.hpp>
#include <algorithm>
#include <atomic> #include <atomic>
#include <cmath> #include <cmath>
#include <csignal> #include <csignal>
@@ -309,7 +310,6 @@ InkBox draw_text_run(plottest::HeadlessContext& ctx, plot::render::TextAtlas& at
program.set_glyph_uv(cell); program.set_glyph_uv(cell);
program.set_px_range({cell.x * texels.x * px, cell.y * texels.y * px}); program.set_px_range({cell.x * texels.x * px, cell.y * texels.y * px});
program.set_texture_size(texels); program.set_texture_size(texels);
program.set_color(text);
atlas.bind(); atlas.bind();
vao.bind(); vao.bind();
plot::gl::glDrawArraysInstanced(plot::gl::GL_TRIANGLE_STRIP, 0, 4, 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::PlotLayout layout = p.layout();
const plot::LegendLayout legend = layout.legend; const plot::LegendLayout legend = layout.legend;
PLOT_CHECK(legend.rows.size() == 2u); PLOT_CHECK(legend.rows.size() == 2u);
const plot::Legend& cfg = p.legend(); const plot::Legend cfg = p.legend(); // copy: set_legend() below swaps the member
const plot::u32 swatch_area = static_cast<plot::u32>(cfg.swatch_w) * const plot::u32 swatch_area = static_cast<plot::u32>(cfg.swatch_w) *
static_cast<plot::u32>(cfg.swatch_h); static_cast<plot::u32>(cfg.swatch_h);
@@ -5364,3 +5364,448 @@ PLOT_TEST(dash_phase_is_stable_across_frames) {
ctx.destroy(); ctx.destroy();
} }
// ------------------------------------------------- B1: relative-x staging (epoch axes)
/// The B1 defect: gathering and staging an absolute epoch x as f32 quantised it to
/// a grid coarser than a pixel (f32 carries 24 bits -- about 128 s at 1.7e9), so an
/// epoch-window trace collapsed onto a few columns while its zero-origin twin drew
/// correctly. The fix stages `to_space(x_abs) - to_space(origin)` -- O(visible
/// pixels) -- and folds the origin into the offset uniform in f64, so the two
/// windows rasterise to the same geometry.
///
/// Both plots below draw the *same samples* with the same width; the only
/// difference is the series' `x0()` and the axis window's offset. The assertion is
/// the per-column trace-ink pattern: identical column sets, not merely similar
/// counts, which a quantised draw could not satisfy.
namespace {
/// Columns (every x of the axes) that hold at least one trace-coloured pixel.
std::vector<bool> trace_column_mask(plottest::HeadlessContext& ctx, const plot::Rect& a) {
const plot::u32 first_row = static_cast<plot::u32>(a.y);
const plot::u32 rows = static_cast<plot::u32>(a.h);
std::vector<bool> mask(a.w > 0.0f ? static_cast<std::size_t>(a.w) : 1u, false);
for (plot::u32 x = 0; x < static_cast<plot::u32>(a.w); ++x) {
const plot::u32 px_x = static_cast<plot::u32>(a.x) + x;
for (plot::u32 yy = 0; yy < rows; ++yy) {
plot::u8 px[4] = {};
if (ctx.read_pixel(px_x, ctx.height - 1u - (first_row + yy), px) && trace_pixel(px)) {
mask[x] = true;
break;
}
}
}
return mask;
}
} // namespace
PLOT_TEST(epoch_window_and_zero_origin_twin_rasterise_identically) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
// A sparse sine: one raw sample per column, so every stage of the pipeline is
// exercised at pixel resolution -- the f32 defect shows on the x coordinates.
std::vector<plot::f32> ys(640u);
for (plot::u32 i = 0; i < ys.size(); ++i) {
ys[i] = 0.8f * std::sin(0.05f * static_cast<plot::f32>(i));
}
const f64 epoch = 1'700'000'000.0;
plot::data::ArraySeries zero(ys, 0.0, 1.0);
plot::data::ArraySeries shifted(ys, epoch, 1.0);
plot::Plot p0;
p0.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
const plot::u32 ax0 = p0.add_x_axis({"t", {0.0, 639.0}, true, plot::AxisPlace::Bottom, false});
const plot::u32 ay0 = p0.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
p0.add_trace({&zero, plot::Color::hex(0x40C0FF), 1.5f, ax0, ay0});
plot::Plot p1;
p1.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
const plot::u32 ax1 =
p1.add_x_axis({"t", {epoch, epoch + 639.0}, true, plot::AxisPlace::Bottom, false});
const plot::u32 ay1 = p1.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
p1.add_trace({&shifted, plot::Color::hex(0x40C0FF), 1.5f, ax1, ay1});
const plot::Rect vp{0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
r.begin_frame(kW, kH, vp);
r.draw(p0);
r.end_frame();
const std::vector<bool> columns_zero = trace_column_mask(ctx, p0.layout().axes);
r.begin_frame(kW, kH, vp);
r.draw(p1);
r.end_frame();
const std::vector<bool> columns_epoch = trace_column_mask(ctx, p1.layout().axes);
PLOT_CHECK(columns_zero == columns_epoch);
// The premise: the twin really drew a trace across the axes (the comparison is
// not vacuously true on two empty frames).
PLOT_CHECK(std::count(columns_zero.begin(), columns_zero.end(), true) >
static_cast<std::size_t>(p0.layout().axes.w) * 9u / 10u);
PLOT_CHECK(!r.last_frame_had_gl_error());
ctx.destroy();
}
// ------------------------------------------------- B6: two text batches
/// The B6 defect: one text batch, flushed after everything, put the legend's
/// names *over* the crosshair's readout panel -- text above the backplate that
/// exists to occlude it. The under batch (legend names included) is now flushed
/// immediately before the panel, so a legend name the panel covers is occluded.
///
/// The scene: a legend in the bottom-left corner with three wide names, and a
/// crosshair parked just inside that corner, whose readout panel flips up and
/// right -- on top of the legend rows. The assertion reads the legend name boxes
/// *minus* every rect the readout legitimately paints (its own label and value
/// runs, and the crosshair's strokes): what is left of a covered name box must
/// hold no text ink with the readout on, and held ink without it.
namespace {
/// Text ink inside `area`, skipping every pixel inside any of `exclude`.
plot::u32 text_ink_outside(const std::vector<plot::u8>& frame, const plot::Rect& area,
const std::vector<plot::Rect>& exclude) {
plot::u32 hit = 0;
const plot::u32 x0 = static_cast<plot::u32>(std::max(0.0f, area.x));
const plot::u32 y0 = static_cast<plot::u32>(std::max(0.0f, area.y));
const plot::u32 x1 = static_cast<plot::u32>(std::min(area.right(), kFrameRect.right()));
const plot::u32 y1 = static_cast<plot::u32>(std::min(area.bottom(), kFrameRect.bottom()));
for (plot::u32 y = y0; y < y1; ++y) {
for (plot::u32 x = x0; x < x1; ++x) {
bool skipped = false;
for (const plot::Rect& e : exclude) {
if (e.contains(static_cast<plot::f32>(x) + 0.5f, static_cast<plot::f32>(y) + 0.5f)) {
skipped = true;
break;
}
}
if (skipped) continue;
if (text_pixel(&frame[(plot::usize(y) * kW + x) * 4u])) ++hit;
}
}
return hit;
}
} // namespace
PLOT_TEST(readout_panel_occludes_the_legend_names_it_covers) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
plot::Plot p;
const plot::Rect vp{0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
p.set_viewport(vp);
const plot::u32 ax = p.add_x_axis({"t", {0.0, 639.0}, true, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {0.0, 1.0}, true, plot::AxisPlace::Left, false});
plot::Legend legend;
legend.place = plot::LegendPlace::BottomLeft;
p.set_legend(legend);
// Flat traces at distinct heights, wide names: three tall legend rows.
plot::data::ArraySeries a(std::vector<plot::f32>(640u, 0.7f), 0.0, 1.0);
plot::data::ArraySeries b(std::vector<plot::f32>(640u, 0.5f), 0.0, 1.0);
plot::data::ArraySeries c(std::vector<plot::f32>(640u, 0.3f), 0.0, 1.0);
p.add_trace({&a, plot::Color::hex(0x40C0FF), 1.5f, ax, ay, "channel one"});
p.add_trace({&b, plot::Color::hex(0xFF5090), 1.5f, ax, ay, "channel two long name"});
p.add_trace({&c, plot::Color::hex(0x50D050), 1.5f, ax, ay, "channel three"});
// Warm-up frame (installs the text backend), then the legend-only frame.
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
const std::vector<plot::u8> legend_only = read_frame(ctx);
const plot::PlotLayout layout = p.layout();
PLOT_CHECK(layout.legend.rows.size() == 3u);
PLOT_CHECK(layout.legend.panel.w > 0.0f);
// The crosshair just inside the bottom-left corner: its panel flips up and
// right, over the legend rows.
const plot::Transform tx = p.x_transform(ax);
const plot::Transform ty = p.y_transform(ay);
p.set_crosshair(tx.to_data_x(layout.axes.x + 30.0f),
ty.to_data_y(layout.axes.bottom() - 30.0f), true);
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
const std::vector<plot::u8> with_readout = read_frame(ctx);
// Fresh layout: the crosshair's own geometry only exists now that it is placed
// (the copy above was taken before `set_crosshair`).
const plot::CrosshairLayout crosshair = p.layout().crosshair;
PLOT_CHECK(crosshair.active);
PLOT_CHECK(crosshair.panel.w > 0.0f);
// The exclusions: the readout's own runs and the crosshair's strokes.
std::vector<plot::Rect> exclude;
for (const plot::CrosshairRow& row : crosshair.rows) {
if (row.listed) {
exclude.push_back(row.label_box);
exclude.push_back(row.value_box);
exclude.push_back(row.swatch); // the row's own swatch fill
}
}
exclude.push_back(crosshair.vertical);
exclude.push_back(crosshair.horizontal);
// The premise: the panel really does cover at least one legend name box.
bool covers = false;
for (const plot::LegendRow& row : layout.legend.rows) {
if (row.ellipsis) continue;
if (crosshair.panel.x < row.name.right() && row.name.x < crosshair.panel.right() &&
crosshair.panel.y < row.name.bottom() && row.name.y < crosshair.panel.bottom()) {
covers = true;
}
}
PLOT_CHECK(covers);
// Each legend name box: ink without the readout; in the part the panel covers,
// none of it left (outside the readout's own runs) once the panel is over it.
// The uncovered remainder of a partially covered box keeps its ink -- that is
// the panel doing its job and nothing more.
bool checked = false;
for (const plot::LegendRow& row : layout.legend.rows) {
const bool overlaps = crosshair.panel.x < row.name.right() &&
row.name.x < crosshair.panel.right() &&
crosshair.panel.y < row.name.bottom() &&
row.name.y < crosshair.panel.bottom();
if (row.ellipsis || !overlaps) continue;
// Inset past the panel's own border ring: the border is a hairline of the
// bright crosshair colour (see draw_readout_panel), which the ink predicate
// cannot tell from text -- it is the panel's chrome, not legend ink.
const plot::Rect inner{crosshair.panel.x + 2.0f, crosshair.panel.y + 2.0f,
crosshair.panel.w - 4.0f, crosshair.panel.h - 4.0f};
const plot::Rect covered{
std::max(inner.x, row.name.x), std::max(inner.y, row.name.y),
std::min(inner.right(), row.name.right()) - std::max(inner.x, row.name.x),
std::min(inner.bottom(), row.name.bottom()) - std::max(inner.y, row.name.y)};
PLOT_CHECK(covered.w > 0.0f && covered.h > 0.0f);
PLOT_CHECK(text_ink_outside(legend_only, covered, {}) > 0u);
PLOT_CHECK(text_ink_outside(with_readout, covered, exclude) == 0u);
checked = true;
}
PLOT_CHECK(checked);
PLOT_CHECK(!r.last_frame_had_gl_error());
ctx.destroy();
}
// ------------------------------------------------- markers
/// Markers are staged as line segments through the trace's own batch: a
/// marker'd trace lights pixels a plain line does not (the shapes extend past
/// the stroke), and the stride is literal -- `marker_every = 3` stages exactly a
/// third of the markers of `marker_every = 1` over the same window.
PLOT_TEST(markers_light_more_than_the_line_and_stride_is_literal) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
// 32 sparse points: the auto stride would be ~1 marker per point anyway, so
// `marker_every = 1` vs `3` differ by construction.
std::vector<plot::f32> ys(32u);
for (plot::u32 i = 0; i < ys.size(); ++i) {
ys[i] = 0.5f + 0.4f * std::sin(0.9f * static_cast<plot::f32>(i));
}
plot::data::ArraySeries s(ys, 0.0, 20.0);
plot::Plot plain_p;
plain_p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
const plot::u32 ax = plain_p.add_x_axis({"t", {0.0, 620.0}, true, plot::AxisPlace::Bottom, false});
const plot::u32 ay = plain_p.add_y_axis({"v", {0.0, 1.0}, true, plot::AxisPlace::Left, false});
plot::Plot marked_p = plain_p;
plot::Plot every3_p = plain_p;
marked_p.add_trace({&s, plot::Color::hex(0xFF5090), 1.5f, ax, ay, "", plot::TraceMode::Envelope,
0.0f, 0.0f, plot::Marker::Circle, 4.0f, 1u});
every3_p.add_trace({&s, plot::Color::hex(0xFF5090), 1.5f, ax, ay, "", plot::TraceMode::Envelope,
0.0f, 0.0f, plot::Marker::Circle, 4.0f, 3u});
plain_p.add_trace({&s, plot::Color::hex(0xFF5090), 1.5f, ax, ay});
const plot::Rect vp{0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
r.begin_frame(kW, kH, vp);
r.draw(plain_p);
r.end_frame();
const plot::u32 plain_ink = predicate_pixels(read_frame(ctx), kW, plain_p.layout().axes,
magenta_pixel);
r.begin_frame(kW, kH, vp);
r.draw(marked_p);
r.end_frame();
const plot::u32 marked_ink = predicate_pixels(read_frame(ctx), kW, marked_p.layout().axes,
magenta_pixel);
const plot::u32 marked_count = r.stats().staged_markers;
r.begin_frame(kW, kH, vp);
r.draw(every3_p);
r.end_frame();
const plot::u32 every3_count = r.stats().staged_markers;
// The premise: the line drew something; the markers drew strictly more.
PLOT_CHECK(plain_ink > 0u);
PLOT_CHECK(marked_ink > plain_ink);
// The stride is literal: a third of the markers, ±1 for the window's edge
// sample count.
PLOT_CHECK(marked_count >= 30u);
PLOT_CHECK(every3_count * 3u + 2u >= marked_count);
PLOT_CHECK(every3_count * 3u <= marked_count + 2u);
PLOT_CHECK(!r.last_frame_had_gl_error());
ctx.destroy();
}
// ------------------------------------------------- text colour
/// The readout's VALUE column draws in the row trace's own colour (resolved from
/// the plot at draw time), the labels in the axis text colour: per-instance
/// `a_colour` instead of one text tint per draw.
PLOT_TEST(readout_value_draws_in_the_row_trace_colour) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
plot::Plot p;
const plot::Rect vp{0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
p.set_viewport(vp);
const plot::u32 ax = p.add_x_axis({"t", {0.0, 639.0}, true, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {0.0, 1.0}, true, plot::AxisPlace::Left, false});
plot::data::ArraySeries s(std::vector<plot::f32>(640u, 0.5f), 0.0, 1.0);
const auto magenta = plot::Color::hex(0xFF5090);
p.add_trace({&s, magenta, 1.5f, ax, ay, "row"});
p.set_crosshair(300.0, 0.5, true);
p.set_crosshair_readout(true);
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
const std::vector<plot::u8> frame = read_frame(ctx);
const plot::CrosshairLayout& crosshair = p.layout().crosshair;
PLOT_CHECK(crosshair.rows.size() == 1u);
PLOT_CHECK(crosshair.rows[0].listed);
PLOT_CHECK(crosshair.rows[0].value_box.w > 0.0f);
// The value box holds pixels the row trace's colour explains (magenta
// dominance at any blend over the dark panel) -- the old single-tint pipeline
// drew every glyph in the axis text grey, so this is the defect's own pixel.
plot::u32 coloured = 0;
const plot::Rect box = crosshair.rows[0].value_box;
for (plot::u32 y = static_cast<plot::u32>(box.y); y < static_cast<plot::u32>(box.bottom()); ++y) {
for (plot::u32 x = static_cast<plot::u32>(box.x); x < static_cast<plot::u32>(box.right());
++x) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, px));
if (magenta_pixel(px)) ++coloured;
}
}
PLOT_CHECK(coloured > 0u);
// The label box, same frame, is the axis text colour, not the trace's.
plot::u32 label_grey = 0;
const plot::Rect labels = crosshair.rows[0].label_box;
for (plot::u32 y = static_cast<plot::u32>(labels.y); y < static_cast<plot::u32>(labels.bottom());
++y) {
for (plot::u32 x = static_cast<plot::u32>(labels.x); x < static_cast<plot::u32>(labels.right());
++x) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, px));
if (text_pixel(px)) ++label_grey;
}
}
PLOT_CHECK(label_grey > 0u);
PLOT_CHECK(!r.last_frame_had_gl_error());
ctx.destroy();
}
// ------------------------------------------------- R6: GL state residue and shutdown
/// `begin_frame()` saves the host's blend function and enable bit; `end_frame()`
/// hands both back -- a host that blends its own way before the frame must not
/// inherit the frame's `SRC_ALPHA / ONE_MINUS_SRC_ALPHA`.
PLOT_TEST(a_frame_hands_the_blend_state_back) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
plot::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
const plot::u32 ax = p.add_x_axis({"t", {0.0, 1.0}, true, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {0.0, 1.0}, true, plot::AxisPlace::Left, false});
plot::data::ArraySeries s(std::vector<plot::f32>(16u, 0.5f), 0.0, 1.0);
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
// The host's own blend: function ONE/ZERO, enabled.
plot::gl::glEnable(plot::gl::GL_BLEND);
plot::gl::glBlendFunc(plot::gl::GL_ONE, plot::gl::GL_ZERO);
const plot::Rect vp{0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
plot::gl::GLint src = -1;
plot::gl::GLint dst = -1;
plot::gl::glGetIntegerv(plot::gl::GL_BLEND_SRC_RGB, &src);
plot::gl::glGetIntegerv(plot::gl::GL_BLEND_DST_RGB, &dst);
PLOT_CHECK(src == static_cast<plot::gl::GLint>(plot::gl::GL_ONE));
PLOT_CHECK(dst == static_cast<plot::gl::GLint>(plot::gl::GL_ZERO));
PLOT_CHECK(plot::gl::glIsEnabled(plot::gl::GL_BLEND) != plot::gl::GL_FALSE);
// And with blending off, it comes back off.
plot::gl::glDisable(plot::gl::GL_BLEND);
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
PLOT_CHECK(plot::gl::glIsEnabled(plot::gl::GL_BLEND) == plot::gl::GL_FALSE);
PLOT_CHECK(!r.last_frame_had_gl_error());
ctx.destroy();
}
/// `release_gl()` destroys every GL handle while the context is current: after
/// it the renderer is uninitialised, the driver holds no plot objects (a
/// re-init and frame still draw, which is the observable half), and the same
/// renderer object can be brought back.
PLOT_TEST(release_gl_shuts_down_and_the_renderer_can_reinit) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
plot::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
const plot::u32 ax = p.add_x_axis({"t", {0.0, 1.0}, true, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {0.0, 1.0}, true, plot::AxisPlace::Left, false});
plot::data::ArraySeries s(std::vector<plot::f32>(16u, 0.5f), 0.0, 1.0);
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
const plot::Rect vp{0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
r.release_gl();
PLOT_CHECK(!r.ready());
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
// Re-init on the same object draws again -- the handles were released, not
// half-destroyed.
PLOT_CHECK(r.init(&st));
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
ctx.destroy();
}
+15
View File
@@ -20,3 +20,18 @@ PLOT_TEST(ring_clear_resets) {
PLOT_CHECK(r.size() == 1); PLOT_CHECK(r.size() == 1);
PLOT_CHECK_NEAR(r[0], 42.0); PLOT_CHECK_NEAR(r[0], 42.0);
} }
PLOT_TEST(ring_capacity_rounds_up_to_a_power_of_two) {
// The capacity is rounded up so slot addressing stays a mask, and capacity()
// reports the rounded value -- the one callers must size for (R4).
PLOT_CHECK(plot::data::RingBuffer<f32>(100).capacity() == 128);
PLOT_CHECK(plot::data::RingBuffer<f32>(1).capacity() == 1);
PLOT_CHECK(plot::data::RingBuffer<f32>(0).capacity() == 1);
PLOT_CHECK(plot::data::RingBuffer<f32>(64).capacity() == 64); // already a power
plot::data::RingBuffer<f32> r(100);
for (int i = 0; i < 1000; ++i) r.push(f32(i));
PLOT_CHECK(r.size() == 128); // size() saturates at the rounded capacity
PLOT_CHECK_NEAR(r[0], 872.0); // oldest retained
PLOT_CHECK_NEAR(r[127], 999.0); // newest
}
+429 -39
View File
@@ -26,9 +26,10 @@ PLOT_TEST(array_series_bounds_and_gather) {
std::vector<f32> out; std::vector<f32> out;
s.gather(0, 0, 4, out); s.gather(0, 0, 4, out);
PLOT_CHECK(out.size() == 8); // interleaved x,y 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[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) { PLOT_TEST(stream_series_acquire_publishes_snapshot) {
@@ -105,9 +106,11 @@ PLOT_TEST(stream_series_exact_queries_survive_ring_wrap) {
std::vector<f32> out; std::vector<f32> out;
s.gather(0, 0, n, out); // level 0: raw samples s.gather(0, 0, n, out); // level 0: raw samples
PLOT_CHECK(out.size() == 2 * n); 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[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) { 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 // 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 // two partial edge buckets as raw samples, so the geometry spans the range
// exactly. This is the mapping the renderer draws from. // exactly. This is the mapping the renderer draws from. Bucket selection is
// ceil/floor on the range edges (B3): `first` is the first level-1 bucket
// wholly at or after window index 0, `last` one past the last wholly inside
// the window -- so every emitted bucket's samples lie inside the window.
std::vector<f32> out; std::vector<f32> out;
s.gather(1, 0, n, out); s.gather(1, 0, n, out);
const u64 first = std::max(base / span, lbase); const u64 first =
const u64 last = std::min((base + n - 1) / span + 1, lbase + lsize); 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 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 trail = last * span - base; // first window index after the last one
const u64 tail = trail < n ? n - trail : 0; const u64 tail = trail < n ? n - trail : 0;
const u64 env = last - first; const u64 env = last - first;
PLOT_CHECK(out.size() == 2 * (lead + 2 * env + tail)); // each bucket is two pairs 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 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); PLOT_CHECK_NEAR(out[2 * i + 1], value(base + i), 1e-6);
} }
for (u64 j = 0; j < env; ++j) { for (u64 j = 0; j < env; ++j) {
// Each whole bucket contributes two pairs: (first sample, min) and (last // Each whole bucket contributes two pairs: (first sample, min) and (last
// sample, max). x is in data coordinates -- x0() is the retained window's // sample, max). x is relative to x0(): window index i plots at i * dx.
// first sample -- so window index i plots at (base + i) * dx.
const u64 bi = first + j; const u64 bi = first + j;
const std::array<f64, 4> want = brute(bi * span - base, (bi + 1) * span - base); const std::array<f64, 4> want = brute(bi * span - base, (bi + 1) * span - base);
const u64 first_sample = bi * span - base; const u64 first_sample = bi * span - base;
const u64 last_sample = std::min((bi + 1) * span, base + n) - 1 - base; const u64 last_sample = std::min((bi + 1) * span, base + n) - 1 - base;
const plot::usize lo = static_cast<plot::usize>(2 * (lead + 2 * j)); const plot::usize lo = static_cast<plot::usize>(2 * (lead + 2 * j));
const plot::usize hi = lo + 2; 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[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); PLOT_CHECK_NEAR(out[hi + 1], want[1], 1e-6);
} }
for (u64 i = 0; i < tail; ++i) { // the partial trailing bucket: the newest samples for (u64 i = 0; i < tail; ++i) { // the partial trailing bucket: the newest samples
const plot::usize k = static_cast<plot::usize>(2 * (lead + 2 * env + i)); const plot::usize k = static_cast<plot::usize>(2 * (lead + 2 * env + i));
PLOT_CHECK_NEAR(out[k], f32(f64(base + trail + i) * 1e-6), 1e-9); PLOT_CHECK_NEAR(out[k], f32(f64(trail + i) * 1e-6), 1e-9);
PLOT_CHECK_NEAR(out[k + 1], value(base + trail + i), 1e-6); 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_CHECK(big <= 2 * small + kBucket);
} }
PLOT_TEST(stream_series_steady_state_acquire_is_fast) {
// Wall-clock backstop for the merge count above: rebuilding the 1M-sample
// window measured ~44 ms, so 128 such frames would need ~5.6 s. The sliding
// path does the same work in a few milliseconds; the bound is two orders of
// magnitude clear of both, so it is not a thin timing margin.
plot::data::StreamSeries s(1u << 20, 0.0, 1.0);
constexpr u64 kSaturate = 1u << 20;
for (u64 i = 0; i < kSaturate; ++i) s.push(f32(i % 977));
s.acquire();
constexpr u64 kFrames = 128;
constexpr u64 kFrame = 1u << 13;
const auto t0 = std::chrono::steady_clock::now();
for (u64 f = 0; f < kFrames; ++f) {
for (u64 k = 0; k < kFrame; ++k) s.push(f32((kSaturate + f * kFrame + k) % 977));
s.acquire();
}
const f64 ms =
std::chrono::duration<f64>(std::chrono::steady_clock::now() - t0).count() * 1.0e3;
PLOT_CHECK(s.size() == 1u << 20);
PLOT_CHECK(ms < 250.0);
}
PLOT_TEST(stream_series_pyramid_memory_is_bounded_by_the_window) { 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 // 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 // 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); s.gather(level, 0, n, out);
PLOT_CHECK(out.size() >= 8u); PLOT_CHECK(out.size() >= 8u);
// Oldest: the first emitted pair is the retained sample at index 0, not the // Oldest: the first emitted pair is the retained sample at index 0, not the
// first whole bucket (which starts `lead` samples later). // first whole bucket (which starts `lead` samples later). x is relative to
PLOT_CHECK_NEAR(out[0], f32(s.x0()), 1e-9); // 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); PLOT_CHECK_NEAR(out[1], 25.0, 1e-6);
// Newest: the last emitted pair is the sample at index n-1, not the last // Newest: the last emitted pair is the sample at index n-1, not the last
// complete bucket. // 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_NEAR(out[out.size() - 1], -25.0, 1e-6);
}; };
PLOT_CHECK(s.pyramid().level_size(1) >= 2); 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() - 2], f32(f64(ny - 1) * 1e-6), 1e-9);
PLOT_CHECK_NEAR(out[out.size() - 1], -25.0, 1e-6); PLOT_CHECK_NEAR(out[out.size() - 1], -25.0, 1e-6);
} }
// --- B3: gather never emits geometry from outside [begin, end) ---------------
PLOT_TEST(neighbours_exact_hits_collapse_to_one_index) {
// The contract says the bracket degenerates to (k, k) when x lands exactly on
// a sample -- at the first sample too, where the old floor-only path answered
// (0, 1) -- and stays (k, k+1) strictly between two samples.
plot::data::ArraySeries s(std::vector<f32>{1, 2, 3, 4, 5}, 10.0, 0.25);
u64 i0 = 9, i1 = 9;
PLOT_CHECK(s.neighbours(s.x_at(0), i0, i1)); // first: was (0, 1)
PLOT_CHECK(i0 == 0 && i1 == 0);
PLOT_CHECK(s.neighbours(s.x_at(2), i0, i1)); // middle
PLOT_CHECK(i0 == 2 && i1 == 2);
PLOT_CHECK(s.neighbours(s.x_at(4), i0, i1)); // last
PLOT_CHECK(i0 == 4 && i1 == 4);
PLOT_CHECK(s.neighbours(s.x_at(1) + 0.1, i0, i1)); // strictly between
PLOT_CHECK(i0 == 1 && i1 == 2);
// A step whose k*dx does not round-trip through the division: the exact-hit
// test must compare samples, not trust t's fraction being an integer.
plot::data::ArraySeries t(std::vector<f32>{0, 1, 2, 3, 4, 5, 6, 7}, -1.0, 0.1);
for (u64 i = 0; i < 8; ++i) {
PLOT_CHECK(t.neighbours(t.x_at(i), i0, i1));
PLOT_CHECK(i0 == i && i1 == i);
}
PLOT_CHECK(t.neighbours((t.x_at(3) + t.x_at(4)) / 2.0, i0, i1));
PLOT_CHECK(i0 == 3 && i1 == 4);
}
PLOT_TEST(neighbours_outside_and_empty_are_false) {
plot::data::ArraySeries s(std::vector<f32>{1, 2, 3}, 0.0, 1.0);
u64 i0 = 7, i1 = 7;
PLOT_CHECK(!s.neighbours(-0.5, i0, i1)); // left of the window
PLOT_CHECK(!s.neighbours(3.5, i0, i1)); // right of the window
PLOT_CHECK(!s.neighbours(std::nan(""), i0, i1));
PLOT_CHECK(i0 == 7 && i1 == 7); // untouched on false
plot::data::ArraySeries e({}, 0.0, 1.0);
PLOT_CHECK(!e.neighbours(1.0, i0, i1));
PLOT_CHECK(e.snap_index(1.0) == 0); // empty series snaps to 0
}
PLOT_TEST(gather_envelope_stays_inside_an_unaligned_range) {
// The defect this pins: the first bucket was floor-selected and the bucket
// containing `end - 1` was emitted whole, so an unaligned range drew
// aggregates covering samples outside it. The samples outside [begin, end)
// here are extreme on purpose: any envelope value beyond the exact reduce is
// geometry that leaked in from outside the range.
constexpr u64 kN = 4096;
std::vector<f32> y(kN);
for (u64 i = 0; i < kN; ++i) {
y[i] = i < 1000 || i >= 3000 ? (i % 2 == 0 ? 1000.0f : -1000.0f)
: 0.25f * f32(i % 4) - 0.375f;
}
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
const u64 b = 1000 + 37, e = 3000 - 61; // unaligned on every level's grid
const plot::Range exact = s.reduce(b, e); // |values| <= ~0.375 inside the range
PLOT_CHECK(exact.max < 1.0 && exact.min > -1.0);
const auto check_range = [&](u32 level) {
std::vector<f32> out;
s.gather(level, b, e, out);
PLOT_CHECK(out.size() >= 4u);
for (plot::usize i = 0; i + 1 < out.size(); i += 2) {
PLOT_CHECK(f64(out[i]) >= f64(b)); // emitted x inside [begin, end)
PLOT_CHECK(f64(out[i]) < f64(e));
}
for (plot::usize i = 1; i < out.size(); i += 2) {
PLOT_CHECK(f64(out[i]) >= exact.min - 1e-6);
PLOT_CHECK(f64(out[i]) <= exact.max + 1e-6);
}
};
check_range(1);
check_range(2);
check_range(3);
check_range(6);
// And the envelope values are not merely *inside* the range: each emitted
// (min, max) pair equals the exact reduce over the samples that pair's two x
// positions bracket. Walk the layout the contract fixes -- raw lead pairs,
// two pairs per complete bucket, raw trail pairs -- so the raw edge spans
// are not mistaken for envelope pairs.
const u32 level = 2;
const u64 span = u64{kBucket} << level;
const u64 lend = s.pyramid().level_size(level);
const u64 first = std::min(b / span + (b % span == 0 ? u64{0} : u64{1}), lend);
const u64 last = std::min(e / span, lend);
const u64 lead = first * span;
const u64 buckets = last - first;
PLOT_CHECK(lead > b && last * span < e); // both edge spans really are partial
std::vector<f32> out;
s.gather(level, b, e, out);
PLOT_CHECK(out.size() == 2 * ((lead - b) + 2 * buckets + (e - last * span)));
plot::usize o = 0;
for (u64 i = b; i < lead; ++i, o += 2) { // the partial leading bucket, raw
PLOT_CHECK_NEAR(out[o], f32(f64(i)), 1e-6);
PLOT_CHECK(f64(out[o + 1]) >= exact.min - 1e-6 && f64(out[o + 1]) <= exact.max + 1e-6);
}
for (u64 j = 0; j < buckets; ++j) {
const u64 bi = first + j;
const u64 lo = bi * span;
const u64 hi = std::min((bi + 1) * span, e) - 1;
const plot::Range r = s.reduce(lo, hi + 1);
PLOT_CHECK_NEAR(out[o], f32(f64(lo)), 1e-6);
PLOT_CHECK_NEAR(out[o + 1], r.min, 1e-6);
PLOT_CHECK_NEAR(out[o + 2], f32(f64(hi)), 1e-6);
PLOT_CHECK_NEAR(out[o + 3], r.max, 1e-6);
o += 4;
}
for (u64 i = last * span; i < e; ++i, o += 2) { // the partial trailing bucket
PLOT_CHECK_NEAR(out[o], f32(f64(i)), 1e-6);
PLOT_CHECK(f64(out[o + 1]) >= exact.min - 1e-6 && f64(out[o + 1]) <= exact.max + 1e-6);
}
PLOT_CHECK(o == out.size());
}
PLOT_TEST(stream_gather_envelope_stays_inside_an_unaligned_range) {
// Same invariant on a sliding window whose base is off the bucket grid. The
// tame zone covers every sample the queried range reaches -- window [3808,
// 12000) queried at window indices [300, n-77), absolute [4108, 11923) -- and
// the extremes sit strictly before it, from 3808 to 4000.
const auto value = [](u64 i) {
return i < 4000 ? (i % 2 == 0 ? 500.0f : -500.0f)
: 0.125f * f32(i % 5) - 0.25f;
};
constexpr u64 kPushes = 12000;
plot::data::StreamSeries s(1u << 13, 0.0, 1.0); // window of 8192, base 3816
for (u64 i = 0; i < kPushes; ++i) s.push(value(i));
s.acquire();
const u64 n = s.size();
const u64 base = kPushes - n;
PLOT_CHECK((base % kBucket) != 0); // window starts mid-bucket
const u64 b = 300, e = n - 77; // unaligned inside the window
const plot::Range exact = s.reduce(b, e);
PLOT_CHECK(exact.max < 1.0 && exact.min > -1.0);
std::vector<f32> out;
s.gather(1, b, e, out);
PLOT_CHECK(out.size() >= 4u);
for (plot::usize i = 0; i + 1 < out.size(); i += 2) {
PLOT_CHECK(f64(out[i]) >= f64(b)); // relative x inside [begin, end)
PLOT_CHECK(f64(out[i]) < f64(e));
}
for (plot::usize i = 1; i < out.size(); i += 2) {
PLOT_CHECK(f64(out[i]) >= exact.min - 1e-6);
PLOT_CHECK(f64(out[i]) <= exact.max + 1e-6);
}
}
// --- B7: non-finite samples are excluded from every aggregate ----------------
PLOT_TEST(nan_samples_are_excluded_from_bounds_and_sums) {
// NaN at the first, middle and last positions: bounds and reduce stay finite,
// sums skip the NaN samples entirely.
std::vector<f32> y{std::nanf(""), 1.0f, 2.0f, std::nanf(""), 3.0f, 4.0f,
std::nanf("")};
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
PLOT_CHECK_NEAR(s.y_bounds().min, 1.0, 1e-9);
PLOT_CHECK_NEAR(s.y_bounds().max, 4.0, 1e-9);
const plot::Range r = s.reduce(0, 7);
PLOT_CHECK_NEAR(r.min, 1.0, 1e-9);
PLOT_CHECK_NEAR(r.max, 4.0, 1e-9);
PLOT_CHECK_NEAR(s.sum(0, 7), 10.0, 1e-9);
PLOT_CHECK_NEAR(s.sumsq(0, 7), 30.0, 1e-9);
// A range that is *all* NaN: zero-span, zero sums.
PLOT_CHECK(s.reduce(0, 1).span() == 0.0);
PLOT_CHECK(s.sum(0, 1) == 0.0);
PLOT_CHECK(s.sumsq(0, 1) == 0.0);
// And a NaN-only window reports a zero-span Range, not NaN.
PLOT_CHECK(s.reduce(5, 7).span() == 0.0);
}
PLOT_TEST(stream_series_bounds_skip_nan_samples) {
// acquire()'s bounds go through the same exact folds, so a stream with NaN
// gaps publishes finite bounds -- and NaN at the edges of the window too.
plot::data::StreamSeries s(256, 0.0, 1.0);
s.push(std::nanf("")); // oldest retained sample
for (int i = 0; i < 254; ++i) s.push(i % 2 == 0 ? -3.0f : 7.0f);
s.push(std::nanf("")); // newest sample
s.acquire();
PLOT_CHECK(s.size() == 256);
PLOT_CHECK_NEAR(s.y_bounds().min, -3.0, 1e-6);
PLOT_CHECK_NEAR(s.y_bounds().max, 7.0, 1e-6);
PLOT_CHECK_NEAR(s.reduce(0, s.size()).max, 7.0, 1e-6);
PLOT_CHECK_NEAR(s.sum(0, s.size()), 127.0 * 4.0, 1e-3);
}
PLOT_TEST(window_without_finite_samples_aggregates_to_zero) {
plot::data::ArraySeries s(std::vector<f32>(8, std::nanf("")), 0.0, 1.0);
PLOT_CHECK(s.y_bounds().span() == 0.0);
PLOT_CHECK(s.reduce(0, 8).span() == 0.0);
PLOT_CHECK(s.sum(0, 8) == 0.0);
PLOT_CHECK(s.sumsq(0, 8) == 0.0);
// x bounds stay the grid's: the window exists even though nothing is finite.
PLOT_CHECK_NEAR(s.x_bounds().min, 0.0, 1e-9);
PLOT_CHECK_NEAR(s.x_bounds().max, 7.0, 1e-9);
}
// --- R3: any u32 gather level is safe ----------------------------------------
PLOT_TEST(gather_level_beyond_the_pyramid_clamps) {
// R3: `kBucket << level` used to shift by >= 64 for a public level past 57
// (undefined). Now any u32 level is accepted and clamped to the deepest one.
std::vector<f32> y(8192);
for (u64 i = 0; i < y.size(); ++i) y[i] = f32(i % 13) - 6.0f;
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
std::vector<f32> deepest;
s.gather(plot::data::kMaxPyramidLevel, 0, s.size(), deepest);
PLOT_CHECK(!deepest.empty());
for (const u32 level : {u32{58}, u32{200}, u32{1000}, ~u32{0}}) {
std::vector<f32> out;
s.gather(level, 0, s.size(), out);
PLOT_CHECK(out == deepest); // clamped to the same deepest geometry
}
// The cap is where the span shift still fits: level 57 spans exactly 2^63.
PLOT_CHECK(plot::data::kMaxPyramidLevel == 57);
PLOT_CHECK((u64{kBucket} << plot::data::kMaxPyramidLevel) == (u64{1} << 63));
}
// --- R4: the ring's capacity is the rounded one -------------------------------
PLOT_TEST(stream_series_capacity_is_rounded_up_and_pre_sized) {
// StreamSeries(100) retains 128 samples: the ring rounds up, size() saturates
// at the rounded capacity, and the pyramid was reserved for it (a saturated
// stream must not grow the level table mid-acquire).
plot::data::StreamSeries s(100, 0.0, 1.0);
for (int i = 0; i < 1000; ++i) s.push(f32(i));
s.acquire();
PLOT_CHECK(s.size() == 128);
PLOT_CHECK_NEAR(s.y_bounds().max, 999.0, 1e-4);
PLOT_CHECK_NEAR(s.x_bounds().max, s.x0() + 127.0, 1e-9);
const plot::Range r = s.reduce(0, s.size());
PLOT_CHECK_NEAR(r.min, 872.0, 1e-4); // the oldest retained sample
}
// --- GatherMode: Envelope / Nearest / Mean ------------------------------------
PLOT_TEST(gather_modes_envelope_nearest_mean) {
// 4096 samples of a sawtooth, level 2 (256-sample buckets, 16 of them).
constexpr u64 kN = 4096;
const auto value = [](u64 i) { return f32(i % 7) - 3.0f; };
std::vector<f32> y(kN);
for (u64 i = 0; i < kN; ++i) y[i] = value(i);
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
const u32 level = 2;
const u64 span = u64{kBucket} << level;
const u64 buckets = kN / span;
PLOT_CHECK(buckets == 16);
std::vector<f32> env, near, mean;
s.gather(level, 0, kN, env, plot::data::GatherMode::Envelope);
s.gather(level, 0, kN, near, plot::data::GatherMode::Nearest);
s.gather(level, 0, kN, mean, plot::data::GatherMode::Mean);
// Envelope: two points per bucket, min at the bucket's first sample, max at
// its last (this sawtooth's per-bucket min is its first sample's value and
// its max its last sample's value).
PLOT_CHECK(env.size() == 4 * buckets);
// Nearest and Mean: one point per bucket, x at the bucket's centre sample.
PLOT_CHECK(near.size() == 2 * buckets);
PLOT_CHECK(mean.size() == 2 * buckets);
for (u64 j = 0; j < buckets; ++j) {
const u64 lo = j * span;
const u64 centre = lo + span / 2;
const f64 centre_rel = f64(centre); // dx == 1, relative to x0() == 0
// x positions are relative and land where the mode says.
PLOT_CHECK_NEAR(env[4 * j], f64(lo), 1e-6);
PLOT_CHECK_NEAR(env[4 * j + 2], f64(lo + span - 1), 1e-6);
PLOT_CHECK_NEAR(near[2 * j], centre_rel, 1e-6);
PLOT_CHECK_NEAR(mean[2 * j], centre_rel, 1e-6);
// Envelope y is the exact per-bucket min/max.
const plot::Range r = s.reduce(lo, lo + span);
PLOT_CHECK_NEAR(env[4 * j + 1], r.min, 1e-6);
PLOT_CHECK_NEAR(env[4 * j + 3], r.max, 1e-6);
// Nearest y is the raw centre sample.
PLOT_CHECK_NEAR(near[2 * j + 1], value(centre), 1e-6);
// Mean y is the bucket's mean.
const f64 want_mean = s.sum(lo, lo + span) / f64(span);
PLOT_CHECK_NEAR(mean[2 * j + 1], want_mean, 1e-6);
}
// Level 0 and sub-bucket ranges are the raw samples for every mode.
std::vector<f32> raw;
s.gather(0, 5, 300, raw, plot::data::GatherMode::Mean);
PLOT_CHECK(raw.size() == 2 * (300 - 5));
PLOT_CHECK_NEAR(raw[0], 5.0, 1e-9);
}
PLOT_TEST(gather_skips_all_nan_buckets) {
// Level-1 bucket 1 (samples [128, 256)) is entirely NaN: the exact queries
// treat it as absent, the gathered geometry emits nothing for it in Envelope
// and Mean, and Nearest draws its centre sample raw -- NaN, as level 0 would.
std::vector<f32> y(384, 1.0f);
for (u64 i = 128; i < 256; ++i) y[i] = std::nanf("");
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
PLOT_CHECK(s.reduce(128, 256).span() == 0.0);
PLOT_CHECK(s.sum(128, 256) == 0.0);
const plot::Range r = s.reduce(0, 384);
PLOT_CHECK_NEAR(r.min, 1.0, 1e-9);
PLOT_CHECK_NEAR(r.max, 1.0, 1e-9);
// first = 0, last = 256/128 = 2: buckets 0 and 1, no lead/trail raw spans.
std::vector<f32> env, mean, near;
s.gather(1, 0, 256, env, plot::data::GatherMode::Envelope);
PLOT_CHECK(env.size() == 4); // bucket 0's two pairs only
PLOT_CHECK_NEAR(env[0], 0.0, 1e-9);
PLOT_CHECK_NEAR(env[1], 1.0, 1e-9);
PLOT_CHECK_NEAR(env[2], 127.0, 1e-9);
PLOT_CHECK_NEAR(env[3], 1.0, 1e-9);
s.gather(1, 0, 256, mean, plot::data::GatherMode::Mean);
PLOT_CHECK(mean.size() == 2); // bucket 1 has no finite mean: skipped
PLOT_CHECK_NEAR(mean[0], 64.0, 1e-9);
PLOT_CHECK_NEAR(mean[1], 1.0, 1e-9);
s.gather(1, 0, 256, near, plot::data::GatherMode::Nearest);
PLOT_CHECK(near.size() == 4); // every bucket still has a centre sample
PLOT_CHECK_NEAR(near[0], 64.0, 1e-9);
PLOT_CHECK_NEAR(near[1], 1.0, 1e-9);
PLOT_CHECK_NEAR(near[2], 192.0, 1e-9); // bucket 1's centre, drawn raw
PLOT_CHECK(std::isnan(near[3]));
}
// --- Snapshot: StreamSeries::freeze() -----------------------------------------
PLOT_TEST(stream_series_freeze_copies_the_last_acquire_window) {
plot::data::StreamSeries s(1024, 5.0, 0.5);
for (u64 i = 0; i < 400; ++i) s.push(f32(i % 31));
s.acquire();
const u64 n = s.size();
const plot::Range bounds = s.y_bounds();
std::vector<f32> gathered;
s.gather(1, 0, n, gathered);
const plot::Range reduced = s.reduce(0, n);
// Freeze now, while the ring still holds exactly the published window.
const plot::data::ArraySeries frozen = s.freeze();
PLOT_CHECK(frozen.size() == n);
PLOT_CHECK_NEAR(frozen.x0(), 5.0, 1e-12);
PLOT_CHECK_NEAR(frozen.dx(), 0.5, 1e-12);
PLOT_CHECK_NEAR(frozen.y_bounds().min, bounds.min, 1e-6);
PLOT_CHECK_NEAR(frozen.y_bounds().max, bounds.max, 1e-6);
const plot::Range fr = frozen.reduce(0, n);
PLOT_CHECK_NEAR(fr.min, reduced.min, 1e-6);
PLOT_CHECK_NEAR(fr.max, reduced.max, 1e-6);
PLOT_CHECK_NEAR(frozen.sum(0, n), s.sum(0, n), 1e-3);
std::vector<f32> frozen_gathered;
frozen.gather(1, 0, n, frozen_gathered);
PLOT_CHECK(frozen_gathered == gathered); // same geometry, byte for byte
// The producer now laps the ring (700 more pushes overwrite the oldest
// slots) and a later acquire() grows the published window -- the frozen copy
// answers from its own samples and never moves.
for (u64 i = 0; i < 700; ++i) s.push(-1000.0f);
s.acquire();
PLOT_CHECK(s.size() == 1024);
PLOT_CHECK(frozen.size() == n);
PLOT_CHECK_NEAR(frozen.y_bounds().max, bounds.max, 1e-6);
PLOT_CHECK_NEAR(frozen.y_bounds().min, bounds.min, 1e-6);
PLOT_CHECK(frozen.reduce(0, n).max == reduced.max);
}
// --- Incremental append: ArraySeries::push() ----------------------------------
PLOT_TEST(array_series_push_matches_commit) {
// The incremental path must land exactly where the wholesale path does:
// bounds, x bounds, exact queries and the pyramid's buckets themselves.
std::vector<f32> y;
y.reserve(1000);
for (u64 i = 0; i < 1000; ++i) {
y.push_back(i % 97 == 0 ? std::nanf("") : f32((i * 37) % 251) - 125.0f);
}
plot::data::ArraySeries pushed({}, 10.0, 0.25);
for (const f32 v : y) pushed.push(v);
const plot::data::ArraySeries committed(y, 10.0, 0.25);
PLOT_CHECK(pushed.size() == committed.size());
PLOT_CHECK_NEAR(pushed.x_bounds().min, committed.x_bounds().min, 1e-12);
PLOT_CHECK_NEAR(pushed.x_bounds().max, committed.x_bounds().max, 1e-12);
PLOT_CHECK_NEAR(pushed.y_bounds().min, committed.y_bounds().min, 1e-6);
PLOT_CHECK_NEAR(pushed.y_bounds().max, committed.y_bounds().max, 1e-6);
for (u64 a = 0; a < 1000; a += 173) {
for (u64 b = a + 1; b <= 1000; b += 331) {
const plot::Range ra = pushed.reduce(a, b);
const plot::Range rb = committed.reduce(a, b);
PLOT_CHECK_NEAR(ra.min, rb.min, 1e-9);
PLOT_CHECK_NEAR(ra.max, rb.max, 1e-9);
PLOT_CHECK_NEAR(pushed.sum(a, b), committed.sum(a, b), 1e-9);
PLOT_CHECK_NEAR(pushed.sumsq(a, b), committed.sumsq(a, b), 1e-9);
}
}
for (u32 l = 0; l < 3; ++l) {
PLOT_CHECK(pushed.pyramid().level_base(l) == committed.pyramid().level_base(l));
PLOT_CHECK(pushed.pyramid().level_size(l) == committed.pyramid().level_size(l));
for (u64 b = pushed.pyramid().level_base(l);
b < pushed.pyramid().level_base(l) + pushed.pyramid().level_size(l); ++b) {
const plot::data::MinMax ma = pushed.pyramid().bucket(l, b);
const plot::data::MinMax mb = committed.pyramid().bucket(l, b);
PLOT_CHECK_NEAR(ma.min, mb.min, 1e-9);
PLOT_CHECK_NEAR(ma.max, mb.max, 1e-9);
PLOT_CHECK_NEAR(pushed.pyramid().bucket_sum(l, b), committed.pyramid().bucket_sum(l, b),
1e-9);
PLOT_CHECK(pushed.pyramid().bucket_count(l, b) == committed.pyramid().bucket_count(l, b));
}
}
}
+58 -3
View File
@@ -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(std::fabs(tick.value * scale - std::round(tick.value * scale)) < 1e-9);
PLOT_CHECK(tick.major ? !tick.label.empty() : tick.label.empty()); PLOT_CHECK(tick.major ? !tick.label.empty() : tick.label.empty());
} }
// A range dense enough to want millions of minors stops at the hard cap. }
/// R9: the cap counts **minors only**. A range dense enough to want more minors
/// than fit is truncated in its subdivisions and never in its labels: every major
/// of the span is present, labelled, and the far end of the axis keeps its number.
PLOT_TEST(minor_cap_spares_the_majors) {
std::vector<plot::Tick> capped; std::vector<plot::Tick> capped;
plot::generate_ticks({0.0, 1e9}, 1e6f, 1.0f, capped, /*include_minors=*/true); // Step 1000 over [0, 65000] (65 major intervals): 260 minors want in, more than
PLOT_CHECK(capped.size() == plot::kMaxTicks); // the 256 the cap holds, and every one of the 66 majors still counts.
plot::generate_ticks({0.0, 65000.0}, 65000.0f, 1000.0f, capped, /*include_minors=*/true);
plot::usize majors = 0;
plot::usize minors = 0;
for (const plot::Tick& tick : capped) {
if (tick.major) {
++majors;
} else {
++minors;
}
}
PLOT_CHECK(minors == plot::kMaxTicks); // minors capped exactly as before
PLOT_CHECK(majors == 66u); // every major of the span kept
for (plot::usize i = 1; i < capped.size(); ++i) PLOT_CHECK(capped[i].value > capped[i - 1].value);
// The far end of the axis still carries its label.
PLOT_CHECK(capped.back().major);
PLOT_CHECK(!capped.back().label.empty());
PLOT_CHECK_NEAR(capped.back().value, 65000.0, 1e-9);
}
/// B2: the major/minor split is an integer-index test, so an epoch origin with a
/// small step no longer fights a ULP-sized residue: majors are classified exactly,
/// whatever the origin's magnitude. The +/-300 s window is the reported defect's
/// shape; the sub-second window is where the old float-tolerance test actually
/// misclassified (step 0.01 at 1.75e9 carries an error far above step*1e-9).
PLOT_TEST(epoch_window_yields_labelled_majors) {
std::vector<plot::Tick> t;
plot::generate_ticks({1.75e9 - 300.0, 1.75e9 + 300.0}, 800.0f, 60.0f, t,
/*include_minors=*/true);
plot::usize majors = 0;
for (const plot::Tick& tick : t) {
if (!tick.major) continue;
++majors;
PLOT_CHECK(!tick.label.empty());
PLOT_CHECK(tick.value >= 1.75e9 - 300.0);
PLOT_CHECK(tick.value <= 1.75e9 + 300.0);
}
// Step 50 s over a 600 s window: the labelled ticks span it.
PLOT_CHECK(majors >= 11);
std::vector<plot::Tick> deep;
plot::generate_ticks({1.75e9 - 0.05, 1.75e9 + 0.05}, 800.0f, 60.0f, deep,
/*include_minors=*/true);
plot::usize deep_majors = 0;
for (const plot::Tick& tick : deep) {
if (!tick.major) continue;
++deep_majors;
PLOT_CHECK(!tick.label.empty());
}
// Step 0.01 s over a 0.1 s window: again a full set of labelled majors.
PLOT_CHECK(deep_majors >= 9);
} }
PLOT_TEST(si_labels_are_utf8_and_never_overrun) { PLOT_TEST(si_labels_are_utf8_and_never_overrun) {
+273
View File
@@ -0,0 +1,273 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "harness.hpp"
#include <plot/data/array_series.hpp>
#include <plot/data/xy_series.hpp>
#include <algorithm>
#include <cmath>
#include <random>
#include <vector>
using plot::data::kBucket;
namespace {
/// Monotone but decidedly non-uniform x: early samples a millisecond apart,
/// later ones seconds apart. Exact in f64, no fixed dx anywhere.
std::vector<f64> spread_x(u64 n) {
std::vector<f64> x;
x.reserve(static_cast<plot::usize>(n));
f64 v = 0.0;
for (u64 i = 0; i < n; ++i) {
x.push_back(v);
v += 0.001 + f64(i) * 0.01;
}
return x;
}
std::vector<f32> saw_y(u64 n) {
std::vector<f32> y;
y.reserve(static_cast<plot::usize>(n));
for (u64 i = 0; i < n; ++i) y.push_back(f32((i * 13) % 97) - 48.0f);
return y;
}
} // namespace
PLOT_TEST(xy_series_reports_its_own_grid) {
auto x = spread_x(500);
const std::vector<f64> xs = x; // keep an un-moved copy for the checks
plot::data::XYSeries s(std::move(x), saw_y(500));
PLOT_CHECK(s.size() == 500);
PLOT_CHECK_NEAR(s.x0(), xs.front(), 1e-12);
PLOT_CHECK(s.dx() == 0.0); // meaningless by contract; nothing reads it
for (const u64 i : {u64{0}, u64{1}, u64{250}, u64{499}}) {
PLOT_CHECK_NEAR(s.x_at(i), xs[static_cast<plot::usize>(i)], 1e-12);
}
// x_bounds come from the array's ends, wherever they sit on the number line.
PLOT_CHECK_NEAR(s.x_bounds().min, xs.front(), 1e-12);
PLOT_CHECK_NEAR(s.x_bounds().max, xs.back(), 1e-12);
PLOT_CHECK_NEAR(s.y_bounds().min, -48.0, 1e-6);
PLOT_CHECK_NEAR(s.y_bounds().max, 48.0, 1e-6);
// Epoch-style offset: the same shape shifted by 1.7e9 keeps exact bounds, and
// gather() emits x relative to x0() -- small numbers, not quantised epochs.
std::vector<f64> epoch = spread_x(500);
const std::vector<f64> es = epoch; // un-moved copy for the expectations
for (auto& v : epoch) v += 1.7e9;
plot::data::XYSeries e(std::move(epoch), saw_y(500));
PLOT_CHECK_NEAR(e.x_bounds().min, 1.7e9, 1e-3);
PLOT_CHECK_NEAR(e.x_bounds().max, 1.7e9 + (xs.back() - xs.front()), 1e-3);
std::vector<f32> out;
e.gather(0, 0, 500, out);
PLOT_CHECK(out.size() == 1000);
// x is relative to x0(): x_at(i) - x0(), the array's own offsets -- small
// values even though the absolute epoch is 1.7e9. The expectations come from
// the epoch array itself, since that is the subtraction the contract does.
PLOT_CHECK_NEAR(out[0], 0.0, 1e-9); // x_at(0) - x0()
PLOT_CHECK_NEAR(out[2], f32(es[1] - es[0]), 1e-6);
PLOT_CHECK_NEAR(out[998], f32(es[499] - es[0]), 1e-3); // span, not 1.7e9
PLOT_CHECK(out[998] < 2000.0f);
}
PLOT_TEST(xy_series_neighbour_lookups_crosshair_style) {
// The crosshair asks neighbours() at an arbitrary x and interpolates between
// the two samples it gets back. On a non-uniform grid that needs real
// searches: a uniform-grid formula would land megabytes off here.
auto x = spread_x(1000);
const std::vector<f64> xs = x;
plot::data::XYSeries s(std::move(x), saw_y(1000));
const auto at = [&](u64 i) { return xs[static_cast<plot::usize>(i)]; };
u64 i0 = 7, i1 = 7;
// Midway between samples 100 and 101: brackets those two.
const f64 mid = (at(100) + at(101)) / 2.0;
PLOT_CHECK(s.neighbours(mid, i0, i1));
PLOT_CHECK(i0 == 100);
PLOT_CHECK(i1 == 101);
// Exactly on a sample: both indices are that sample.
PLOT_CHECK(s.neighbours(at(250), i0, i1));
PLOT_CHECK(i0 == 250 && i1 == 250);
// Just left of a sample: still bracketed from below.
PLOT_CHECK(s.neighbours(std::nextafter(at(250), 0.0), i0, i1));
PLOT_CHECK(i0 == 249 && i1 == 250);
// Edges are inclusive.
PLOT_CHECK(s.neighbours(at(0), i0, i1));
PLOT_CHECK(i0 == 0 && i1 == 0);
PLOT_CHECK(s.neighbours(at(999), i0, i1));
PLOT_CHECK(i0 == 999 && i1 == 999); // exact hit on the last sample
// Outside the bounds (and NaN): false, indices untouched.
const u64 keep = 42;
i0 = i1 = keep;
PLOT_CHECK(!s.neighbours(at(0) - 1.0, i0, i1));
PLOT_CHECK(!s.neighbours(at(999) + 1.0, i0, i1));
PLOT_CHECK(!s.neighbours(std::nan(""), i0, i1));
PLOT_CHECK(i0 == keep && i1 == keep);
// Clustered duplicates: the contract is "last index <= x" and "first index
// >= x", so a run of equal x brackets the run's ends.
std::vector<f64> dup{1.0, 2.0, 2.0, 2.0, 3.0};
plot::data::XYSeries d(dup, {1.0f, 2.0f, 3.0f, 4.0f, 5.0f});
PLOT_CHECK(d.neighbours(2.0, i0, i1));
PLOT_CHECK(i0 == 3 && i1 == 1); // last <= 2 is index 3, first >= 2 is index 1
}
PLOT_TEST(xy_series_snap_index_finds_the_nearest_sample) {
auto x = spread_x(1000);
const std::vector<f64> xs = x;
plot::data::XYSeries s(std::move(x), saw_y(1000));
const auto at = [&](u64 i) { return xs[static_cast<plot::usize>(i)]; };
PLOT_CHECK(s.snap_index(at(400)) == 400); // exact hit
PLOT_CHECK(s.snap_index((at(400) + at(401)) / 2.0 + 1e-9) == 401);
PLOT_CHECK(s.snap_index((at(400) + at(401)) / 2.0 - 1e-9) == 400);
PLOT_CHECK(s.snap_index(at(0) - 100.0) == 0); // clamped
PLOT_CHECK(s.snap_index(at(999) + 100.0) == 999); // clamped
PLOT_CHECK(s.snap_index(std::nan("")) == 0); // NaN lands on 0
// A tie goes to the lower index (midpoints are impossible on this x, so make
// one explicitly).
std::vector<f64> tie{0.0, 10.0};
plot::data::XYSeries t(tie, {1.0f, 2.0f});
PLOT_CHECK(t.snap_index(5.0) == 0);
PLOT_CHECK(t.snap_index(5.0 + 1e-9) == 1);
}
PLOT_TEST(xy_series_pyramid_matches_array_series_over_the_same_y) {
// The pyramid buckets by sample index, exactly as for a uniform series, so an
// XYSeries over x = i*dx must answer every exact query identically to the
// ArraySeries over the same y -- and the bucket aggregates must agree too.
constexpr u64 kN = 3000;
std::mt19937 rng(11);
std::uniform_real_distribution<float> d(-50.0f, 50.0f);
std::vector<f32> y(kN);
for (auto& v : y) v = d(rng);
std::vector<f64> x(kN);
for (u64 i = 0; i < kN; ++i) x[i] = f64(i) * 0.5;
const plot::data::XYSeries xy(x, y);
const plot::data::ArraySeries uni(y, 0.0, 0.5);
PLOT_CHECK_NEAR(xy.x0(), uni.x0(), 1e-12);
for (u64 a = 0; a < kN; a += 211) {
for (u64 b = a + 1; b <= kN; b += 499) {
const plot::Range rx = xy.reduce(a, b);
const plot::Range ru = uni.reduce(a, b);
PLOT_CHECK_NEAR(rx.min, ru.min, 1e-6);
PLOT_CHECK_NEAR(rx.max, ru.max, 1e-6);
PLOT_CHECK_NEAR(xy.sum(a, b), uni.sum(a, b), 1e-6);
PLOT_CHECK_NEAR(xy.sumsq(a, b), uni.sumsq(a, b), 1e-6);
}
}
for (u32 l = 0; l < 3; ++l) {
PLOT_CHECK(xy.pyramid().level_base(l) == uni.pyramid().level_base(l));
PLOT_CHECK(xy.pyramid().level_size(l) == uni.pyramid().level_size(l));
for (u64 b = xy.pyramid().level_base(l);
b < xy.pyramid().level_base(l) + xy.pyramid().level_size(l); ++b) {
PLOT_CHECK_NEAR(xy.pyramid().bucket(l, b).min, uni.pyramid().bucket(l, b).min, 1e-6);
PLOT_CHECK_NEAR(xy.pyramid().bucket(l, b).max, uni.pyramid().bucket(l, b).max, 1e-6);
}
}
}
PLOT_TEST(xy_series_gather_envelope_matches_exact_reduces) {
// gather() buckets by index with the same B3 ceil/floor selection, but the x
// of every emitted point is the array's own coordinate relative to x0() --
// and each envelope pair equals the exact reduce over the samples its two x
// positions bracket.
constexpr u64 kN = 4096;
auto x = spread_x(kN);
const std::vector<f64> xs = x;
const std::vector<f32> ys = saw_y(kN);
plot::data::XYSeries s(std::move(x), ys);
const auto at = [&](u64 i) { return xs[static_cast<plot::usize>(i)]; };
const auto val = [&](u64 i) { return ys[static_cast<plot::usize>(i)]; };
const f64 x0 = at(0);
const u32 level = 2;
const u64 span = u64{kBucket} << level;
const u64 b = 500 + 33, e = 3500 - 57; // unaligned on the bucket grid
std::vector<f32> out;
s.gather(level, b, e, out, plot::data::GatherMode::Envelope);
PLOT_CHECK(out.size() >= 8u);
// Emitted x stays inside [x_at(begin), x_at(end)) and the geometry reaches
// both the range's extremes (the lead/trail raw spans carry them).
const plot::Range exact = s.reduce(b, e);
f64 emitted_min = 1e30, emitted_max = -1e30;
for (plot::usize i = 0; i + 1 < out.size(); i += 2) {
const f64 rel = f64(out[i]);
// The emitted x is f32(x_at(i) - x0()), so allow the f32 cast's slack
// (relative positions here reach ~1.4e4, where f32 ULP is ~1e-3).
PLOT_CHECK(rel >= at(b) - x0 - 1e-3);
PLOT_CHECK(rel <= at(e - 1) - x0 + 1e-3);
emitted_min = std::min(emitted_min, f64(out[i + 1]));
emitted_max = std::max(emitted_max, f64(out[i + 1]));
}
PLOT_CHECK_NEAR(emitted_min, exact.min, 1e-6);
PLOT_CHECK_NEAR(emitted_max, exact.max, 1e-6);
// Envelope pairs == exact reduce over their own spans. Walk the layout the
// gather contract fixes -- raw lead pairs, then two pairs per complete
// bucket, then raw trail pairs -- and read each pair's span off its x
// positions (all saw_y values are finite, so no bucket is skipped).
const u64 lend = s.pyramid().level_size(level);
const u64 first = std::min(b / span + (b % span == 0 ? u64{0} : u64{1}), lend);
const u64 last = std::min(e / span, lend);
const u64 lead = first * span;
const u64 buckets = last - first;
PLOT_CHECK(out.size() ==
2 * (lead - b + 2 * buckets + (e - last * span)));
plot::usize o = 0;
for (u64 i = b; i < lead; ++i, o += 2) { // the partial leading bucket, raw
PLOT_CHECK_NEAR(out[o], f32(at(i) - x0), 1e-3);
PLOT_CHECK_NEAR(out[o + 1], val(i), 1e-6);
}
for (u64 j = 0; j < buckets; ++j) {
const u64 bi = first + j;
const u64 lo = bi * span;
const u64 hi = std::min((bi + 1) * span, e) - 1;
const plot::Range r = s.reduce(lo, hi + 1);
PLOT_CHECK_NEAR(out[o], f32(at(lo) - x0), 1e-3);
PLOT_CHECK_NEAR(out[o + 1], r.min, 1e-6);
PLOT_CHECK_NEAR(out[o + 2], f32(at(hi) - x0), 1e-3);
PLOT_CHECK_NEAR(out[o + 3], r.max, 1e-6);
o += 4;
}
for (u64 i = last * span; i < e; ++i, o += 2) { // the partial trailing bucket
PLOT_CHECK_NEAR(out[o], f32(at(i) - x0), 1e-3);
PLOT_CHECK_NEAR(out[o + 1], val(i), 1e-6);
}
PLOT_CHECK(o == out.size());
}
PLOT_TEST(xy_series_nan_policy_matches_the_other_series) {
// Non-finite y: excluded from bounds and sums; an all-NaN window aggregates
// to zero-span / zero.
std::vector<f64> x{0.0, 1.0, 2.0, 3.0, 4.0, 5.0};
plot::data::XYSeries s(x, {std::nanf(""), 1.0f, 2.0f, std::nanf(""), 3.0f, std::nanf("")});
PLOT_CHECK_NEAR(s.y_bounds().min, 1.0, 1e-9);
PLOT_CHECK_NEAR(s.y_bounds().max, 3.0, 1e-9);
const plot::Range r = s.reduce(0, 6);
PLOT_CHECK_NEAR(r.min, 1.0, 1e-9);
PLOT_CHECK_NEAR(r.max, 3.0, 1e-9);
PLOT_CHECK_NEAR(s.sum(0, 6), 6.0, 1e-9);
PLOT_CHECK(s.reduce(4, 5).span() == 0.0);
plot::data::XYSeries all(x, std::vector<f32>(6, std::nanf("")));
PLOT_CHECK(all.y_bounds().span() == 0.0);
PLOT_CHECK(all.sum(0, 6) == 0.0);
PLOT_CHECK_NEAR(all.x_bounds().min, 0.0, 1e-12); // x bounds are the array's
PLOT_CHECK_NEAR(all.x_bounds().max, 5.0, 1e-12);
}
PLOT_TEST(xy_series_commit_rebuilds_and_reset_replaces) {
auto x = spread_x(256);
plot::data::XYSeries s(std::move(x), saw_y(256));
const plot::Range before = s.y_bounds();
std::vector<f64> x2{0.0, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0};
s.reset(std::move(x2), {5.0f, 3.0f, 1.0f, 4.0f, 4.0f, 4.0f, 4.0f, 4.0f});
PLOT_CHECK(s.size() == 8);
PLOT_CHECK_NEAR(s.y_bounds().min, 1.0, 1e-9);
PLOT_CHECK_NEAR(s.y_bounds().max, 5.0, 1e-9);
PLOT_CHECK_NEAR(s.x_bounds().max, 32.0, 1e-12);
PLOT_CHECK(s.y_bounds().max != before.max);
// reset() re-asserts the precondition too: the arrays above are sorted, so
// this passes; unsorted x is a debug-abort by contract (see the header) and
// is deliberately not exercised here.
}