Compare commits
36
Commits
d03f9ce51a
...
0.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc908efa36 | ||
|
|
e81e84645f | ||
|
|
bee318870f | ||
|
|
58473db384 | ||
|
|
c2c8c596f3 | ||
|
|
59c2e45af7 | ||
|
|
03c41adbec | ||
|
|
c960decaea | ||
|
|
c028f48fa0 | ||
|
|
26a7c82a80 | ||
|
|
0ee6e10c81 | ||
|
|
faef19ba60 | ||
|
|
7dbe4e7183 | ||
|
|
fba8d985cf | ||
|
|
c4c0a49466 | ||
|
|
01e410bd74 | ||
|
|
f3fb500068 | ||
|
|
2dcd1f1864 | ||
|
|
211c5b97bc | ||
|
|
125cc1f64a | ||
|
|
523446d08f | ||
|
|
133cd680ce | ||
|
|
e337c6a60c | ||
|
|
525759f9a9 | ||
|
|
98cfc5805b | ||
|
|
38ef459897 | ||
|
|
f132a5ec92 | ||
|
|
1f5ce0d4a5 | ||
|
|
2fbd1de7d4 | ||
|
|
9aff06acee | ||
|
|
81792dcd05 | ||
|
|
040e40254d | ||
|
|
ed3a8ce645 | ||
|
|
059227d080 | ||
|
|
19c8d4368d | ||
|
|
b7d0f901cc |
No files matched your search
@@ -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
@@ -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()
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Martino Ferrari
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
# plot
|
||||||
|
|
||||||
|
An embeddable OpenGL time-series plotting library for real-time, high-sample-rate data.
|
||||||
|
The core (`plot::core`, C++20, OpenGL 3.3 core) has no third-party dependency at all;
|
||||||
|
the host integrations that own a window and an event loop are separate, opt-in targets
|
||||||
|
built on top of it.
|
||||||
|
|
||||||
|
## Hosts, and what a frame costs
|
||||||
|
|
||||||
|
| host | CMake target | source |
|
||||||
|
|---|---|---|
|
||||||
|
| GLFW | `plot::glfw` | `adapters/glfw/` |
|
||||||
|
| Dear ImGui | `plot::imgui` (build-tree only) | `adapters/imgui/` |
|
||||||
|
| Qt6 | `plot::qt6` | `adapters/qt6/` |
|
||||||
|
| GTK4 | `plot::gtk4` | `adapters/gtk4/` |
|
||||||
|
|
||||||
|
The visible x range is mapped onto sample indices through the series' own x
|
||||||
|
(`Series::neighbours()`, a binary search over the real stamps), never through the
|
||||||
|
`(x - x0()) / dx()` grid: `dx()` is a *nominal* spacing, and a series whose x is a streamed
|
||||||
|
timestamp would otherwise have its draw window land inside the visible range and lose the two
|
||||||
|
segments that enter and leave the frame — the empty left and right margins of a zoomed-in view.
|
||||||
|
Per-frame rendering cost is O(visible pixels + samples produced since the last frame),
|
||||||
|
not O(samples): a trace denser than one sample per pixel is drawn from a min/max LOD
|
||||||
|
pyramid rather than from raw samples. The `plot_multiplot_demo` run stages **20,901 GPU
|
||||||
|
segments** to draw **~4.5 million visible samples** across six traces — the frame is
|
||||||
|
bounded by the pixels it lights, not by the sample count behind them. That figure is from
|
||||||
|
a `linux-release` build under `xvfb-run` on **llvmpipe** (Mesa's software rasteriser); no
|
||||||
|
run touched a GPU, so read it as an upper bound and a record of the software path.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- a trace whose series answers a *clamped* stamp for its oldest slots -- a ring whose published
|
||||||
|
window is pinned while it slides -- is drawn from its longest increasing *suffix*, so an unreadable
|
||||||
|
front cannot draw a segment from the newest sample back to the oldest (a line across the whole
|
||||||
|
panel);
|
||||||
|
- a following group's window stays on its members' data: while the members retain at least a
|
||||||
|
window's worth, the scroll offset is bounded by `retained - span`, so a pan or a zoom-in near an
|
||||||
|
edge cannot leave the window's own span over data the scope never had (a window *wider* than the
|
||||||
|
data keeps the older placement -- right edge on the newest sample);
|
||||||
|
- a trace whose gathered window is denser than the axis is reduced **onto the pixel grid** --
|
||||||
|
one snapped vertical segment per integer column, lit from that column's own extremes --
|
||||||
|
because an LOD envelope staged as a polyline puts three or four near-vertical hairlines per
|
||||||
|
column at as many sub-pixel phases, and the beat between the bucket pitch and the pixel pitch
|
||||||
|
is a crawling moiré; one segment per column leaves one phase, so there is nothing to beat
|
||||||
|
(`FrameStats::column_traces`, and `docs/data-and-performance.md` for the four rules it keeps);
|
||||||
|
- and such a column can be drawn **graded by dwell** instead of as one flat band
|
||||||
|
(`Style::intensity_grading`, off by default, `FrameStats::graded_traces`): bright where the
|
||||||
|
signal sits, faint where it only passes through, the way a digital-phosphor scope reads. It is
|
||||||
|
what puts back the information the reduction throws away — a column's min and max are the same
|
||||||
|
whether its samples fill the span or pile at its two ends — so the gather is asked at a level
|
||||||
|
finer than the pixel grid and the distribution read from those sub-buckets;
|
||||||
|
- sample **markers** when a window is sparse enough that its samples are the shape
|
||||||
|
(`Style::marker_below_samples`, 50 by default, `marker_px` across, in the trace's own colour);
|
||||||
|
- grid with independent major/minor line classes (`Style::grid_major_visible`,
|
||||||
|
`grid_minor_visible`), each with its own colour, width and (for the minors) dash — the
|
||||||
|
subdivisions default to thinner, dashed and alpha-blended, so they read as texture under the
|
||||||
|
labelled grid rather than as a second set of ticks;
|
||||||
|
- x and y axes, with a vertical (bottom-to-top) y-axis title;
|
||||||
|
- cursors in both orientations — vertical markers and horizontal rulers;
|
||||||
|
- draggable regions and measurement bands with exact interval statistics;
|
||||||
|
- a signal legend anchored to an axes corner;
|
||||||
|
- a hover crosshair with a per-signal readout, quoting the nearest sample and marking it
|
||||||
|
(`CrosshairValue::NearestSample`, the default) or interpolating between two
|
||||||
|
(`CrosshairValue::Interpolated`);
|
||||||
|
- a multi-plot `Figure` of stacked rows sharing one synchronised x axis;
|
||||||
|
- live follow (`XFollow::Live`) with relative (multiplicative) zoom and mouse panning;
|
||||||
|
- XY series for irregularly sampled data (`plot/data/xy_series.hpp`): sorted-ascending x
|
||||||
|
with binary-searched crosshair, measurement and index snapping;
|
||||||
|
- per-trace draw modes: dash patterns (`Trace::dash_on`/`dash_off`, logical px, 0 = solid)
|
||||||
|
and point markers (`Trace::marker`: cross, circle, square) with a manual
|
||||||
|
`marker_every` stride or an automatic one; `width == 0` draws the markers alone
|
||||||
|
(scatter);
|
||||||
|
- three downsample policies per trace (`Trace::mode`, `GatherMode`): `Envelope`
|
||||||
|
(min/max pairs — the default), `Nearest` (one raw sample per bucket) and `Mean`
|
||||||
|
(one averaged point per bucket);
|
||||||
|
- readout text colour: crosshair value rows render in their row's trace colour, labels in
|
||||||
|
the axis text colour;
|
||||||
|
- input parity across hosts: pinch-zoom and pan gestures on Qt6 and GTK4 drive the same
|
||||||
|
controller paths as mouse and wheel (touch parity), with keyboard panning
|
||||||
|
(arrow keys), Escape cancelling a drag or box-zoom, double-click re-fitting the axes,
|
||||||
|
and modifier-masked box zoom (Shift = both axes, Ctrl = x only, Alt = y only);
|
||||||
|
- DPI-consistent sizing: metrics are logical units converted once through
|
||||||
|
`Style::ui_scale`;
|
||||||
|
- an anti-aliased embedded monospaced font, plus an optional runtime TrueType backend
|
||||||
|
(`plot::font_ttf`).
|
||||||
|
|
||||||
|
## Build and install
|
||||||
|
|
||||||
|
Needs a C++20 compiler and CMake **3.24+**. Presets live in `CMakePresets.json`
|
||||||
|
(`linux-debug`, `linux-release`, `linux-asan`, `linux-coverage`).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake --preset linux-release
|
||||||
|
cmake --build build/release
|
||||||
|
cmake --install build/release --prefix /where/you/want
|
||||||
|
```
|
||||||
|
|
||||||
|
Install is on by default (`PLOT_INSTALL=ON`) and, via `GNUInstallDirs`, installs the
|
||||||
|
`plot_core` archive under `lib/`, the public headers under `include/plot/` — including the
|
||||||
|
generated `plot/version.hpp` — a CMake package config under `lib/cmake/plot/`, a pkg-config
|
||||||
|
file under `lib/pkgconfig/plot.pc`, and the licences under `share/licenses/plot/`. A
|
||||||
|
consumer then needs only:
|
||||||
|
|
||||||
|
```cmake
|
||||||
|
find_package(plot 0.1 REQUIRED)
|
||||||
|
target_link_libraries(my_app PRIVATE plot::core)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Versioning.** The package config carries **SameMinorVersion** compatibility: at 0.x the
|
||||||
|
major component promises nothing and the minor is what moves when the surface breaks, so
|
||||||
|
`find_package(plot 0.1 REQUIRED)` accepts 0.1.x and refuses anything newer. A shared build
|
||||||
|
(`BUILD_SHARED_LIBS=ON`) names its ABI on the file itself — `libplot_core.so.0`, the
|
||||||
|
SOVERSION being the major component. What a header set *is* is in its generated
|
||||||
|
`<plot/version.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
|
||||||
|
|
||||||
|
A host owns the window, the GL context and the event loop; `plot` owns none of them. This
|
||||||
|
is the whole of a minimal GLFW program — the adapter opens the window, runs the loop and
|
||||||
|
feeds the controller, and the callback is where live data would be pushed. It is committed
|
||||||
|
at the repository root as `readme_main.cpp`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
#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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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). The one
|
||||||
|
non-obvious flag is `-I build/debug/include`: that is where CMake generates
|
||||||
|
`plot/version.hpp`, which the umbrella pulls in:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
g++ -std=c++20 -I include -I adapters -I build/debug/include readme_main.cpp \
|
||||||
|
build/debug/adapters/glfw/libplot_glfw.a build/debug/libplot_core.a \
|
||||||
|
$(pkg-config --libs glfw3) -ldl -lpthread -o plot_readme
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
The user guide lives under `docs/`; `docs/superpowers/` is the design record, not user
|
||||||
|
documentation.
|
||||||
|
|
||||||
|
- [Getting started](docs/getting-started.md) — prerequisites, build/install/consume, and a
|
||||||
|
minimal GLFW program against `plot::core`.
|
||||||
|
- [Architecture](docs/architecture.md) — the layers (`core`/`math` → `gl` → `data` →
|
||||||
|
`render` → `plot` → `input`), what each owns, and the dependency rules.
|
||||||
|
- [Embedding into a host](docs/embedding.md) — the four adapters, the `ui_scale`/DPI rule
|
||||||
|
per host, and the headless demo invocations.
|
||||||
|
- [Data and performance](docs/data-and-performance.md) — the sample stores, the
|
||||||
|
single-producer/single-consumer contract, the LOD pyramid, and the measured frame times.
|
||||||
|
- [Interaction](docs/interaction.md) — cursors, regions, the measurement band, the legend,
|
||||||
|
the crosshair, the gesture table, and live follow.
|
||||||
|
- [Styling](docs/styling.md) — every `Style` field with its default and unit, the grid
|
||||||
|
classes, and installing a text backend.
|
||||||
|
- [Building and testing](docs/building-and-testing.md) — presets, `PLOT_BUILD_*` options,
|
||||||
|
the suite, the headless demo modes, and the coverage command.
|
||||||
|
- [Coverage](docs/coverage.md) — the coverage method and the measured per-file table.
|
||||||
|
|
||||||
|
## Testing and coverage
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake --preset linux-debug
|
||||||
|
cmake --build build/debug
|
||||||
|
ctest --test-dir build/debug --output-on-failure # 350 cases, 0 failed
|
||||||
|
|
||||||
|
tools/coverage.sh # configure+build+run+capture+floor check
|
||||||
|
```
|
||||||
|
|
||||||
|
The `linux-debug` suite runs **350 cases**; the `linux-coverage` preset builds **333** of
|
||||||
|
them, because its adapters are off and 17 cases need an adapter target (11 in
|
||||||
|
`test_imgui_widget.cpp`, 6 in `test_glfw_host.cpp`). Coverage is enforced by
|
||||||
|
`tools/coverage.sh` against `tools/coverage_floor.py`: **80%** per file under `src/` and
|
||||||
|
**90%** aggregate. The measured aggregate and per-file table live in
|
||||||
|
[docs/coverage.md](docs/coverage.md), which the command regenerates — it is the single
|
||||||
|
source for those numbers, so this page carries none.
|
||||||
|
|
||||||
|
## Licence and third-party provenance
|
||||||
|
|
||||||
|
- **This library** is **MIT licensed**: the full text is in [`LICENSE`](LICENSE), and
|
||||||
|
every first-party file (C++, Python and shell) carries its SPDX identifier in the file
|
||||||
|
header — `// SPDX-License-Identifier: MIT` — so the grant travels with each source file,
|
||||||
|
not just the repository root.
|
||||||
|
- **Adwaita Mono** (the embedded font, `render::EmbeddedMonoFont`) is under **SIL OFL
|
||||||
|
1.1**, committed verbatim at [`licences/OFL-1.1.txt`](licences/OFL-1.1.txt). The backend
|
||||||
|
class is named neutrally (`EmbeddedMonoFont`, not "Adwaita Mono") because OFL reserves
|
||||||
|
the font name for modified redistributions. The baked atlas is generated at authoring
|
||||||
|
time by `tools/fonts/gen_atlas.py` and committed as data, so `plot_core` rasterises
|
||||||
|
nothing at build or run time.
|
||||||
|
- **stb_truetype v1.26** (public domain, Sean Barrett / RAD Game Tools) is vendored at
|
||||||
|
`third_party/stb_truetype.h` and compiled **only** by the optional `plot_font_ttf`
|
||||||
|
target; its include directory is `PRIVATE`, so no consumer and no other target can reach
|
||||||
|
it. `PLOT_BUILD_TTF_FONT` defaults on exactly when the vendored header is present.
|
||||||
|
- **`plot_core` is third-party-free.** It links only `${CMAKE_DL_LIBS}`, and no public
|
||||||
|
header under `include/plot/` includes a third-party header. The header self-containment
|
||||||
|
and umbrella checks in `tests/CMakeLists.txt` fail the build if either rule breaks.
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- **Timings are llvmpipe under Xvfb** (`tools/coverage.sh`, the demo summaries). No
|
||||||
|
measurement here used a GPU; there are no hardware numbers.
|
||||||
|
- **macOS and Windows are untested.** Development and CI are Linux/EGL + X11 only.
|
||||||
|
- **Fractional-scale compositing and touchpad scroll calibration are unverified** — the
|
||||||
|
`ui_scale` sources are correct per host, but no run exercised a real fractional DPI or a
|
||||||
|
calibrated scroll device. The new gesture paths (Qt6 and GTK4 pinch, the keyboard and
|
||||||
|
wheel paths behind the parity list above) are tested at the toolkit-free seams and
|
||||||
|
headless; no run has touched real touch hardware.
|
||||||
|
- **Figure row rects are one frame stale during a resize.** The hosts cache
|
||||||
|
`Figure::layout()`'s rows and refresh them once per frame, so an event arriving mid-resize
|
||||||
|
routes against the previous frame's rects (see [docs/embedding.md](docs/embedding.md)).
|
||||||
|
- **Untrusted font files are out of scope** for the runtime `plot::font_ttf` backend:
|
||||||
|
stb_truetype does no range checking, so `from_file()` must only be handed files the host
|
||||||
|
trusts.
|
||||||
|
- **`render` ↔ `plot` is not a strict include chain.** `Renderer` includes `plot/plot.hpp`
|
||||||
|
because drawing *is* being handed a `Plot`; the `plot` layer names only `render`'s value
|
||||||
|
types (`Style`, `TextBackend`), never `Renderer`. The cycle and its direction are
|
||||||
|
documented in [docs/architecture.md](docs/architecture.md).
|
||||||
+134
-18
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#include <glfw/glfw_host.hpp>
|
#include <glfw/glfw_host.hpp>
|
||||||
|
|
||||||
// Before GLFW's header, so it does not include <GL/gl.h>: that header's macros
|
// Before GLFW's header, so it does not include <GL/gl.h>: that header's macros
|
||||||
@@ -108,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;
|
||||||
@@ -207,12 +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);
|
||||||
figure_->layout();
|
// The frame's one layout of the figure, into the cache every event handler
|
||||||
// Every row's controller gets that row's rect, so a gesture -- routed by
|
// between now and the next frame reads (R7): the rows below, and the routing of
|
||||||
// controller_at() or injected by a scripted run -- is hit-tested against the
|
// whatever the next `glfwPollEvents()` batch delivers, all take *these* rects
|
||||||
// geometry the last frame drew, and the two readers of a row's geometry cannot
|
// rather than each paying for another layout.
|
||||||
// disagree about it.
|
refresh_row_rects();
|
||||||
const std::vector<Rect>& rows = figure_->layout().rows; // the one layout of this frame
|
const std::vector<Rect>& rows = row_rects_;
|
||||||
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]);
|
||||||
}
|
}
|
||||||
@@ -222,6 +229,18 @@ bool Host::run(const RunConfig& cfg, const FrameFn& frame, Status* st) {
|
|||||||
// signals at the one x the pointer is over. In the run loop rather than in a callback, so a
|
// signals at the one x the pointer is over. In the run loop rather than in a callback, so a
|
||||||
// scripted run that injects its events straight into a row controller is synced too.
|
// scripted run that injects its events straight into a row controller is synced too.
|
||||||
sync_figure_crosshair(rows);
|
sync_figure_crosshair(rows);
|
||||||
|
// Only the row the pointer's events go to ever republishes its own hot handles, so a row
|
||||||
|
// the pointer left keeps the `HotHandles` it last published and goes on drawing a hovered
|
||||||
|
// marker or region bound as if the pointer were still on it -- and its `hovered_region()`/
|
||||||
|
// `grabbed_cursor()` keep answering for it. Clear every *other* row here, in the run loop,
|
||||||
|
// where that row is known (the captured row while a drag owns the button, so an in-flight
|
||||||
|
// gesture keeps its highlight).
|
||||||
|
if (have_pointer_ && !rows.empty()) {
|
||||||
|
const u32 active = pointer_row(rows);
|
||||||
|
for (u32 i = 0; i < rows.size() && i < row_controllers_.size(); ++i) {
|
||||||
|
if (i != active) figure_->plot(i).set_hot_handles(HotHandles{});
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
controller_.set_viewport(viewport); // also Plot::set_viewport, then layout()
|
controller_.set_viewport(viewport); // also Plot::set_viewport, then layout()
|
||||||
}
|
}
|
||||||
@@ -337,7 +356,8 @@ 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_);
|
||||||
controller_at(e.x, e.y).on_pointer(e);
|
e.time_s = glfwGetTime(); // GLFW's monotonic clock; only differences are read
|
||||||
|
feed_pointer_event(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
void Host::on_mouse_button(int button, int action, int mods) noexcept {
|
void Host::on_mouse_button(int button, int action, int mods) noexcept {
|
||||||
@@ -364,7 +384,8 @@ 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_);
|
||||||
controller_at(e.x, e.y).on_pointer(e);
|
e.time_s = glfwGetTime(); // the double-click detector reads differences of these
|
||||||
|
feed_pointer_event(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
void Host::on_scroll(f64 dx, f64 dy) noexcept {
|
void Host::on_scroll(f64 dx, f64 dy) noexcept {
|
||||||
@@ -390,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;
|
||||||
@@ -415,14 +443,34 @@ void Host::set_figure(Figure* figure) noexcept {
|
|||||||
last_row_ = 0u;
|
last_row_ = 0u;
|
||||||
// A new figure holds no crosshair this host has spread: the comparison in
|
// A new figure holds no crosshair this host has spread: the comparison in
|
||||||
// `sync_figure_crosshair()` starts from "nothing spread", which is the state its rows are in.
|
// `sync_figure_crosshair()` starts from "nothing spread", which is the state its rows are in.
|
||||||
|
// Nor does it hold a captured drag row: the controllers are rebuilt below, so the old
|
||||||
|
// capture names a controller that no longer exists.
|
||||||
spread_active_ = false;
|
spread_active_ = false;
|
||||||
spread_x_ = 0.0;
|
spread_x_ = 0.0;
|
||||||
if (figure_ == nullptr) return;
|
spread_y_ = 0.0;
|
||||||
|
captured_ = false;
|
||||||
|
captured_row_ = 0u;
|
||||||
|
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 {
|
||||||
@@ -431,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);
|
||||||
@@ -446,6 +496,54 @@ input::Controller& Host::controller_at(f32 x, f32 y) noexcept {
|
|||||||
return controller;
|
return controller;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Host::feed_pointer_event(const input::PointerEvent& e) noexcept {
|
||||||
|
if (figure_ == nullptr) {
|
||||||
|
controller_.on_pointer(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pointer_target(e.x, e.y, e.button, e.pressed, e.released).on_pointer(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
input::Controller& Host::pointer_target(f32 x, f32 y, input::Button button, bool pressed,
|
||||||
|
bool released) noexcept {
|
||||||
|
(void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in()
|
||||||
|
if (figure_ == nullptr || row_controllers_.empty()) return controller_;
|
||||||
|
const std::vector<Rect>& rows = row_rects_;
|
||||||
|
if (rows.empty() || rows.size() != row_controllers_.size()) return controller_;
|
||||||
|
|
||||||
|
u32 row = 0u;
|
||||||
|
if (captured_) {
|
||||||
|
// A button-down gesture belongs to the row it began in for its whole life: a drag
|
||||||
|
// that crosses into a neighbouring row must not hand its motion -- or its release --
|
||||||
|
// to a controller that never saw the press, which would leave the owner's drag open
|
||||||
|
// for ever (and let a later gesture resume it from the old anchor).
|
||||||
|
row = captured_row_;
|
||||||
|
// Only the owning button's release ends the capture (R5): a second button's
|
||||||
|
// release -- pressed meanwhile as a chord -- must not strand the first button's
|
||||||
|
// drag, and its press must not steal the capture to the row it landed in.
|
||||||
|
if (released && button == captured_button_) captured_ = false;
|
||||||
|
} else {
|
||||||
|
row = row_in(rows, y);
|
||||||
|
if (pressed) {
|
||||||
|
captured_ = true;
|
||||||
|
captured_row_ = row;
|
||||||
|
captured_button_ = button;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The captured row is re-read against this frame's rects: the figure can be resized
|
||||||
|
// mid-drag, and the guard keeps a stale index from reaching past the controller list.
|
||||||
|
if (row >= row_controllers_.size()) row = 0u;
|
||||||
|
last_row_ = row;
|
||||||
|
input::Controller& controller = *row_controllers_[row];
|
||||||
|
controller.set_viewport(rows[row]);
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
u32 Host::pointer_row(const std::vector<Rect>& rows) noexcept {
|
||||||
|
if (captured_ && captured_row_ < rows.size()) return captured_row_;
|
||||||
|
return row_in(rows, last_pointer_y_);
|
||||||
|
}
|
||||||
|
|
||||||
u32 Host::row_in(const std::vector<Rect>& rows, f32 y) noexcept {
|
u32 Host::row_in(const std::vector<Rect>& rows, f32 y) noexcept {
|
||||||
if (rows.empty()) return 0u;
|
if (rows.empty()) return 0u;
|
||||||
// The row the pixel is in, or -- when it is in none of them, which is the row gap a
|
// The row the pixel is in, or -- when it is in none of them, which is the row gap a
|
||||||
@@ -482,11 +580,18 @@ void Host::sync_figure_crosshair(const std::vector<Rect>& rows) noexcept {
|
|||||||
// keeps it.
|
// keeps it.
|
||||||
u32 hovered = row_count;
|
u32 hovered = row_count;
|
||||||
if (have_pointer_) {
|
if (have_pointer_) {
|
||||||
hovered = row_in(rows, last_pointer_y_);
|
// The row the pointer's events currently go to -- the captured row while a drag owns
|
||||||
|
// the button, the hovered row otherwise -- so the hover follows the gesture it began.
|
||||||
|
hovered = pointer_row(rows);
|
||||||
} else {
|
} else {
|
||||||
for (u32 i = 0; i < row_count; ++i) {
|
for (u32 i = 0; i < row_count; ++i) {
|
||||||
const Crosshair& candidate = figure_->plot(i).crosshair();
|
const Crosshair& candidate = figure_->plot(i).crosshair();
|
||||||
if (candidate.active != spread_active_ || (candidate.active && candidate.x != spread_x_)) {
|
// A row differs when its active state, its x, or the y of *its own* horizontal line
|
||||||
|
// differs from what was spread: a pointer that moves to another row at the same x is
|
||||||
|
// a new hover and must move the one horizontal line, not leave the old row's behind.
|
||||||
|
const bool y_differs = candidate.horizontal && candidate.y != spread_y_;
|
||||||
|
if (candidate.active != spread_active_ ||
|
||||||
|
(candidate.active && (candidate.x != spread_x_ || y_differs))) {
|
||||||
hovered = i;
|
hovered = i;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -502,8 +607,19 @@ void Host::sync_figure_crosshair(const std::vector<Rect>& rows) noexcept {
|
|||||||
// figure's, so the rows cannot be left showing a hover the pointer has abandoned.
|
// figure's, so the rows cannot be left showing a hover the pointer has abandoned.
|
||||||
const Plot& row = figure_->plot(hovered);
|
const Plot& row = figure_->plot(hovered);
|
||||||
if (row.crosshair().active) {
|
if (row.crosshair().active) {
|
||||||
|
if (row.crosshair().horizontal) {
|
||||||
|
// The row carries its own y (its controller placed both coordinates), so the figure's
|
||||||
|
// four-argument overload gives the pointer row the one horizontal line.
|
||||||
figure_->set_crosshair(row.crosshair().x, hovered, row.crosshair().y, true);
|
figure_->set_crosshair(row.crosshair().x, hovered, row.crosshair().y, true);
|
||||||
|
} else {
|
||||||
|
// The row's crosshair is vertical-only -- a caller placed it through the
|
||||||
|
// single-coordinate `Figure::set_crosshair()`, which is documented vertical-only. The
|
||||||
|
// four-argument overload would turn the horizontal line on at the stored (meaningless)
|
||||||
|
// y == 0, clamped to the axes edge and made permanent. Promote only the vertical line.
|
||||||
|
figure_->set_crosshair(row.crosshair().x, true);
|
||||||
|
}
|
||||||
spread_x_ = row.crosshair().x;
|
spread_x_ = row.crosshair().x;
|
||||||
|
spread_y_ = row.crosshair().y;
|
||||||
spread_active_ = true;
|
spread_active_ = true;
|
||||||
} else {
|
} else {
|
||||||
figure_->clear_crosshair();
|
figure_->clear_crosshair();
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
e.mods = static_cast<u32>(mods);
|
|
||||||
// GLFW reports auto-repeat as its own action; a held key keeps acting, exactly
|
|
||||||
// as holding the key on a keyboard does.
|
|
||||||
e.down = action != GLFW_RELEASE;
|
|
||||||
// A key has no position of its own: it goes to the row the pointer was last over,
|
|
||||||
// which is the row the user is looking at.
|
|
||||||
input::Controller& controller =
|
|
||||||
(figure_ == nullptr || last_row_ >= row_controllers_.size()) ? controller_
|
|
||||||
: *row_controllers_[last_row_];
|
|
||||||
controller.on_key(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace plot::glfw
|
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <functional>
|
#include <functional>
|
||||||
@@ -125,18 +128,58 @@ 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.
|
||||||
///
|
///
|
||||||
/// It also hands that row its own viewport, which is what keeps the controller's
|
/// It also hands that row its own viewport, which is what keeps the controller's
|
||||||
/// hit-test geometry in step with the layout between two frames. The event callbacks
|
/// hit-test geometry in step with the layout between two frames. It is the pure
|
||||||
/// route through it, and so can a host that wants to know where a synthetic gesture
|
/// query -- what a gesture *would* land on -- and it does not move the capture
|
||||||
/// would land.
|
/// below; the GLFW callbacks and `feed_pointer_event()` are the routing.
|
||||||
input::Controller& controller_at(f32 x, f32 y) noexcept;
|
input::Controller& controller_at(f32 x, f32 y) noexcept;
|
||||||
|
|
||||||
|
/// Feeds one pointer event, stated in framebuffer pixels, through the host's own
|
||||||
|
/// routing and into the controller it belongs to. The GLFW callbacks fill the
|
||||||
|
/// position and the button transitions out of GLFW and call exactly this, so a host
|
||||||
|
/// or a test with no window can drive the routing directly.
|
||||||
|
///
|
||||||
|
/// The routing is the row the event lands in, with one exception that is the whole
|
||||||
|
/// point of it: a press **captures** the row it landed in, and every motion and the
|
||||||
|
/// release until *that button* comes up go to the captured row. A drag that crosses
|
||||||
|
/// into a neighbouring figure row is therefore still the drag it began as -- the
|
||||||
|
/// owning controller sees the motion and the release that end it -- rather than
|
||||||
|
/// being handed to a controller whose `mode_ == None`, which would leave the owner's
|
||||||
|
/// drag open for ever and let a later gesture resume it from the old anchor.
|
||||||
|
///
|
||||||
|
/// Single-gesture policy: a figure routes one button-down gesture at a time. The
|
||||||
|
/// first button to press claims the capture; a second button pressed meanwhile --
|
||||||
|
/// a chord, or a trackpad that reports a second finger as a button -- neither
|
||||||
|
/// steals the capture to another row nor ends it, and only the owning button's
|
||||||
|
/// release ends it. Whatever the second button's events do to the controllers is
|
||||||
|
/// the controllers' business; the host only guarantees they all arrive at the row
|
||||||
|
/// the first press claimed.
|
||||||
|
void feed_pointer_event(const input::PointerEvent& e) noexcept;
|
||||||
|
|
||||||
|
/// Re-reads the figure's row rects into the cache every event handler below
|
||||||
|
/// reads -- the one `Figure::layout()` the routing costs between two frames. The
|
||||||
|
/// run loop calls this once per frame, right after the figure's viewport is set,
|
||||||
|
/// so the rects the handlers answer from are the rects that frame draws; the
|
||||||
|
/// handlers themselves never lay the figure out.
|
||||||
|
///
|
||||||
|
/// A driver that injects events without running the loop -- a scripted run, the
|
||||||
|
/// headless tests -- calls this after re-configuring the figure (a new viewport,
|
||||||
|
/// a new row gap, `set_figure()`): it is the scripted equivalent of the frame the
|
||||||
|
/// run loop would have staged. Until it runs, the handlers answer from the last
|
||||||
|
/// refreshed rects, which is also what happens across a window resize: GLFW
|
||||||
|
/// delivers the resize and any pointer events queued behind it in one batch, the
|
||||||
|
/// events before the resize see the rows the previous frame drew, and the loop's
|
||||||
|
/// next `refresh_row_rects()` brings the cache onto the new geometry -- rows are
|
||||||
|
/// at most one frame stale, never wrong by a second layout.
|
||||||
|
void refresh_row_rects() noexcept;
|
||||||
|
|
||||||
/// The gesture controller the host feeds. Valid for the host's whole life.
|
/// 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_; }
|
||||||
|
|
||||||
@@ -180,6 +223,20 @@ class Host {
|
|||||||
/// that has just laid the figure out does not make it lay out again for this.
|
/// that has just laid the figure out does not make it lay out again for this.
|
||||||
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:
|
||||||
|
/// a press claims the row it landed in (`captured_row_` + `captured_button_`) and
|
||||||
|
/// every motion and the release until *that button* comes up are routed to it;
|
||||||
|
/// `controller_at()` is the pure query that never captures. Also hands the target
|
||||||
|
/// its row's viewport, so the hit-test geometry is the last frame's.
|
||||||
|
input::Controller& pointer_target(f32 x, f32 y, input::Button button, bool pressed,
|
||||||
|
bool released) noexcept;
|
||||||
|
|
||||||
|
/// The row the pointer's events currently go to: the captured row while a drag owns
|
||||||
|
/// the button, the row under the pointer otherwise. The run loop uses it to clear
|
||||||
|
/// every *other* row's hot handles, so a row the pointer left cannot keep drawing a
|
||||||
|
/// hover or a grab it last published.
|
||||||
|
u32 pointer_row(const std::vector<Rect>& rows) noexcept;
|
||||||
|
|
||||||
/// Spreads the hover over a whole `Figure` (spec §15).
|
/// Spreads the hover over a whole `Figure` (spec §15).
|
||||||
///
|
///
|
||||||
/// A figure's rows share one x, so the crosshair one row's controller placed is read back here and
|
/// A figure's rows share one x, so the crosshair one row's controller placed is read back here and
|
||||||
@@ -217,10 +274,31 @@ class Host {
|
|||||||
/// `sync_figure_crosshair()`): what a row is compared against to find the one a
|
/// `sync_figure_crosshair()`): what a row is compared against to find the one a
|
||||||
/// scripted run touched, since the figure writes one value to every row.
|
/// scripted run touched, since the figure writes one value to every row.
|
||||||
/// `spread_active_` false means "spread nothing" -- the state before the first
|
/// `spread_active_` false means "spread nothing" -- the state before the first
|
||||||
/// sync, and the one a clear leaves.
|
/// sync, and the one a clear leaves. `spread_y_` is the horizontal line's own
|
||||||
|
/// value, compared as well so a y-only change -- the same x in another row --
|
||||||
|
/// is not mistaken for "nothing changed".
|
||||||
f64 spread_x_ = 0.0;
|
f64 spread_x_ = 0.0;
|
||||||
|
f64 spread_y_ = 0.0;
|
||||||
bool spread_active_ = false;
|
bool spread_active_ = false;
|
||||||
|
|
||||||
|
/// The row a button-down drag has captured, and the button that owns the capture:
|
||||||
|
/// the row its press landed in owns that button's motions and its release until
|
||||||
|
/// the button comes up. `captured_` is false between gestures, and cleared when
|
||||||
|
/// another figure is set. The button is recorded because only the owning button's
|
||||||
|
/// release ends the capture (R5): a second button pressed while the drag runs is
|
||||||
|
/// routed to the same row and must neither steal the capture nor end it, or its
|
||||||
|
/// release would strand the first button's drag.
|
||||||
|
bool captured_ = false;
|
||||||
|
u32 captured_row_ = 0u;
|
||||||
|
input::Button captured_button_ = input::Button::Left;
|
||||||
|
|
||||||
|
/// The figure's row rects as of the last `refresh_row_rects()` -- the cache the
|
||||||
|
/// event routing reads instead of laying the figure out per event (R7). Filled by
|
||||||
|
/// `set_figure()` and once per frame by the run loop; empty while no figure is
|
||||||
|
/// set, which is the state `controller_at()`/`pointer_target()` fall back to the
|
||||||
|
/// single-plot controller on.
|
||||||
|
std::vector<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;
|
||||||
/// Framebuffer pixels per window pixel. Taken from the two sizes GLFW reports
|
/// Framebuffer pixels per window pixel. Taken from the two sizes GLFW reports
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <plot/core/rect.hpp>
|
#include <plot/core/rect.hpp>
|
||||||
@@ -226,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
|
||||||
+324
-31
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#include <gtk4/gtk_plot_area.hpp>
|
#include <gtk4/gtk_plot_area.hpp>
|
||||||
|
|
||||||
#include <gtk4/gtk_input.hpp>
|
#include <gtk4/gtk_input.hpp>
|
||||||
@@ -15,6 +18,8 @@
|
|||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <dlfcn.h>
|
#include <dlfcn.h>
|
||||||
|
#include <limits>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
// The mirrored constants in gtk_input.hpp are pinned to GDK's here: a GDK that
|
// The mirrored constants in gtk_input.hpp are pinned to GDK's here: a GDK that
|
||||||
// renumbered the scroll units or moved a modifier bit fails to compile rather
|
// renumbered the scroll units or moved a modifier bit fails to compile rather
|
||||||
@@ -28,6 +33,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 {
|
||||||
@@ -155,6 +165,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
|
||||||
@@ -197,9 +219,187 @@ void PlotArea::refresh_viewport() noexcept {
|
|||||||
// chrome out with it) and refreshes the layout. `last_device_viewport()` is
|
// chrome out with it) and refreshes the layout. `last_device_viewport()` is
|
||||||
// deliberately not assigned here -- it records the region a *frame* was drawn
|
// deliberately not assigned here -- it records the region a *frame* was drawn
|
||||||
// into, which is `render()`'s to set.
|
// into, which is `render()`'s to set.
|
||||||
|
if (figure_ != nullptr) {
|
||||||
|
lay_figure_out(device_viewport());
|
||||||
|
return;
|
||||||
|
}
|
||||||
controller_.set_viewport(device_viewport());
|
controller_.set_viewport(device_viewport());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- figure mode ----------------------------------------------------------
|
||||||
|
|
||||||
|
void PlotArea::set_figure(Figure* figure) noexcept {
|
||||||
|
figure_ = figure;
|
||||||
|
row_controllers_.clear();
|
||||||
|
last_row_ = 0;
|
||||||
|
// A new figure holds no crosshair this area has spread, and no captured press row:
|
||||||
|
// the controllers are rebuilt below, so an open press would name one that is gone.
|
||||||
|
spread_active_ = false;
|
||||||
|
spread_x_ = 0.0;
|
||||||
|
spread_y_ = 0.0;
|
||||||
|
press_open_ = false;
|
||||||
|
press_row_ = 0;
|
||||||
|
drag_.have = false;
|
||||||
|
zoom_.end();
|
||||||
|
if (figure_ == nullptr) {
|
||||||
|
row_rects_.clear(); // no figure, no rows: the routing falls back to `controller_`
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const u32 rows = figure_->row_count();
|
||||||
|
row_controllers_.reserve(rows);
|
||||||
|
for (u32 i = 0; i < rows; ++i) {
|
||||||
|
row_controllers_.push_back(std::make_unique<input::Controller>(figure_->plot(i)));
|
||||||
|
}
|
||||||
|
// The new figure's geometry goes into the cache now, not at the next frame: an event
|
||||||
|
// arriving between `set_figure()` and the first render -- which is every event a
|
||||||
|
// headless probe injects -- must be routed against the figure it was just handed.
|
||||||
|
lay_figure_out(device_viewport());
|
||||||
|
}
|
||||||
|
|
||||||
|
void PlotArea::lay_figure_out(Rect px) noexcept {
|
||||||
|
figure_->set_viewport(px);
|
||||||
|
refresh_row_rects();
|
||||||
|
for (u32 i = 0; i < row_controllers_.size() && i < row_rects_.size(); ++i) {
|
||||||
|
// Each row's own rect, which is the viewport `Figure::layout()` just gave that
|
||||||
|
// row's `Plot`: the controller and the plot must agree on it or a gesture lands on
|
||||||
|
// the wrong pixels. Set here rather than by the controller, which owns no viewport.
|
||||||
|
row_controllers_[i]->set_viewport(row_rects_[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void PlotArea::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.
|
||||||
|
row_rects_ = figure_ != nullptr ? figure_->layout().rows : std::vector<Rect>{};
|
||||||
|
}
|
||||||
|
|
||||||
|
input::Controller* PlotArea::row_controller(u32 row) noexcept {
|
||||||
|
return row < row_controllers_.size() ? row_controllers_[row].get() : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
input::Controller& PlotArea::controller_at(f32 x, f32 y) noexcept {
|
||||||
|
if (figure_ == nullptr || row_controllers_.empty()) return controller_;
|
||||||
|
// The cached rects `refresh_row_rects()` filled -- the same ones the last frame drew
|
||||||
|
// -- and not a fresh `Figure::layout()`: an event pays no layout, or a burst of
|
||||||
|
// pointer motion over a multi-row figure would re-lay every row out per move.
|
||||||
|
if (row_rects_.empty() || row_rects_.size() != row_controllers_.size()) return controller_;
|
||||||
|
(void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in()
|
||||||
|
const u32 row = row_in(row_rects_, y);
|
||||||
|
input::Controller& controller = *row_controllers_[row];
|
||||||
|
controller.set_viewport(row_rects_[row]);
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
u32 PlotArea::row_at(double x, double y) const noexcept {
|
||||||
|
if (figure_ == nullptr || row_rects_.empty()) return 0;
|
||||||
|
(void)x;
|
||||||
|
return row_in(row_rects_, to_device(x, y).y);
|
||||||
|
}
|
||||||
|
|
||||||
|
input::Controller& PlotArea::target_controller(f32 y) noexcept {
|
||||||
|
if (figure_ == nullptr || row_controllers_.empty()) return controller_;
|
||||||
|
if (row_rects_.empty() || row_rects_.size() != row_controllers_.size()) return controller_;
|
||||||
|
// The capture: while a press is open the gesture belongs to the row that press
|
||||||
|
// landed in, however far the pointer has travelled since. Handing a `drag-update` or
|
||||||
|
// the release to the row the pointer is over *now* would leave the first row's drag
|
||||||
|
// open for ever -- its controller never sees the release that ends it -- and let a
|
||||||
|
// later gesture resume it from the stale anchor.
|
||||||
|
u32 row = press_open_ ? press_row_ : row_in(row_rects_, y);
|
||||||
|
// Re-read against this frame's rects: the widget can be resized mid-drag, and the
|
||||||
|
// guard keeps a stale index from reaching past the controller list.
|
||||||
|
if (row >= row_controllers_.size()) row = 0;
|
||||||
|
last_row_ = row;
|
||||||
|
input::Controller& controller = *row_controllers_[row];
|
||||||
|
controller.set_viewport(row_rects_[row]);
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
u32 PlotArea::row_in(const std::vector<Rect>& rows, f32 y) noexcept {
|
||||||
|
if (rows.empty()) return 0;
|
||||||
|
// The row the pixel is in, or -- when it is in none of them, which is the row gap a
|
||||||
|
// host asked for with `Figure::set_row_gap()` or the figure's own margins -- the row
|
||||||
|
// whose rect is nearest. By distance rather than by "the first that matches": the
|
||||||
|
// rows tile the figure at the default gap of 0, so this is unreachable until a host
|
||||||
|
// asks for a gap, and a gesture in a visible gap must not default to row 0.
|
||||||
|
u32 row = 0;
|
||||||
|
f32 best = std::numeric_limits<f32>::infinity();
|
||||||
|
for (u32 i = 0; i < rows.size(); ++i) {
|
||||||
|
const f32 top = rows[i].y;
|
||||||
|
const f32 bottom = rows[i].y + rows[i].h;
|
||||||
|
const f32 distance = y < top ? top - y : (y >= bottom ? y - bottom : 0.0f);
|
||||||
|
if (distance < best) {
|
||||||
|
best = distance;
|
||||||
|
row = i;
|
||||||
|
if (distance == 0.0f) break; // inside this row: nothing can be nearer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
u32 PlotArea::pointer_row() const noexcept {
|
||||||
|
if (row_rects_.empty()) return 0;
|
||||||
|
// The captured row while a press owns the gesture, so an in-flight drag keeps its
|
||||||
|
// highlight even after the pointer has left the row it began in.
|
||||||
|
if (press_open_ && press_row_ < row_rects_.size()) return press_row_;
|
||||||
|
return row_in(row_rects_, pointer_.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PlotArea::sync_figure_crosshair() noexcept {
|
||||||
|
if (row_rects_.empty() || row_controllers_.empty()) return;
|
||||||
|
const u32 row_count = static_cast<u32>(row_rects_.size());
|
||||||
|
|
||||||
|
// Which row's crosshair is the hover. With a pointer of this area's own it is the row
|
||||||
|
// that pointer's events go to -- the same rule the routing uses, so the hover follows
|
||||||
|
// the gesture it began. A headless probe injects its events straight into a row
|
||||||
|
// controller and the area never sees a pointer, so there the hover is the row whose
|
||||||
|
// crosshair is *not* the one last spread: the figure writes one value to every row,
|
||||||
|
// so a row holding anything else was changed from outside. Nothing differing means no
|
||||||
|
// new hover, and the figure keeps the one it has.
|
||||||
|
u32 hovered = row_count;
|
||||||
|
if (have_pointer_) {
|
||||||
|
hovered = pointer_row();
|
||||||
|
} else {
|
||||||
|
for (u32 i = 0; i < row_count; ++i) {
|
||||||
|
const Crosshair& candidate = figure_->plot(i).crosshair();
|
||||||
|
// A row differs when its active state, its x, or the y of *its own* horizontal
|
||||||
|
// line differs from what was spread: a pointer moving to another row at the same
|
||||||
|
// x is a new hover and must move the one horizontal line, not leave the old one.
|
||||||
|
const bool y_differs = candidate.horizontal && candidate.y != spread_y_;
|
||||||
|
if (candidate.active != spread_active_ ||
|
||||||
|
(candidate.active && (candidate.x != spread_x_ || y_differs))) {
|
||||||
|
hovered = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hovered >= row_count) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One x to every row, and the hovered row's **own** y only to that row (spec §17):
|
||||||
|
// the rows of a figure share x and not y -- row 0 may be volts and row 1 degrees --
|
||||||
|
// so a horizontal line spread across the figure would mean a different value in each.
|
||||||
|
// A row whose controller has *no* crosshair (the pointer left its axes, the only
|
||||||
|
// thing that clears one) clears the figure's, so the rows cannot be left showing a
|
||||||
|
// hover the pointer has abandoned.
|
||||||
|
const Plot& row = figure_->plot(hovered);
|
||||||
|
if (row.crosshair().active) {
|
||||||
|
if (row.crosshair().horizontal) {
|
||||||
|
figure_->set_crosshair(row.crosshair().x, hovered, row.crosshair().y, true);
|
||||||
|
} else {
|
||||||
|
// Vertical-only: a caller placed it through the single-coordinate
|
||||||
|
// `Figure::set_crosshair()`. The four-argument overload would turn the horizontal
|
||||||
|
// line on at the stored (meaningless) y == 0, clamped to the axes edge and made
|
||||||
|
// permanent, so only the vertical line is promoted.
|
||||||
|
figure_->set_crosshair(row.crosshair().x, true);
|
||||||
|
}
|
||||||
|
spread_x_ = row.crosshair().x;
|
||||||
|
spread_y_ = row.crosshair().y;
|
||||||
|
spread_active_ = true;
|
||||||
|
} else {
|
||||||
|
figure_->clear_crosshair();
|
||||||
|
spread_active_ = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- the GL signals -------------------------------------------------------
|
// --- the GL signals -------------------------------------------------------
|
||||||
|
|
||||||
void PlotArea::realize() noexcept {
|
void PlotArea::realize() noexcept {
|
||||||
@@ -276,6 +476,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 {
|
||||||
@@ -296,12 +497,36 @@ void PlotArea::render() noexcept {
|
|||||||
// chrome out with it) and refreshes the layout, so every frame is laid out for
|
// chrome out with it) and refreshes the layout, so every frame is laid out for
|
||||||
// the geometry of *this* frame. A resize changes the layout; it never scales a
|
// the geometry of *this* frame. A resize changes the layout; it never scales a
|
||||||
// stale one.
|
// stale one.
|
||||||
|
if (figure_ != nullptr) {
|
||||||
|
// The figure's one layout for this frame: it hands every row its rect (and so
|
||||||
|
// every row's `Plot` its viewport) and fills the rect cache the routing of
|
||||||
|
// whatever GTK delivers before the next frame answers from.
|
||||||
|
lay_figure_out(viewport);
|
||||||
|
// One hover, every row: the crosshair the row under the pointer placed is applied
|
||||||
|
// to the whole figure here, after this frame's events have been handled and before
|
||||||
|
// anything lays the rows out again. Here rather than in a handler, so a headless
|
||||||
|
// probe that injects straight into a row controller is synced too.
|
||||||
|
sync_figure_crosshair();
|
||||||
|
// Only the row the pointer's events go to republishes its own hot handles, so a row
|
||||||
|
// the pointer has left would keep the `HotHandles` it last published and go on
|
||||||
|
// drawing a hovered marker or cursor grab as if the pointer were still on it. Clear
|
||||||
|
// every *other* row here, where that row is known.
|
||||||
|
if (have_pointer_ && !row_rects_.empty()) {
|
||||||
|
const u32 active = pointer_row();
|
||||||
|
for (u32 i = 0; i < row_rects_.size() && i < row_controllers_.size(); ++i) {
|
||||||
|
if (i != active) figure_->plot(i).set_hot_handles(HotHandles{});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
controller_.set_viewport(viewport);
|
controller_.set_viewport(viewport);
|
||||||
|
}
|
||||||
|
|
||||||
const f64 started = now_s();
|
const f64 started = now_s();
|
||||||
const f64 dt = last_render_s_ >= 0.0 ? started - last_render_s_ : 0.0;
|
const f64 dt = last_render_s_ >= 0.0 ? started - last_render_s_ : 0.0;
|
||||||
last_render_s_ = started;
|
last_render_s_ = started;
|
||||||
if (frame_) frame_(*plot_, dt);
|
// A figure's rows take the pointer, but the frame callback is still given a `Plot&`:
|
||||||
|
// row 0 is the one it gets, so the callback argument and `figure()->plot(0)` agree.
|
||||||
|
if (frame_) frame_(figure_ != nullptr ? figure_->plot(0u) : *plot_, dt);
|
||||||
|
|
||||||
// The mapping's factor is the display's scale, so the renderer draws its logical
|
// The mapping's factor is the display's scale, so the renderer draws its logical
|
||||||
// style metrics at the same ratio the viewport and every forwarded coordinate are
|
// style metrics at the same ratio the viewport and every forwarded coordinate are
|
||||||
@@ -310,7 +535,11 @@ void PlotArea::render() noexcept {
|
|||||||
// ceiling under fractional scaling and would over-scale the chrome by up to 60%.
|
// ceiling under fractional scaling and would over-scale the chrome by up to 60%.
|
||||||
renderer_->style().ui_scale = mapping_.factor;
|
renderer_->style().ui_scale = mapping_.factor;
|
||||||
renderer_->begin_frame(static_cast<u32>(viewport.w), static_cast<u32>(viewport.h), viewport);
|
renderer_->begin_frame(static_cast<u32>(viewport.w), static_cast<u32>(viewport.h), viewport);
|
||||||
|
if (figure_ != nullptr) {
|
||||||
|
renderer_->draw(*figure_);
|
||||||
|
} else {
|
||||||
renderer_->draw(*plot_);
|
renderer_->draw(*plot_);
|
||||||
|
}
|
||||||
renderer_->end_frame();
|
renderer_->end_frame();
|
||||||
|
|
||||||
const f32 frame_ms = static_cast<f32>((now_s() - started) * 1.0e3);
|
const f32 frame_ms = static_cast<f32>((now_s() - started) * 1.0e3);
|
||||||
@@ -361,13 +590,19 @@ 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
|
||||||
|
|
||||||
|
const Vec2 p = to_device(x, y);
|
||||||
|
// The figure row this gesture belongs to for its whole life, claimed from the press's
|
||||||
|
// own position *before* the event is dispatched (see `target_controller()`). Harmless
|
||||||
|
// and unread while no figure is set.
|
||||||
|
press_row_ = figure_ != nullptr ? row_in(row_rects_, p.y) : 0;
|
||||||
press_open_ = true;
|
press_open_ = true;
|
||||||
press_button_ = button;
|
press_button_ = button;
|
||||||
press_mods_ = mods;
|
press_mods_ = mods;
|
||||||
|
|
||||||
const Vec2 p = to_device(x, y);
|
|
||||||
pointer_ = p;
|
pointer_ = p;
|
||||||
have_pointer_ = true;
|
have_pointer_ = true;
|
||||||
|
|
||||||
@@ -380,12 +615,17 @@ 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;
|
||||||
controller_.on_pointer(e);
|
e.time_s = pointer_time_s(time_ms); // the double-click detector differences these
|
||||||
|
target_controller(p.y).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;
|
||||||
|
// Resolved while the press is still open, so the release reaches the row the press
|
||||||
|
// claimed rather than the row the pointer ended over -- the controller that owns the
|
||||||
|
// drag is the only one that can close it.
|
||||||
|
input::Controller& target = target_controller(position.y);
|
||||||
press_open_ = false;
|
press_open_ = false;
|
||||||
|
|
||||||
input::PointerEvent e;
|
input::PointerEvent e;
|
||||||
@@ -397,7 +637,8 @@ 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;
|
||||||
controller_.on_pointer(e);
|
e.time_s = pointer_time_s(time_ms);
|
||||||
|
target.on_pointer(e);
|
||||||
gtk_gl_area_queue_render(GTK_GL_AREA(widget_));
|
gtk_gl_area_queue_render(GTK_GL_AREA(widget_));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,7 +650,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,
|
||||||
@@ -418,11 +660,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
|
||||||
@@ -431,7 +673,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,
|
||||||
@@ -450,7 +696,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)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,13 +726,16 @@ 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;
|
||||||
area->controller_.on_pointer(e);
|
e.time_s = area->pointer_time_s(
|
||||||
|
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture)));
|
||||||
|
// The press's row, not the row the pointer has reached: `press_open_` is true here,
|
||||||
|
// so a drag that crosses a row boundary stays with the controller that began it.
|
||||||
|
area->target_controller(sample.position.y).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
|
||||||
@@ -493,7 +743,48 @@ 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_);
|
||||||
|
// The folded event carries the focal point, which is the position this pinch belongs
|
||||||
|
// to; while a press is open the capture overrides it, as it does for a drag.
|
||||||
|
area->target_controller(g.y).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,
|
||||||
@@ -525,7 +816,7 @@ int PlotArea::on_scroll(GtkEventControllerScroll* scroll, double dx, double dy,
|
|||||||
e.dx = static_cast<f32>(scroll_notches(dx, unit));
|
e.dx = static_cast<f32>(scroll_notches(dx, unit));
|
||||||
e.dy = static_cast<f32>(notches);
|
e.dy = static_cast<f32>(notches);
|
||||||
e.mods = gesture_mods(GTK_EVENT_CONTROLLER(scroll));
|
e.mods = gesture_mods(GTK_EVENT_CONTROLLER(scroll));
|
||||||
area->controller_.on_wheel(e);
|
area->target_controller(at.y).on_wheel(e);
|
||||||
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
|
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
|
||||||
return TRUE; // consumed: the plot zoomed
|
return TRUE; // consumed: the plot zoomed
|
||||||
}
|
}
|
||||||
@@ -547,7 +838,9 @@ 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;
|
||||||
area->controller_.on_pointer(e);
|
e.time_s = PlotArea::pointer_time_s(
|
||||||
|
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(motion)));
|
||||||
|
area->target_controller(p.y).on_pointer(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- keyboard -------------------------------------------------------------
|
// --- keyboard -------------------------------------------------------------
|
||||||
@@ -557,20 +850,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;
|
||||||
@@ -578,7 +865,13 @@ int PlotArea::on_key_pressed(GtkEventControllerKey* keys, unsigned int keyval,
|
|||||||
// GTK reports a held key as repeated presses and sends no release the controller
|
// GTK reports a held key as repeated presses and sends no release the controller
|
||||||
// would act on anyway (its actions are press-only), so the press is the event.
|
// would act on anyway (its actions are press-only), so the press is the event.
|
||||||
e.down = true;
|
e.down = true;
|
||||||
area->controller_.on_key(e);
|
// A key has no position of its own, so it goes to the row the last event was routed
|
||||||
|
// to -- the row the user is looking at.
|
||||||
|
input::Controller& controller =
|
||||||
|
(area->figure_ == nullptr || area->last_row_ >= area->row_controllers_.size())
|
||||||
|
? area->controller_
|
||||||
|
: *area->row_controllers_[area->last_row_];
|
||||||
|
controller.on_key(e);
|
||||||
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
|
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
|
||||||
return TRUE; // consumed: the plot acted on it
|
return TRUE; // consumed: the plot acted on it
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <functional>
|
#include <functional>
|
||||||
|
#include <memory>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
#include <plot/core/rect.hpp>
|
#include <plot/core/rect.hpp>
|
||||||
#include <plot/core/status.hpp>
|
#include <plot/core/status.hpp>
|
||||||
#include <plot/core/types.hpp>
|
#include <plot/core/types.hpp>
|
||||||
#include <plot/input/controller.hpp>
|
#include <plot/input/controller.hpp>
|
||||||
#include <plot/math/vec2.hpp>
|
#include <plot/math/vec2.hpp>
|
||||||
|
#include <plot/plot/figure.hpp>
|
||||||
#include <plot/plot/plot.hpp>
|
#include <plot/plot/plot.hpp>
|
||||||
#include <plot/render/renderer.hpp>
|
#include <plot/render/renderer.hpp>
|
||||||
|
|
||||||
@@ -29,10 +35,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 {
|
||||||
|
|
||||||
@@ -131,6 +139,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
|
||||||
@@ -162,6 +174,42 @@ using FrameFn = std::function<void(Plot&, f64 dt)>;
|
|||||||
/// visible without waiting for the host's next frame, and the frame rate stays the
|
/// visible without waiting for the host's next frame, and the frame rate stays the
|
||||||
/// host's: the demo pumps `g_timeout_add(16, gtk_gl_area_queue_render)`.
|
/// host's: the demo pumps `g_timeout_add(16, gtk_gl_area_queue_render)`.
|
||||||
///
|
///
|
||||||
|
/// ## Figure mode
|
||||||
|
///
|
||||||
|
/// `set_figure()` makes the area draw a whole `Figure` -- several rows sharing one
|
||||||
|
/// x axis -- instead of its own `Plot`, exactly as `glfw::Host::set_figure()` does
|
||||||
|
/// for the reference host, and for the same reason: the multi-row shape is one
|
||||||
|
/// `Figure`, not N widgets, because the x link groups, the shared y gutter and the
|
||||||
|
/// one shared x axis strip all live inside it and cannot be reassembled from
|
||||||
|
/// separate areas. The GTK plumbing above is unchanged by it; what changes is where
|
||||||
|
/// an event goes and what the renderer is handed.
|
||||||
|
///
|
||||||
|
/// A `Controller` is bound to a plot at construction, so figure mode holds **one
|
||||||
|
/// controller per row**, created by `set_figure()`, and routes each event to the row
|
||||||
|
/// it lands in (`controller_at()`). The row rects come from a cache
|
||||||
|
/// (`refresh_row_rects()`) that the render callback fills once per frame, so a burst
|
||||||
|
/// of pointer motion costs no layouts; between a resize and the next frame the cache
|
||||||
|
/// is one frame stale, which is the documented worst case.
|
||||||
|
///
|
||||||
|
/// A gesture belongs to the row its **press** landed in for its whole life: the
|
||||||
|
/// press claims the row, and every `drag-update`, pinch, wheel and the release that
|
||||||
|
/// follow while that press is open go there, however far the pointer travels. A drag
|
||||||
|
/// that crosses into the row below is therefore still the drag it began as, instead
|
||||||
|
/// of being handed to a controller that never saw the press -- which would leave the
|
||||||
|
/// first row's drag open for ever and let a later gesture resume it from the stale
|
||||||
|
/// anchor. The single open press `press_open_` already enforces (see the input
|
||||||
|
/// section) is that capture; this adapter needs no separate button bookkeeping the
|
||||||
|
/// way the GLFW host does, because GTK's two gestures share one sequence.
|
||||||
|
///
|
||||||
|
/// The **crosshair** is one crosshair over the figure and not one per row: the row
|
||||||
|
/// under the pointer places it, and `sync_figure_crosshair()` reads it back and
|
||||||
|
/// applies it to every row, so each row's readout lists its own traces at the one x
|
||||||
|
/// the pointer is over. Reading it back rather than deriving a second coordinate
|
||||||
|
/// here is what keeps the row's hover and the figure's the same number.
|
||||||
|
///
|
||||||
|
/// A key event has no position, so it goes to the row the last event was routed to,
|
||||||
|
/// which is the row the user is looking at.
|
||||||
|
///
|
||||||
/// ## Headless use
|
/// ## Headless use
|
||||||
///
|
///
|
||||||
/// A `GtkGLArea` renders into its own FBO, so a screenshot of the *plot* is a
|
/// A `GtkGLArea` renders into its own FBO, so a screenshot of the *plot* is a
|
||||||
@@ -193,10 +241,56 @@ class PlotArea {
|
|||||||
/// The plot this area renders. Valid for the widget's whole life.
|
/// The plot this area renders. Valid for the widget's whole life.
|
||||||
Plot& plot() noexcept { return *plot_; }
|
Plot& plot() noexcept { return *plot_; }
|
||||||
const Plot& plot() const noexcept { return *plot_; }
|
const Plot& plot() const noexcept { return *plot_; }
|
||||||
/// The controller this area feeds, bound to `plot()`.
|
/// The controller this area feeds, bound to `plot()`. **Not** the one a figure's
|
||||||
|
/// gestures reach: see `row_controller()`.
|
||||||
input::Controller& controller() noexcept { return controller_; }
|
input::Controller& controller() noexcept { return controller_; }
|
||||||
const input::Controller& controller() const noexcept { return controller_; }
|
const input::Controller& controller() const noexcept { return controller_; }
|
||||||
|
|
||||||
|
/// Draws `figure` instead of this area's own plot, with one `input::Controller` per
|
||||||
|
/// row and every event routed to the row it lands in (see "Figure mode" above).
|
||||||
|
/// The figure is borrowed and must outlive the widget.
|
||||||
|
///
|
||||||
|
/// The frame callback is still handed a `Plot&`, and in figure mode it is **row
|
||||||
|
/// 0**, so the callback's argument and `figure()->plot(0)` are the same plot --
|
||||||
|
/// matching `glfw::Host`. A host that needs the figure itself captures it.
|
||||||
|
///
|
||||||
|
/// Passing null restores the single-plot path; `plot()` is untouched throughout,
|
||||||
|
/// so the area goes back to drawing exactly what it drew before.
|
||||||
|
void set_figure(Figure* figure) noexcept;
|
||||||
|
Figure* figure() noexcept { return figure_; }
|
||||||
|
const Figure* figure() const noexcept { return figure_; }
|
||||||
|
|
||||||
|
/// One figure row's controller -- the one a gesture on that row reaches -- or null
|
||||||
|
/// while no figure is set or the row does not exist. A host reads a drag mode or a
|
||||||
|
/// hover out of it.
|
||||||
|
input::Controller* row_controller(u32 row) noexcept;
|
||||||
|
|
||||||
|
/// The controller an event at device pixel `(x, y)` belongs to: `controller()`
|
||||||
|
/// while no figure is set, and otherwise the controller of the figure row that
|
||||||
|
/// pixel lands in -- a pixel inside no row (a `Figure::set_row_gap()` gap, a
|
||||||
|
/// margin) is the *nearest* row rather than dropped, so a drag that leaves the data
|
||||||
|
/// area still ends where it should. It also hands that row its own viewport, which
|
||||||
|
/// is what keeps the controller's hit-test geometry in step with the layout between
|
||||||
|
/// two frames.
|
||||||
|
///
|
||||||
|
/// The pure query: it reports what a gesture *would* land on and never claims the
|
||||||
|
/// press capture, so a host may call it freely.
|
||||||
|
input::Controller& controller_at(f32 x, f32 y) noexcept;
|
||||||
|
|
||||||
|
/// The figure row a point in the widget's **logical** coordinates lands in -- the
|
||||||
|
/// coordinates every GTK event controller reports, so a host's own drop target or
|
||||||
|
/// context menu can name the row a pointer is over without converting anything. The
|
||||||
|
/// same nearest-row rule as `controller_at()`, and 0 while no figure is set.
|
||||||
|
u32 row_at(double x, double y) const noexcept;
|
||||||
|
|
||||||
|
/// Re-reads the figure's row rects into the cache the event routing answers from --
|
||||||
|
/// the one `Figure::layout()` the routing costs between two frames. The render
|
||||||
|
/// callback runs it once per frame, right after the figure's viewport is set, so
|
||||||
|
/// the rects the handlers use are the rects that frame draws. A driver that injects
|
||||||
|
/// events without rendering (a headless probe) calls it after re-configuring the
|
||||||
|
/// figure; a no-op with no figure set.
|
||||||
|
void refresh_row_rects() noexcept;
|
||||||
|
|
||||||
/// The renderer, or null until a successful `::realize`. Valid from the first
|
/// The renderer, or null until a successful `::realize`. Valid from the first
|
||||||
/// render onwards, and after the widget's context exists.
|
/// render onwards, and after the widget's context exists.
|
||||||
Renderer* renderer() noexcept { return renderer_ ? &*renderer_ : nullptr; }
|
Renderer* renderer() noexcept { return renderer_ ? &*renderer_ : nullptr; }
|
||||||
@@ -268,13 +362,46 @@ 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;
|
||||||
|
|
||||||
|
/// The controller one event of this area's goes to, given the event's device y.
|
||||||
|
/// `controller_` while no figure is set; otherwise the row the gesture *belongs*
|
||||||
|
/// to: the row the open press claimed while a press is open, and the row under `y`
|
||||||
|
/// between gestures. That is the capture `controller_at()` deliberately omits, and
|
||||||
|
/// it is why this -- not `controller_at()` -- is what every handler below calls.
|
||||||
|
///
|
||||||
|
/// Hands the chosen row its own viewport, so the geometry it hit-tests is the
|
||||||
|
/// geometry the last frame drew.
|
||||||
|
input::Controller& target_controller(f32 y) noexcept;
|
||||||
|
/// The row of `row_rects_` a device y belongs to: the row it is in, or the nearest
|
||||||
|
/// one when it is in none (a row gap, a margin outside the figure). Selection by
|
||||||
|
/// distance rather than "the first that matches" is what makes a pixel in a visible
|
||||||
|
/// gap belong to the row it is *near* instead of to row 0.
|
||||||
|
static u32 row_in(const std::vector<Rect>& rows, f32 y) noexcept;
|
||||||
|
/// The row this area's events currently go to: the press's row while one is open,
|
||||||
|
/// so an in-flight gesture keeps its highlight, and the row under the last pointer
|
||||||
|
/// position otherwise. 0 with no rows.
|
||||||
|
u32 pointer_row() const noexcept;
|
||||||
|
/// Spreads one row's crosshair over the whole figure (see "Figure mode"). Run once
|
||||||
|
/// per frame, from the render callback, after this frame's row rects are cached.
|
||||||
|
void sync_figure_crosshair() noexcept;
|
||||||
|
/// `figure_->set_viewport(px)`, one `layout()` into the rect cache, and each row's
|
||||||
|
/// own rect to its controller. The figure-mode half of `refresh_viewport()` and of
|
||||||
|
/// `render()`, which must not drift apart.
|
||||||
|
void lay_figure_out(Rect px) noexcept;
|
||||||
|
|
||||||
static void on_realize(GtkGLArea* area, void* self) noexcept;
|
static void on_realize(GtkGLArea* area, void* self) noexcept;
|
||||||
static void on_unrealize(GtkGLArea* area, void* self) noexcept;
|
static void on_unrealize(GtkGLArea* area, void* self) noexcept;
|
||||||
/// `gboolean` is `int`; a GtkGLArea render handler ignores the context.
|
/// `gboolean` is `int`; a GtkGLArea render handler ignores the context.
|
||||||
@@ -286,6 +413,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,
|
||||||
@@ -304,6 +440,26 @@ class PlotArea {
|
|||||||
|
|
||||||
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_;
|
||||||
|
/// The figure this area draws instead of `plot_`, and one controller per row for it
|
||||||
|
/// -- one per `Plot`, because a `Controller` is bound to a plot at construction and
|
||||||
|
/// this is what makes a gesture on any row reach that row. Both are empty until
|
||||||
|
/// `set_figure()` is called, and the single-plot path reads neither.
|
||||||
|
Figure* figure_ = nullptr;
|
||||||
|
std::vector<std::unique_ptr<input::Controller>> row_controllers_;
|
||||||
|
/// 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. Empty while no figure
|
||||||
|
/// is set, which is the state the routing falls back to `controller_` on.
|
||||||
|
std::vector<Rect> row_rects_;
|
||||||
|
/// The row the last event was routed to, which is where a key event goes: a key has
|
||||||
|
/// no position of its own.
|
||||||
|
u32 last_row_ = 0;
|
||||||
|
/// The crosshair last spread over the figure (see `sync_figure_crosshair()`): what a
|
||||||
|
/// row is compared against to find the one something outside changed, since the
|
||||||
|
/// figure writes one value to every row. `spread_active_` false means "spread
|
||||||
|
/// nothing" -- the state before the first sync, and the one a clear leaves.
|
||||||
|
f64 spread_x_ = 0.0;
|
||||||
|
f64 spread_y_ = 0.0;
|
||||||
|
bool spread_active_ = false;
|
||||||
GtkWidget* widget_ = nullptr;
|
GtkWidget* widget_ = nullptr;
|
||||||
std::optional<Renderer> renderer_;
|
std::optional<Renderer> renderer_;
|
||||||
FrameFn frame_;
|
FrameFn frame_;
|
||||||
@@ -315,12 +471,19 @@ class PlotArea {
|
|||||||
bool press_open_ = false;
|
bool press_open_ = false;
|
||||||
input::Button press_button_ = input::Button::Left;
|
input::Button press_button_ = input::Button::Left;
|
||||||
u32 press_mods_ = 0;
|
u32 press_mods_ = 0;
|
||||||
|
/// The figure row the open press landed in -- the gesture's capture (see
|
||||||
|
/// `target_controller()`). Meaningless while `press_open_` is false and while no
|
||||||
|
/// figure is set.
|
||||||
|
u32 press_row_ = 0;
|
||||||
/// The drag gesture's anchor: its press point and last offset, in *logical*
|
/// The drag gesture's anchor: its press point and last offset, in *logical*
|
||||||
/// pixels. Logical on purpose: a factor change between two samples of a drag
|
/// pixels. Logical on purpose: a factor change between two samples of a drag
|
||||||
/// cannot then turn into a phantom movement (see `DragAnchor`). The offset is
|
/// cannot then turn into a phantom movement (see `DragAnchor`). The offset is
|
||||||
/// 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_{};
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#include <imgui/imgui_plot.hpp>
|
#include <imgui/imgui_plot.hpp>
|
||||||
|
|
||||||
#include <imgui.h>
|
#include <imgui.h>
|
||||||
@@ -198,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);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -228,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;
|
||||||
@@ -236,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];
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <optional>
|
#include <optional>
|
||||||
|
|||||||
+108
-11
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#include <qt6/qt_plot_widget.hpp>
|
#include <qt6/qt_plot_widget.hpp>
|
||||||
#include <qt6/qt_pointer.hpp>
|
#include <qt6/qt_pointer.hpp>
|
||||||
|
|
||||||
@@ -8,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>
|
||||||
@@ -90,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() {
|
||||||
@@ -240,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();
|
||||||
@@ -253,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;
|
||||||
}
|
}
|
||||||
@@ -270,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);
|
||||||
@@ -289,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;
|
||||||
@@ -304,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
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
// The plot headers come first, and must. Qt's <QOpenGLWidget> reaches
|
// The plot headers come first, and must. Qt's <QOpenGLWidget> reaches
|
||||||
@@ -31,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 {
|
||||||
|
|
||||||
@@ -103,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
|
||||||
///
|
///
|
||||||
@@ -167,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;
|
||||||
@@ -193,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_;
|
||||||
@@ -204,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;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#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 {
|
||||||
@@ -46,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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
`plot` is one library, `plot_core`, plus optional adapter targets. The public headers are
|
||||||
|
grouped into seven directories, and the umbrella header `include/plot/plot.hpp` includes
|
||||||
|
them in this order:
|
||||||
|
|
||||||
|
```
|
||||||
|
core / math → gl → data → render → plot → input
|
||||||
|
```
|
||||||
|
|
||||||
|
with the host adapters (`adapters/glfw`, `adapters/imgui`, `adapters/qt6`, `adapters/gtk4`)
|
||||||
|
built on top. This page says what each layer owns and the rules the build enforces.
|
||||||
|
|
||||||
|
## What each layer owns
|
||||||
|
|
||||||
|
| layer | directory | owns |
|
||||||
|
|---|---|---|
|
||||||
|
| `core` | `include/plot/core/`, `src/core/` | the vocabulary: `f32`/`f64`/`u32`/`usize`, `kInvalid`, `Range`, `Rect`, `Color`, `Span`, and `Status`/`ErrorCode` for fallible operations. |
|
||||||
|
| `math` | `include/plot/math/`, `src/math/` | `Vec2`, `Mat3`, and `Transform` — the data↔pixel affine mapping, including the log-axis handling and its invertibility guarantee. |
|
||||||
|
| `gl` | `include/plot/gl/`, `src/gl/` | a hand-written OpenGL surface: enumerator typedefs, the entry-point table `plot::gl::load()` fills, `plot::gl::caps()`, and the move-only RAII wrappers (`gl::Buffer`, `VertexArray`, `Program`, `Texture`, `Framebuffer`, `Fence`). No GL loader library is used. |
|
||||||
|
| `data` | `include/plot/data/`, `src/data/` | the sample stores: the `Series` interface, `ArraySeries`, `StreamSeries`, `RingBuffer`, and the min/max LOD `MinMaxPyramid`. |
|
||||||
|
| `render` | `include/plot/render/`, `src/render/` | presentation: `Style`, the `Renderer`, the text machinery (`TextBackend`, `TextAtlas`, `EmbeddedMonoFont`), and the shader/program/buffer/vertex-array primitives. |
|
||||||
|
| `plot` | `include/plot/plot/`, `src/plot/` | the scene model: `Plot`, `Figure`, and the primitives they own — `Axis`, `Trace`, `Cursor`, `Region`, `Measure`, `Legend`, `Crosshair`, `Annotation`, `Grid`, `Ticks`. Pure CPU state, no GL. |
|
||||||
|
| `input` | `include/plot/input/`, `src/input/` | `Controller` and the host-neutral event types (`PointerEvent`, `WheelEvent`, `KeyEvent`); turns events into gestures. |
|
||||||
|
| adapters | `adapters/*/` | one per host: they own the window/context/event loop and translate their toolkit's events into `input` events. |
|
||||||
|
|
||||||
|
## The dependency rules
|
||||||
|
|
||||||
|
- **`plot_core` is third-party-free.** Its `CMakeLists.txt` links only
|
||||||
|
`${CMAKE_DL_LIBS}`, and the public headers under `include/plot/` include no third-party
|
||||||
|
header. The one exception is the optional `plot::font_ttf` target, which compiles the
|
||||||
|
vendored `third_party/stb_truetype.h` (stb_truetype v1.26, public domain) in its own
|
||||||
|
object library `plot_stb_truetype`, whose include directory is `PRIVATE` — so nothing
|
||||||
|
else in the build, and no consumer, can reach it by accident. `PLOT_BUILD_TTF_FONT`
|
||||||
|
defaults to on exactly when the vendored header is present.
|
||||||
|
- **Every public header compiles standalone, and `plot.hpp` includes every one of them.**
|
||||||
|
`tests/CMakeLists.txt` generates one translation unit per header (globbed, so a new
|
||||||
|
header is picked up automatically), builds an umbrella TU, and registers ctest cases
|
||||||
|
(`plot_headers_self_contained`, `plot_umbrella_covers_public_headers`,
|
||||||
|
`plot_umbrella_check`) that fail the build if either rule breaks.
|
||||||
|
- **The umbrella stops at the library.** It never includes an adapter header, so
|
||||||
|
`#include <plot/plot.hpp>` cannot drag in GLFW, ImGui, Qt or GTK. The adapter headers are
|
||||||
|
reached as `<glfw/glfw_host.hpp>`, `<imgui/imgui_plot.hpp>`, `<qt6/qt_plot_widget.hpp>`
|
||||||
|
and `<gtk4/gtk_plot_area.hpp>`.
|
||||||
|
- **The renderer never owns the context or the event loop.** `Renderer` never creates,
|
||||||
|
binds or resizes a framebuffer: `begin_frame(fb_w, fb_h, viewport_px)` draws into
|
||||||
|
whatever framebuffer is bound, clears exactly the given rectangle (scissored, so a host
|
||||||
|
that painted the rest of its surface keeps it), and `end_frame()` turns the GL state it
|
||||||
|
moved back off. The renderer also never sees a pointer, a button or a modifier — it draws
|
||||||
|
the handles the plot reports as hot, and only a host that drives the plot with an
|
||||||
|
`input::Controller` ever makes anything hot. Owning the window, the context and the loop
|
||||||
|
is the adapter's job (or, for a library user, the application's).
|
||||||
|
|
||||||
|
One direction is worth stating precisely, because it is the one place the layer order is
|
||||||
|
not a strict include chain: `Renderer` (in `render/`) includes `plot/plot.hpp` and
|
||||||
|
`plot/figure.hpp`, because drawing *is* being handed a `Plot` or a `Figure` to draw. The
|
||||||
|
`plot` layer names only `render`'s value types — `render::Style` and
|
||||||
|
`render::TextBackend` — and never names `Renderer`. So the runtime direction is
|
||||||
|
"the renderer knows the plot, the plot does not know the renderer".
|
||||||
|
|
||||||
|
## The design record
|
||||||
|
|
||||||
|
The specifications and implementation plans — the requirements, the numbered spec sections
|
||||||
|
this documentation sometimes refers to, and the reasoning behind decisions — live under
|
||||||
|
[`docs/superpowers/`](superpowers/) (`specs/` and `plans/`). That tree is the project's
|
||||||
|
design record, not user documentation: it is where a decision is argued, not where the
|
||||||
|
library is explained. This `docs/` guide and [docs/coverage.md](coverage.md) are the
|
||||||
|
user-facing documents.
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# Building and testing
|
||||||
|
|
||||||
|
## Presets and options
|
||||||
|
|
||||||
|
Four configure/build presets live in `CMakePresets.json`:
|
||||||
|
|
||||||
|
| preset | binaryDir | what it builds |
|
||||||
|
|---|---|---|
|
||||||
|
| `linux-debug` | `build/debug` | Debug; tests, examples and all four adapters |
|
||||||
|
| `linux-release` | `build/release` | Release; `PLOT_BUILD_TESTS=OFF` (so the installed `libplot_core.a` carries none of the test-only counter instrumentation); examples and all four adapters |
|
||||||
|
| `linux-asan` | `build/asan` | Debug with `PLOT_SANITIZE=address`; tests and all four adapters |
|
||||||
|
| `linux-coverage` | `build/cov` | the instrumented tree behind `tools/coverage.sh`: `--coverage -O0 -g`, `PLOT_COVERAGE=ON`, tests on, adapters and examples off |
|
||||||
|
|
||||||
|
The options (`cmake/plot_options.cmake`) and their defaults:
|
||||||
|
|
||||||
|
| option | default | meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `PLOT_BUILD_TESTS` | `ON` | build the test suite (needs EGL) |
|
||||||
|
| `PLOT_BUILD_EXAMPLES` | `OFF` | build the demos |
|
||||||
|
| `PLOT_BUILD_GLFW` | `OFF` | build the GLFW adapter and its demo |
|
||||||
|
| `PLOT_BUILD_IMGUI` | `OFF` | build the Dear ImGui adapter and its demo (ImGui is fetched at configure time) |
|
||||||
|
| `PLOT_BUILD_QT6` | `OFF` | build the Qt6 adapter and its demo |
|
||||||
|
| `PLOT_BUILD_GTK4` | `OFF` | build the GTK4 adapter and its demo |
|
||||||
|
| `PLOT_BUILD_TTF_FONT` | on iff `third_party/stb_truetype.h` exists | build the optional `plot::text::TrueTypeFont` backend |
|
||||||
|
| `PLOT_INSTALL` | `ON` | generate install/export rules for `find_package(plot)` |
|
||||||
|
| `PLOT_SANITIZE` | `""` | sanitizer list for `-fsanitize=` (e.g. `address`) |
|
||||||
|
| `PLOT_COVERAGE` | `OFF` | add the `coverage` target (requires an instrumented tree) |
|
||||||
|
|
||||||
|
Adapters and examples are off by default, so the core library and its tests build on a bare
|
||||||
|
toolchain. Each example is guarded by the adapter target it needs, so
|
||||||
|
`-DPLOT_BUILD_EXAMPLES=ON` without the matching adapter is a no-op rather than a configure
|
||||||
|
error.
|
||||||
|
|
||||||
|
## Running the suite
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake --preset linux-debug
|
||||||
|
cmake --build build/debug
|
||||||
|
ctest --test-dir build/debug --output-on-failure
|
||||||
|
```
|
||||||
|
|
||||||
|
ctest registers four tests: `plot_tests` (the test binary), the two header/umbrella build
|
||||||
|
checks (`plot_headers_self_contained`, `plot_umbrella_covers_public_headers`) and
|
||||||
|
`plot_umbrella_check`. The `plot_tests` binary itself runs **350 cases** in the `linux-debug`
|
||||||
|
preset; its harness prints one line per case and a final `350 cases, 0 failed` summary line.
|
||||||
|
The case counts per file are the `PLOT_TEST(...)` declarations under `tests/` — recount them
|
||||||
|
(`grep -c '^PLOT_TEST(' tests/test_*.cpp`) after adding or removing cases.
|
||||||
|
|
||||||
|
## Install layout
|
||||||
|
|
||||||
|
`cmake --install` follows `GNUInstallDirs`, so every path honours the standard cache
|
||||||
|
variables (`CMAKE_INSTALL_LIBDIR`, `CMAKE_INSTALL_INCLUDEDIR`, …) and their distribution
|
||||||
|
defaults — plain `lib`, `include` and `share` unless a packager overrides them:
|
||||||
|
|
||||||
|
| prefix-relative path | what |
|
||||||
|
|---|---|
|
||||||
|
| `lib/` | the `plot_core` archive (and `libplot_core.so` with its version and SONAME symlinks when built with `BUILD_SHARED_LIBS=ON`) |
|
||||||
|
| `include/plot/` | every public header, including the generated `plot/version.hpp` (`plot::version::major/minor/patch/string` and the `PLOT_VERSION_*` macros) |
|
||||||
|
| `lib/cmake/plot/` | the CMake package: `plotConfig.cmake`, `plotConfigVersion.cmake` (Compatibility **SameMinorVersion**, so `find_package(plot 0.1 …)` accepts 0.1.x only), `plotTargets.cmake` |
|
||||||
|
| `lib/pkgconfig/plot.pc` | pkg-config metadata for consumers whose build is not CMake |
|
||||||
|
| `share/licenses/plot/` | `LICENSE` (MIT) and `licences/OFL-1.1.txt` (the embedded font) |
|
||||||
|
|
||||||
|
Adapters that were built and exported install themselves alongside the core (their headers
|
||||||
|
under `include/<adapter>/`, their targets in the same package).
|
||||||
|
|
||||||
|
## Headless demo modes
|
||||||
|
|
||||||
|
Each demo supports two environment variables, implemented once and reused:
|
||||||
|
|
||||||
|
- `PLOT_DEMO_EXIT_AFTER_MS=<n>` — stop the loop after `n` milliseconds and return. Unset,
|
||||||
|
unparsable or not positive means "run until the window is closed". A run with a deadline
|
||||||
|
creates its window hidden, so an automated run never flashes a window on a real display,
|
||||||
|
and its frame times are not throttled by a vblank.
|
||||||
|
- `PLOT_DEMO_SCREENSHOT=<path>` — on that final frame, write it as a binary PPM before the
|
||||||
|
swap. The GLFW host writes the framebuffer with `Renderer::dump_frame_ppm()`; the ImGui
|
||||||
|
demo writes the whole framebuffer, while Qt and GTK write their widget's/area's own
|
||||||
|
framebuffer (their toolkits' chrome lives outside it).
|
||||||
|
|
||||||
|
That pair is what lets CI run the GUI demos under `xvfb-run` with no display access and no
|
||||||
|
screen capture (the invocations are in [embedding.md](embedding.md)).
|
||||||
|
|
||||||
|
Three demos add env-gated **self-checking** modes. These are verification affordances for
|
||||||
|
the project's own testing, driven through the real host and its own controllers — they are
|
||||||
|
not a second implementation of anything, and they exit non-zero if a measured property fails:
|
||||||
|
|
||||||
|
| variable | demo | what it drives |
|
||||||
|
|---|---|---|
|
||||||
|
| `PLOT_DEMO_FOLLOW=1` | `plot_glfw_demo` | the live-follow sequence: three wheel notches and a pan on a `XFollow::Live` axis, printing the range width, the distance behind the newest sample and the follow state at each step |
|
||||||
|
| `PLOT_DEMO_REGIONS` (`=hot` to hover a bound) | `plot_glfw_demo` | the direct-manipulation sequence: freeze the view, place a region, pan 100 px with the press outside it, and check the region came along with its width untouched |
|
||||||
|
| `PLOT_DEMO_SYNC=1` | `plot_multiplot_demo` | the synced-follow check: the three rows hold one range and its right edge keeps tracking the newest sample through a zoom and a pan on different rows |
|
||||||
|
| `PLOT_DEMO_CROSSHAIR=<row>` | `plot_multiplot_demo` | parks the pointer in a row each frame so a headless screenshot shows the one horizontal line the hovered row draws |
|
||||||
|
|
||||||
|
(`plot_glfw_demo` also accepts `PLOT_DEMO_CROSSHAIR=1`, which parks the pointer at the centre
|
||||||
|
of the data area so the two-line crosshair has ink to measure.)
|
||||||
|
|
||||||
|
## Coverage
|
||||||
|
|
||||||
|
```sh
|
||||||
|
tools/coverage.sh # from the repository root
|
||||||
|
cmake --build build/cov --target coverage # the same thing, through CMake
|
||||||
|
```
|
||||||
|
|
||||||
|
The script configures `build/cov` from the `linux-coverage` preset, builds it, clears the
|
||||||
|
`.gcda` counters, runs the whole suite, captures `src/**` with `lcov`, renders an HTML report
|
||||||
|
to `build/cov/html/index.html`, prints the per-file table, and **exits non-zero when a floor
|
||||||
|
is violated**. It needs `cmake`, `ninja`, `ctest`, `lcov`, `genhtml` and `python3`, and exits
|
||||||
|
`2` with a message when one is missing.
|
||||||
|
|
||||||
|
The floors are enforced by `tools/coverage_floor.py`:
|
||||||
|
|
||||||
|
| scope | floor |
|
||||||
|
|---|---|
|
||||||
|
| every file under `src/` | **80%** of its instrumented lines |
|
||||||
|
| `src/` as a whole | **90%** of instrumented lines |
|
||||||
|
|
||||||
|
Branch coverage is deliberately off. This page carries no measured totals — a number written
|
||||||
|
here would be stale the moment `src/` moved, and the report is reproducible: the method, the
|
||||||
|
per-file table and the floor verdict live in [docs/coverage.md](coverage.md), which
|
||||||
|
`tools/coverage.sh` rewrites from the very run that measured them. Re-run the command for the
|
||||||
|
live numbers.
|
||||||
|
|
||||||
|
**One thing to know about the coverage preset:** it builds **333 of the 350 cases**. The 17
|
||||||
|
that are missing are the adapter cases that need an adapter target to exist — 11 in
|
||||||
|
`test_imgui_widget.cpp` and 6 in `test_glfw_host.cpp` — and the coverage preset turns the
|
||||||
|
adapters off. The toolkit-free adapter seams still run there: `test_qt_pointer.cpp` (5 cases)
|
||||||
|
and `test_gtk_input.cpp` (11 cases) compile without Qt or GTK. So the coverage number measures
|
||||||
|
the library plus its unit suite, not the adapter bodies (which the headless demo runs cover).
|
||||||
+54
-58
@@ -3,6 +3,13 @@
|
|||||||
`plot` measures line coverage of the compiled library bodies under `src/` and enforces a floor on
|
`plot` measures line coverage of the compiled library bodies under `src/` and enforces a floor on
|
||||||
it, so a regression fails a command instead of quietly lowering a number in a document.
|
it, so a regression fails a command instead of quietly lowering a number in a document.
|
||||||
|
|
||||||
|
**The table and the floor verdict below are generated.** `tools/coverage.sh` rewrites the region
|
||||||
|
between `<!-- BEGIN GENERATED: tools/coverage.sh -->` and
|
||||||
|
`<!-- END GENERATED: tools/coverage.sh -->` from the report that run just captured, by calling
|
||||||
|
`tools/coverage_floor.py --write-doc`. Do not edit inside those markers: the numbers there come
|
||||||
|
from the command, not from a hand-typed copy, and a run on an unchanged tree rewrites the region
|
||||||
|
with identical content.
|
||||||
|
|
||||||
## The one command
|
## The one command
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -12,12 +19,10 @@ cmake --build build/cov --target coverage # the same thing, through CMake
|
|||||||
|
|
||||||
The script configures `build/cov` from the `linux-coverage` preset, builds it, runs the whole
|
The script configures `build/cov` from the `linux-coverage` preset, builds it, runs the whole
|
||||||
`ctest` suite, captures `src/**` with `lcov` into `build/cov/coverage.info`, renders the HTML report
|
`ctest` suite, captures `src/**` with `lcov` into `build/cov/coverage.info`, renders the HTML report
|
||||||
to `build/cov/html/index.html`, prints the per-file table below, and **exits non-zero when a floor
|
to `build/cov/html/index.html`, prints the per-file table, rewrites the generated region of this
|
||||||
is violated**. It needs `cmake`, `ninja`, `ctest`, `lcov`, `genhtml` and `python3`
|
file, and **exits non-zero when a floor is violated**. It needs `cmake`, `ninja`, `ctest`, `lcov`,
|
||||||
(Arch: `pacman -S cmake ninja lcov python`); it exits `2` with a message when one is missing.
|
`genhtml` and `python3` (Arch: `pacman -S cmake ninja lcov python`); it exits `2` with a message
|
||||||
|
when one is missing.
|
||||||
A clean run of it — no build tree, all 26 translation units under `src/` compiled, 274 cases — takes
|
|
||||||
about two minutes here, of which the suite is ~107 s.
|
|
||||||
|
|
||||||
## The floors
|
## The floors
|
||||||
|
|
||||||
@@ -35,45 +40,41 @@ about two minutes here, of which the suite is ~107 s.
|
|||||||
|
|
||||||
## The measured table
|
## The measured table
|
||||||
|
|
||||||
Measured on 2026-09-28 with GCC 16.2.1 from a clean `build/cov`, over the full test suite.
|
<!-- BEGIN GENERATED: tools/coverage.sh -->
|
||||||
`coverage.sh` prints exactly this table on every run.
|
|
||||||
|
```
|
||||||
|
coverage_floor: ok -- 24 files under src/, aggregate 93.9% lines (3730/3971), every file at or above 80%, aggregate target 90%
|
||||||
|
```
|
||||||
|
|
||||||
<!-- BEGIN GENERATED TABLE (tools/coverage_floor.py --table --format markdown) -->
|
|
||||||
| 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` | 214/256 | 83.6% | 100.0% |
|
| `src/data/xy_series.cpp` | 102/120 | 85.0% | 100.0% |
|
||||||
| `src/gl/loader.cpp` | 53/81 | 65.4% | 77.8% |
|
| `src/gl/handle.cpp` | 259/259 | 100.0% | 100.0% |
|
||||||
| `src/input/controller.cpp` | 327/341 | 95.9% | 100.0% |
|
| `src/gl/loader.cpp` | 81/81 | 100.0% | 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` | 213/218 | 97.7% | 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` | 733/753 | 97.3% | 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` | 87/98 | 88.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` | 529/604 | 87.6% | 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` | 49/59 | 83.1% | 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** | **3203/3447** | **92.9%** | **97.8%** |
|
| **src/ aggregate** | **3730/3971** | **93.9%** | **98.0%** |
|
||||||
<!-- END GENERATED TABLE -->
|
|
||||||
|
|
||||||
**The floor currently fails, and that is the point.** `src/gl/loader.cpp` sits at 65.4% lines
|
<!-- END GENERATED: tools/coverage.sh -->
|
||||||
(53/81) — below the 80% per-file floor — so `tools/coverage.sh` exits `1` on today's tree. What is
|
|
||||||
uncovered there is the loader's error paths and its extension scanner; `src/gl/handle.cpp` (83.6%)
|
|
||||||
and `src/render/vertex_array.cpp` (83.1%) are the same kind of gap, just above the line. Those tests
|
|
||||||
are Task 2 of `docs/superpowers/plans/2026-09-28-plot-docs-and-coverage.md`; the floor went in first
|
|
||||||
so the fix has something to satisfy rather than something to negotiate with, and so the *next*
|
|
||||||
regression in that area is a failing command rather than a number nobody reads.
|
|
||||||
|
|
||||||
## What the report measures
|
## What the report measures
|
||||||
|
|
||||||
@@ -118,40 +119,35 @@ before `genhtml` re-creates it.
|
|||||||
per-file `LF`/`LH`/`FNF`/`FNH` tallies that are identical for all 23 files — verified by diffing the
|
per-file `LF`/`LH`/`FNF`/`FNH` tallies that are identical for all 23 files — verified by diffing the
|
||||||
two tracefiles — but the per-line hit *counts* differ by a few parts per million, because a handful
|
two tracefiles — but the per-line hit *counts* differ by a few parts per million, because a handful
|
||||||
of tests drive a producer thread whose iteration count follows wall-clock timing. Everything the
|
of tests drive a producer thread whose iteration count follows wall-clock timing. Everything the
|
||||||
floor reads (the `LF`/`LH` tallies, and therefore every rate in the table above) is unaffected.
|
floor reads (the `LF`/`LH` tallies, and therefore every rate in the generated table above) is
|
||||||
|
unaffected.
|
||||||
|
|
||||||
## Adjusting the floors, and seeing the gate fail
|
## Adjusting the floors, and seeing the gate fail
|
||||||
|
|
||||||
Both floors are flags, which is how the failure path is demonstrated — and how a later task tightens
|
Both floors are flags, which is how the failure path is demonstrated — and how a later task tightens
|
||||||
the contract. Against `build/cov/coverage.info` from the table above:
|
the contract. Against the report in the generated table above, a floor raised above every file's
|
||||||
|
rate fails the check and names each offender:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
$ python3 tools/coverage_floor.py build/cov/coverage.info # the real floors
|
# Any file not at 100% fails: a floor that is guaranteed to bite, whatever the
|
||||||
coverage_floor: FAILED -- 1 floor(s) violated in build/cov/coverage.info:
|
# current rates are. The list of offenders is the files in the table above whose
|
||||||
src/gl/loader.cpp: 65.4% lines (53/81) is below the 80% per-file floor
|
# rate is below 100%.
|
||||||
$ echo $?
|
python3 tools/coverage_floor.py --per-file 100 build/cov/coverage.info
|
||||||
1
|
# coverage_floor: FAILED -- <n> floor(s) violated in build/cov/coverage.info:
|
||||||
|
# <file>: <rate>% lines (<hit>/<found>) is below the 100% per-file floor
|
||||||
|
# ...
|
||||||
|
# $? == 1
|
||||||
|
|
||||||
$ python3 tools/coverage_floor.py --per-file 95 build/cov/coverage.info # floor raised above rates
|
# The same, for the aggregate: a target above the measured aggregate fails with one line.
|
||||||
coverage_floor: FAILED -- 10 floor(s) violated in build/cov/coverage.info:
|
python3 tools/coverage_floor.py --aggregate 99 build/cov/coverage.info
|
||||||
src/gl/handle.cpp: 83.6% lines (214/256) is below the 95% per-file floor
|
# coverage_floor: FAILED -- 1 floor(s) violated in build/cov/coverage.info:
|
||||||
src/gl/loader.cpp: 65.4% lines (53/81) is below the 95% per-file floor
|
# src/ aggregate: <rate>% lines (<hit>/<found>) is below the 99% aggregate floor
|
||||||
src/plot/grid.cpp: 93.8% lines (15/16) is below the 95% per-file floor
|
# $? == 1
|
||||||
src/plot/ticks.cpp: 93.9% lines (93/99) is below the 95% per-file floor
|
|
||||||
src/render/buffer.cpp: 88.8% lines (87/98) is below the 95% per-file floor
|
|
||||||
src/render/primitives.cpp: 94.7% lines (71/75) is below the 95% per-file floor
|
|
||||||
src/render/renderer.cpp: 87.6% lines (529/604) is below the 95% per-file floor
|
|
||||||
src/render/text.cpp: 90.0% lines (27/30) is below the 95% per-file floor
|
|
||||||
src/render/vertex_array.cpp: 83.1% lines (49/59) is below the 95% per-file floor
|
|
||||||
src/text/true_type_font.cpp: 92.8% lines (270/291) is below the 95% per-file floor
|
|
||||||
$ echo $?
|
|
||||||
1
|
|
||||||
|
|
||||||
$ python3 tools/coverage_floor.py --per-file 60 --aggregate 90 build/cov/coverage.info
|
# A floor at or below the measured rates passes.
|
||||||
coverage_floor: ok -- 23 files under src/, aggregate 92.9% lines (3203/3447), every file at or
|
python3 tools/coverage_floor.py build/cov/coverage.info
|
||||||
above 60%, aggregate target 90%
|
# coverage_floor: ok -- ...
|
||||||
$ echo $?
|
# $? == 0
|
||||||
0
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Why the check is a CMake target and not a ctest case
|
## Why the check is a CMake target and not a ctest case
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
# Data and performance
|
||||||
|
|
||||||
|
## The sample stores
|
||||||
|
|
||||||
|
All series derive from `plot::data::Series` (`include/plot/data/series.hpp`), which
|
||||||
|
describes the sample-indexing contract; a **uniform-grid** series additionally has
|
||||||
|
sample `i` at `x = x0() + i * dx()` (`ArraySeries`, `StreamSeries`), while
|
||||||
|
**`XYSeries`** carries its own sorted, ascending x array for data that is not on a
|
||||||
|
uniform grid.
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
class Series {
|
||||||
|
public:
|
||||||
|
virtual u64 size() const;
|
||||||
|
virtual f64 x0() const; // x of sample 0 (the oldest retained sample)
|
||||||
|
virtual f64 dx() const; // 0 for XYSeries, which owns its x array
|
||||||
|
virtual Range x_bounds() const;
|
||||||
|
virtual Range y_bounds() const;
|
||||||
|
virtual const MinMaxPyramid& pyramid() const;
|
||||||
|
virtual bool streaming() const noexcept; // default false
|
||||||
|
virtual 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)
|
||||||
|
f64 sum(u64 begin, u64 end) const; // exact sum over [begin, end)
|
||||||
|
f64 sumsq(u64 begin, u64 end) const; // exact sum of squares
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Indices are the series' **own window**: index 0 is the oldest sample it currently holds, so
|
||||||
|
`size()`, `x0()`, `reduce()`, `gather()` and `pyramid()` are all in the same coordinates.
|
||||||
|
|
||||||
|
**`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
|
||||||
|
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), `commit()`,
|
||||||
|
which rebuilds the pyramid and cached bounds from the current `y`, or `push(y)`, which
|
||||||
|
appends one sample incrementally — the pyramid folds it in amortised O(1) and the bounds
|
||||||
|
extend in place, NaN-aware, instead of rebuilding.
|
||||||
|
|
||||||
|
**`StreamSeries`** (`stream_series.hpp`) is the single-producer / single-consumer streaming
|
||||||
|
store: `StreamSeries(u64 capacity, f64 x0, f64 dx)`. `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
|
||||||
|
|
||||||
|
One producer thread, one consumer thread, and the division is strict
|
||||||
|
(`stream_series.hpp`):
|
||||||
|
|
||||||
|
- **The producer may only call** `push(f32 y)`, `push(const f32* ys, u64 n)` and
|
||||||
|
`produced()`. Those touch the ring buffer and nothing else: **no lock, no pyramid, no GL**.
|
||||||
|
The acquisition thread can therefore never stall the renderer.
|
||||||
|
- **The consumer calls `acquire()`**, which snapshots the produced count and brings the
|
||||||
|
pyramid and bounds up to it, and then queries the published window. `size()`, `reduce()`,
|
||||||
|
`sum()`, `sumsq()`, `gather()` and `pyramid()` all use the window published by the last
|
||||||
|
`acquire()`.
|
||||||
|
|
||||||
|
`acquire()` costs O(samples produced since the last acquire), not O(capacity): the pyramid
|
||||||
|
is indexed in absolute sample space, so a sliding window only drops the leading buckets the
|
||||||
|
ring overwrote and appends what is new; aggregates still in the window are never recomputed.
|
||||||
|
The one exception is a producer that laps the consumer (more than `capacity()` samples
|
||||||
|
between two acquires), where the missing samples cannot be represented and the pyramid
|
||||||
|
restarts at the new base, re-reading the whole window.
|
||||||
|
|
||||||
|
The ring overwrites the oldest samples — that is what keeps a live stream showing the newest
|
||||||
|
data — so it is **not a frozen window**. Those slots are relaxed atomics, so this is not a
|
||||||
|
data race and no value tears; but if the producer pushes `k` samples while a read is in
|
||||||
|
flight, the first `k` positions of that read can hold one of those `k` newer samples rather
|
||||||
|
than the one the snapshot named. Ingest with no reuse underneath a read at all needs the
|
||||||
|
producer to stop before unread data (drop-on-full) or a claim protocol — a different
|
||||||
|
contract from this one.
|
||||||
|
|
||||||
|
`streaming()` returns `true` for `StreamSeries` and `false` for everything else. Only a
|
||||||
|
streaming series has a **live edge** (the newest x it holds, which moves on its own), and it
|
||||||
|
is that property `XFollow::Live` follows (see [interaction.md](interaction.md)).
|
||||||
|
|
||||||
|
## The min/max LOD pyramid
|
||||||
|
|
||||||
|
`data::MinMaxPyramid` (`minmax_pyramid.hpp`) is the index that makes per-frame cost depend on
|
||||||
|
the visible pixels rather than on the sample count.
|
||||||
|
|
||||||
|
- Samples are grouped into buckets of `kBucket = 64` (**`kBucket`** is a fixed constant, so
|
||||||
|
all series share one LOD geometry). Level `l` bucket `k` covers the absolute samples
|
||||||
|
`[k * (kBucket << l), (k + 1) * (kBucket << l))`, and bucket boundaries are fixed for the
|
||||||
|
pyramid's life — appending never re-buckets existing aggregates, and a window can slide
|
||||||
|
over the same buckets.
|
||||||
|
- Each level holds its complete buckets in a `std::deque` (O(1) `push_back` and
|
||||||
|
`pop_front`) plus a `base`. `append()` folds a sample into the in-progress level-0 bucket
|
||||||
|
and, when it completes, pushes it and walks up, stopping at the first level that gains no
|
||||||
|
entry: an append costs **amortised O(1)**. `drop_before(u)` slides the window's leading
|
||||||
|
edge, bounding the memory by the retained window rather than by everything ever appended.
|
||||||
|
- An entry is 32 bytes (`MinMax` plus the exact `sum`/`sumsq` the exact range queries fold
|
||||||
|
over and the finite-sample `count` the mean folds by), so level 0 costs `0.5n` bytes and
|
||||||
|
the geometric tail brings the whole pyramid to about **`1n` bytes — roughly 25% of the
|
||||||
|
`4n` bytes of raw samples** (`f32`). A min/max-only index would be `0.25n` (~6%); the
|
||||||
|
extra words are the price of answering mean/RMS/integral from whole buckets instead of
|
||||||
|
raw samples.
|
||||||
|
|
||||||
|
Queries on the pyramid are **bucket-grained** and named `*_buckets` (`reduce_buckets`,
|
||||||
|
`sum_buckets`, `sumsq_buckets`): a pyramid stores aggregates, so it can only answer exactly
|
||||||
|
for whole buckets, and partial edge buckets are excluded rather than approximated. Exact
|
||||||
|
range queries live on `Series`, which owns the raw samples.
|
||||||
|
|
||||||
|
`level_for(u64 n, f64 samples_per_pixel)` returns the pyramid level whose buckets hold about
|
||||||
|
`samples_per_pixel` samples for a window of `n` samples (clamped to levels that exist, to
|
||||||
|
levels with more than one bucket, and to levels that give the window at least two buckets).
|
||||||
|
|
||||||
|
The renderer uses this: a trace's x range is mapped onto sample indices, and `Series::gather`
|
||||||
|
is asked for that window *at `pyramid().level_for(n, samples_per_pixel)`* whenever a pixel
|
||||||
|
covers `Style::dense_threshold_spp` samples or more (default 1.5). A 100-million-sample
|
||||||
|
series therefore stages O(visible pixels) vertices, not O(samples), and `FrameStats` records
|
||||||
|
which path each trace took. Below the threshold the raw samples are drawn instead; both
|
||||||
|
sides go through the same instanced anti-aliased quad pipeline.
|
||||||
|
|
||||||
|
## Dense traces are reduced onto the pixel grid
|
||||||
|
|
||||||
|
A gathered window denser than the axis is **not** staged as a polyline. For each integer
|
||||||
|
column of `layout.axes`, `Renderer::draw_traces` emits one exactly vertical segment from the
|
||||||
|
lowest to the highest screen y any gathered point put in that column, flushed in framebuffer
|
||||||
|
pixels with `u_snap` on. `FrameStats::column_traces` counts the traces that took this path.
|
||||||
|
|
||||||
|
It exists because a polyline over such a window **moirés**, and three things compound to make
|
||||||
|
it. `MinMaxPyramid::level_for()` picks the level whose buckets hold about
|
||||||
|
`samples_per_pixel` samples, so a bucket spans **one to two pixel columns**;
|
||||||
|
`GatherMode::Envelope` emits two points per bucket, so each bucket contributes a near-vertical
|
||||||
|
min→max hairline *and* a near-vertical connector to the next — about 3.6 near-vertical
|
||||||
|
hairlines per column; and each of those lands at a **fractional** x, so with ordinary
|
||||||
|
src-alpha compositing a column's brightness is a non-linear function of the sub-pixel phases
|
||||||
|
overlapping it. The phase pattern repeats at the beat between the bucket pitch and the pixel
|
||||||
|
pitch, which is a visible banding, and panning sweeps the beat so it crawls. Reducing to one
|
||||||
|
segment per column leaves the trace **one** sub-pixel phase, so there is nothing left to beat,
|
||||||
|
and the snap then puts that one phase on the grid.
|
||||||
|
|
||||||
|
Four properties of the reduction are load-bearing:
|
||||||
|
|
||||||
|
- **It is gated on the gathered *density*, not on the level.** It runs when the window is
|
||||||
|
`GatherMode::Envelope` and carries at least two gathered points per column of the axes —
|
||||||
|
the raw path at 1.5–64 samples per pixel has the same beat and the same cure. The density
|
||||||
|
term is also what keeps it away from a *sparse* window, whose trace is drawn by the two
|
||||||
|
segments that enter and leave the frame (`a_sparse_window_between_samples_still_inks_both_margins`);
|
||||||
|
`Nearest` and `Mean` are excluded because they are one-point-per-bucket modes a band defeats.
|
||||||
|
- **A hole stays a hole.** A column no gathered point landed in emits nothing. Bridging it
|
||||||
|
would draw a vertical line over x the series says nothing about.
|
||||||
|
- **Disjoint columns are joined.** Two neighbouring columns' extremes need not overlap — a
|
||||||
|
fast edge rises through a whole column — and two disjoint vertical segments read as a broken
|
||||||
|
trace, which is what the polyline's connector prevented. The later column is extended to
|
||||||
|
meet the earlier one's nearest bound, so the join is made inside the column that moved.
|
||||||
|
- **A span shorter than the stroke is drawn *as* the stroke.** The line fragment shader ramps
|
||||||
|
on the distance to the segment's axis and does not cap its ends, so a vertical segment
|
||||||
|
shorter than its own width rasterises a sliver of itself, and a flat trace's column, where
|
||||||
|
the two bounds are equal, nothing at all.
|
||||||
|
|
||||||
|
This is the **one carve-out** in `u_snap`'s rule that chrome may be snapped and data may not
|
||||||
|
(see `LineProgram::set_snap()`): a column *is* a pixel column and its x is already a screen
|
||||||
|
coordinate, so there is no data position to move it off, and the y bounds the segment reports
|
||||||
|
are untouched by the snap.
|
||||||
|
|
||||||
|
`a_dense_trace_is_drawn_as_whole_pixel_columns` is the guard, and what it measures is the
|
||||||
|
**number of distinct coverage levels** in the ink: 4 with the reduction against 126 with the
|
||||||
|
polyline, over one sine period across 60 000 samples. A count of *partially covered pixels* is
|
||||||
|
**not** the reading and was tried first — a 1.5 px stroke with a 1 px anti-aliasing fringe
|
||||||
|
always spills into its neighbour column, so the two renderings measure 577 and 652, which
|
||||||
|
separates nothing. The frame's whole geometry collapses with it: 627 segments against 60 054.
|
||||||
|
|
||||||
|
## A reduced column can be graded by dwell
|
||||||
|
|
||||||
|
`Style::intensity_grading` draws each of those columns **graded** rather than as one flat band:
|
||||||
|
bright where the signal sits, faint where it only passes through, the way a digital-phosphor
|
||||||
|
scope reads. It is off by default — a change of look, not a correction of one — and
|
||||||
|
`FrameStats::graded_traces` counts the traces that took it, a subset of `column_traces`, since
|
||||||
|
grading is a way of drawing the reduction and not a third path.
|
||||||
|
|
||||||
|
What it puts back is the information the reduction throws away: a column's min and max are the
|
||||||
|
**same** whether its samples fill the span evenly or pile at its two ends, so a ramp and a pulse
|
||||||
|
are one band. The distribution cannot be recovered from the band, which is why the gather is
|
||||||
|
asked at a level **finer than the pixel grid** (`intensity_sub_columns` sub-buckets per column)
|
||||||
|
and the dwell read from those sub-buckets.
|
||||||
|
|
||||||
|
Five things carry it, and each was arrived at by being wrong first:
|
||||||
|
|
||||||
|
- **The measure is dwell per row, each segment depositing one unit spread over the rows it
|
||||||
|
spans** (`1 / height`). Counting +1 per row instead grades a sine's fast middle as brightly as
|
||||||
|
its rails, which is the pattern inverted — the middle is where the signal spends the *least*
|
||||||
|
time.
|
||||||
|
- **The accumulator is one column wide.** Gathered x is monotonic, so points arrive column by
|
||||||
|
column: accumulate, read, clear. A full `axes.w × axes.h` density image is 33 MB at 4K; one
|
||||||
|
column of `axes.h` floats is L1-resident. A span is written as a `+1/height` delta at its first
|
||||||
|
row and the negation one past its last, so it costs two writes whatever its height, and the
|
||||||
|
running sum over the column is the per-row dwell.
|
||||||
|
- **Normalisation is per column, against the column's own peak.** A frame-wide scale needs the
|
||||||
|
whole image to exist before anything can be drawn, *and* reads worse: a column the signal flew
|
||||||
|
through uniformly comes out faint at every row, so the trace fades out exactly where it moves
|
||||||
|
fastest. The cost of the local scale is one extra non-clearing walk of the accumulator to find
|
||||||
|
the peak.
|
||||||
|
- **Alpha is quantised into `intensity_steps` tiers, one `LineBatch` and one draw call each.**
|
||||||
|
The line pipeline's colour is a **uniform**, not per-instance (`LineInstance` is four floats of
|
||||||
|
geometry; only `TextInstance` carries `a_colour`), so a per-segment alpha is inexpressible and
|
||||||
|
the tiers are what make the gradient drawable at all. `intensity_floor` keeps the faintest tier
|
||||||
|
visible — a fast riser is one sub-bucket deep everywhere and must not be dimmed out of
|
||||||
|
existence — and `intensity_gamma` lifts the faint end. A connector between two disjoint columns
|
||||||
|
is emitted at the **faintest** tier: it is inferred geometry, not a place the signal was
|
||||||
|
measured to dwell.
|
||||||
|
- **Grading may not undo the reduction.** `level_for()` answers 0 — raw samples — as soon as the
|
||||||
|
asked-for fraction of a column falls under one bucket (`kBucket` is 64), so the finer ask is
|
||||||
|
**floored at level 1** whenever the flat path would have used the pyramid. Without the floor
|
||||||
|
every moderately dense frame became an O(visible) gather: measured in udpscope at 1.3 MS/s over
|
||||||
|
a 2 s window, 172 samples a pixel, the flat path sat on the pyramid and grading dropped the
|
||||||
|
whole figure to raw (`raw=1154 lod=0` against `raw=710 lod=453`) and cost 2.3 → 6.4 ms of draw
|
||||||
|
mean. The floor degrades the *look* instead — fewer sub-columns than asked for, down to what the
|
||||||
|
coarsest buckets give, which at 64 samples a bucket is still two or three per column. That is
|
||||||
|
also why raising `intensity_sub_columns` past about 8 buys nothing: the bucket size is the
|
||||||
|
limit, not the number.
|
||||||
|
|
||||||
|
`a_dense_trace_can_be_graded_by_dwell` is the guard, and it draws the same frame both ways. It
|
||||||
|
profiles the mean peak coverage over the band's two outer fifths against its middle fifth: the
|
||||||
|
flat band is flat (**191 and 191**, one ink) and the graded one is **191 against 131**, a middle
|
||||||
|
at 0.69 of the edge. That ratio is a *tier* ratio and not the dwell ratio — the default gamma
|
||||||
|
0.45 and floor 0.3 compress it on purpose. The geometry stays bounded by the axis and not by its
|
||||||
|
area: **8303 segments over a 572 px axis**, 14.5 runs a column, against the flat path's 627 at
|
||||||
|
one end and `572 × 156` rows at the other. A fourth frame over a window wide enough that the
|
||||||
|
flat path is on the pyramid is what exercises the floor (`raw=0 lod=1 … seg=634
|
||||||
|
visible=200001`); the test's first window sits at 105 samples a pixel, where both paths are raw
|
||||||
|
and the floor is never reached.
|
||||||
|
|
||||||
|
## One sample or a statistic of many
|
||||||
|
|
||||||
|
Every number the library reports is one the samples actually hold. What differs between
|
||||||
|
these two is *how many* samples answer the question:
|
||||||
|
|
||||||
|
- **`Series::reduce` / `sum` / `sumsq` are exact over a sample range.** They answer "what
|
||||||
|
are the samples' extremes / sum / sum of squares over `[begin, end)`". Each is
|
||||||
|
implemented once over `copy_raw()`: the bucket-aligned interior comes from the pyramid
|
||||||
|
(O(log n) bucket aggregates) and at most two partial edge buckets are read raw and
|
||||||
|
scanned, so the cost is O(`kBucket`) worst case and **independent of the series length**,
|
||||||
|
and no query allocates. `Measure::evaluate` and `Plot::region_stats` are built on these,
|
||||||
|
never on the `*_buckets` forms, which would silently report wrong extremes for an
|
||||||
|
unaligned interval.
|
||||||
|
- **The crosshair readout is a single sample — the nearest one — by default.**
|
||||||
|
`crosshair_span(series, x)` returns the two samples `x` lies between (`begin`, `end`, `t`),
|
||||||
|
and `CrosshairValue::NearestSample` takes whichever of the two is nearer: `t <= 0.5` the
|
||||||
|
earlier, otherwise the later. It answers "which datum is under the pointer", not "what is
|
||||||
|
the signal *at* this instant" — a linearly interpolated y is a number the series does not
|
||||||
|
contain, and on a source whose samples are already averaged or decimated at ingest it is a
|
||||||
|
claim about a signal nobody measured. That is why it is the default; a host whose x is a
|
||||||
|
quantity it is willing to interpolate states `CrosshairValue::Interpolated` and gets
|
||||||
|
`crosshair_interpolate(y_begin, y_end, t)` instead, at the crosshair's own x. The lookup is
|
||||||
|
O(1) and allocation-free either way, and it reads level 0, so the value is a stored sample
|
||||||
|
(or a blend of two) and never an LOD bucket's aggregate. `Plot::crosshair_point(trace, x,
|
||||||
|
out_x, out_y)` exposes **both** coordinates to a host and `crosshair_value()` the y alone;
|
||||||
|
`CrosshairRow::x`/`y` and its `marker` are placed from that same pair, so in
|
||||||
|
`NearestSample` the mark snaps onto the quoted sample and the dot and the number cannot
|
||||||
|
disagree. A row whose trace has no sample at the crosshair's x shows `kCrosshairNoValue`
|
||||||
|
(an em dash, `"—"`), and an x outside the series window yields no value rather than
|
||||||
|
clamping to the first or last sample.
|
||||||
|
|
||||||
|
`Measurement` (from `Plot::measurement()`) documents `integral` as a **left-Riemann sum**,
|
||||||
|
`sum(begin, end) * dx` — the samples taken as the value at the left edge of each step. That
|
||||||
|
is part of the result's contract, not an implementation detail.
|
||||||
|
|
||||||
|
## Measured frame times
|
||||||
|
|
||||||
|
These are the demos' own frame summaries, taken from a `linux-release` build, **all of them
|
||||||
|
llvmpipe (Mesa software rasteriser) under Xvfb** — no run touched a GPU. Treat them as
|
||||||
|
upper bounds and as a record of the software path, not as hardware numbers.
|
||||||
|
|
||||||
|
| demo | window | mean ms/frame | work |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GLFW (`plot_glfw_demo`) | 1280×720 | 0.42 | one 1 MS/s `StreamSeries` |
|
||||||
|
| ImGui (`plot_imgui_demo`) | 1280×720 | 0.37 | the same stream, embedded |
|
||||||
|
| multiplot (`plot_multiplot_demo`) | 1280×720 | 0.66 | 3 rows, 6 traces, 500 kS/s each |
|
||||||
|
| Qt6 (`plot_qt_demo`) | 820×588 at exit | 3.23 | one stream, `QOpenGLWidget` FBO |
|
||||||
|
| GTK4 (`plot_gtk_demo`) | 810×564 at exit | 2.85 | one stream, `GtkGLArea` FBO |
|
||||||
|
|
||||||
|
The multiplot run stages 18 651 segments for ~4.5 million visible samples, which is the
|
||||||
|
pyramid doing its job: the frame is bounded by the pixels, not the samples. The Qt and GTK
|
||||||
|
figures are dominated by their toolkit's texture-backed FBO under llvmpipe, not by the
|
||||||
|
library.
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
# Embedding into a host
|
||||||
|
|
||||||
|
A host owns the window, the GL context and the event loop; `plot` owns none of them. The
|
||||||
|
contract the renderer offers is small: make a 3.3 core context current, resolve the entry
|
||||||
|
points with `plot::gl::load()`, call `Renderer::init()`, then each frame
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
renderer.style().ui_scale = <device pixels per logical unit>;
|
||||||
|
renderer.begin_frame(fb_w, fb_h, viewport_px); // clears exactly viewport_px
|
||||||
|
renderer.draw(plot); // or renderer.draw(figure)
|
||||||
|
renderer.end_frame(); // restores the GL state it moved
|
||||||
|
```
|
||||||
|
|
||||||
|
and hand the plot (or figure) whatever events the host receives. The `Renderer` never
|
||||||
|
creates, binds or resizes a framebuffer, never reads your event loop, and never sees a
|
||||||
|
pointer or a button.
|
||||||
|
|
||||||
|
Below are the four shipped adapters. Each is a separate opt-in target
|
||||||
|
(`plot::glfw`, `plot::imgui`, `plot::qt6`, `plot::gtk4`) and each reads its own source
|
||||||
|
under `adapters/`.
|
||||||
|
|
||||||
|
## Shared conventions
|
||||||
|
|
||||||
|
**Pixel space.** Every `Rect`, viewport and pointer position `plot` speaks is in
|
||||||
|
**device (framebuffer) pixels, origin top-left**. Every host below reports geometry in
|
||||||
|
*logical* units (window points, widget pixels) and converts before forwarding, so a
|
||||||
|
gesture lands on the pixels the plot was laid out in.
|
||||||
|
|
||||||
|
**DPI / `ui_scale`.** The metrics in `render::Style` (font size, stroke widths, paddings,
|
||||||
|
tick spacing) are **logical** units; `Style::ui_scale` is device pixels per logical unit,
|
||||||
|
and the renderer converts each metric through `Style::device()` exactly once. The host
|
||||||
|
writes `ui_scale` from its own display, so a 12 px label is the same physical size on a 1x
|
||||||
|
and a 2x panel. The one metric that is *not* scaled is `dense_threshold_spp`, a
|
||||||
|
samples-per-pixel density (see [styling.md](styling.md)). How each host supplies the
|
||||||
|
scale:
|
||||||
|
|
||||||
|
| host | value written to `ui_scale` | source |
|
||||||
|
|---|---|---|
|
||||||
|
| GLFW | `fb_scale_x` = framebuffer width / window width | `glfwGetFramebufferSize()` vs `glfwGetWindowSize()`, re-read on resize |
|
||||||
|
| ImGui | `io.DisplayFramebufferScale.x` | filled by the platform backend |
|
||||||
|
| Qt6 | `devicePixelRatioF()` | re-read every `paintGL()` |
|
||||||
|
| GTK4 | `mapping_.factor` = device size / logical allocation | the two sizes `::resize` reports, not `gtk_widget_get_scale_factor()` |
|
||||||
|
|
||||||
|
**Keys, key repeat, timestamps and touch parity.** The controller's keys and gestures
|
||||||
|
arrive through the adapters translated from each toolkit's own events, and four
|
||||||
|
conventions hold across all four hosts:
|
||||||
|
|
||||||
|
* **The key set is the same everywhere**: `+`/`-` zoom (with the `=`/`_` spellings a
|
||||||
|
shifted layout produces), `f`/`F` re-fits, the four arrows pan (10% of the span per
|
||||||
|
press), and Escape cancels the active drag. Each host translates its native key codes
|
||||||
|
to `input::kKey*` before calling the controller.
|
||||||
|
* **A held key repeats in every adapter.** GLFW, Qt and GTK deliver the OS auto-repeat
|
||||||
|
as extra press events and forward each one. ImGui edge-detects a press exactly once,
|
||||||
|
so its widget asks `IsKeyPressed(..., repeat = true)` for every key it maps and
|
||||||
|
ImGui's own repeat timer supplies what the OS would have.
|
||||||
|
* **`PointerEvent::time_s` is the host's monotonic clock, in seconds.** Only
|
||||||
|
differences are read: the double-click (two presses of the same button within 0.35 s
|
||||||
|
and 4 px re-fits every axis) compares two stamps, never an epoch. A host that leaves
|
||||||
|
the field 0 never produces a double-click, by contract.
|
||||||
|
|
||||||
|
| host | key repeat | `time_s` source | touch / pinch |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GLFW | the OS auto-repeat, forwarded as repeated presses | `glfwGetTime()` | none — GLFW has no touch input |
|
||||||
|
| ImGui | `IsKeyPressed(..., repeat = true)` on every mapped key | `ImGui::GetTime()` | none — no touch input to forward |
|
||||||
|
| Qt6 | Qt repeats a held key as press events | `QInputEvent::timestamp()` / 1000 | `Qt::PinchGesture` → `GestureEvent`, folded by `PinchTracker` (`qt_pointer.hpp`) |
|
||||||
|
| GTK4 | GTK repeats a held key as press events | the GDK event's time / 1000 | `GtkGestureZoom` → `GestureEvent`, folded by `ZoomAnchor` (`gtk_input.hpp`) |
|
||||||
|
|
||||||
|
The two pinch folds are the same fold in two namespaces, and both live in
|
||||||
|
toolkit-free headers precisely so the headless tests can drive them: a toolkit
|
||||||
|
reports a pinch as **cumulative** values (total scale since the gesture began, the
|
||||||
|
focal point now), while `GestureEvent` wants the increments (this frame's zoom factor
|
||||||
|
about the focal point, 1.0 = no change, and the focal point's movement as `dx`/`dy`).
|
||||||
|
The folds also keep their focal anchor in *logical* pixels, like every other adapter
|
||||||
|
anchor, so a window dragged onto a display with another scale factor mid-gesture
|
||||||
|
cannot read the ratio change as a phantom pan.
|
||||||
|
|
||||||
|
**DPR / display changes.** The scale each host states to the renderer is re-read
|
||||||
|
whenever it can change, not latched: GLFW re-derives it in `refresh_sizes()` (every
|
||||||
|
frame, and from the resize callback), ImGui every frame from
|
||||||
|
`io.DisplayFramebufferScale`, Qt every `paintGL()` **and** on
|
||||||
|
`QEvent::DevicePixelRatioChange` — which also refreshes the controller's viewport
|
||||||
|
before any event queued behind the change maps a gesture through the old geometry —
|
||||||
|
and GTK in `::resize` through `DeviceMapping`. An input coordinate is always scaled by
|
||||||
|
the ratio in force at that event, never by one cached at startup.
|
||||||
|
|
||||||
|
**Headless verification.** Every demo supports `PLOT_DEMO_EXIT_AFTER_MS=<ms>` (stop the
|
||||||
|
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
|
||||||
|
and no screen capture. The invocations it uses (each demo, from the repository root, all
|
||||||
|
five exit `0`):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
PLOT_DEMO_EXIT_AFTER_MS=4000 PLOT_DEMO_SCREENSHOT=/tmp/glfw.ppm \
|
||||||
|
xvfb-run -a ./build/release/examples/plot_glfw_demo
|
||||||
|
PLOT_DEMO_EXIT_AFTER_MS=4000 PLOT_DEMO_SCREENSHOT=/tmp/multiplot.ppm \
|
||||||
|
xvfb-run -a ./build/release/examples/plot_multiplot_demo
|
||||||
|
PLOT_DEMO_EXIT_AFTER_MS=4000 PLOT_DEMO_SCREENSHOT=/tmp/imgui.ppm \
|
||||||
|
xvfb-run -a ./build/release/examples/plot_imgui_demo
|
||||||
|
PLOT_DEMO_EXIT_AFTER_MS=4000 PLOT_DEMO_SCREENSHOT=/tmp/qt.ppm \
|
||||||
|
QT_QPA_PLATFORM=xcb xvfb-run -a -s "-screen 0 1920x1200x24" \
|
||||||
|
./build/release/examples/plot_qt_demo
|
||||||
|
PLOT_DEMO_EXIT_AFTER_MS=4000 PLOT_DEMO_SCREENSHOT=/tmp/gtk.ppm \
|
||||||
|
GDK_BACKEND=x11 xvfb-run -a -s "-screen 0 1920x1200x24" \
|
||||||
|
./build/release/examples/plot_gtk_demo
|
||||||
|
```
|
||||||
|
|
||||||
|
The `-s "-screen 0 1920x1200x24"` shape matters for Qt and GTK, whose demos script a
|
||||||
|
resize before the exit frame; a bare `xvfb-run` screen (640×480) clamps the window and
|
||||||
|
that self-check reports a false failure.
|
||||||
|
|
||||||
|
## GLFW — `plot::glfw`
|
||||||
|
|
||||||
|
`#include <glfw/glfw_host.hpp>`, link `plot::glfw` (which links `plot::core`). This is the
|
||||||
|
reference host: `Host` owns the GLFW window and its 3.3 core context, the GL entry points,
|
||||||
|
the `Renderer`, the `Plot`, an `input::Controller` and the frame loop.
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
plot::glfw::Host host;
|
||||||
|
plot::Plot& p = host.plot(); // populate axes/traces here
|
||||||
|
plot::Status st;
|
||||||
|
if (!host.run({1280, 720, "plot"}, [](plot::Plot& p, plot::f64 dt) { /* feed data */ }, &st)) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
What `run()` does, in order:
|
||||||
|
|
||||||
|
1. `glfwInit()`, then window hints for OpenGL **3.3 core** (plus forward compatibility) and
|
||||||
|
`glfwCreateWindow()`, `glfwMakeContextCurrent()`, `glfwSwapInterval(vsync)`.
|
||||||
|
2. `plot::gl::load(&resolve_gl)`, where `resolve_gl` wraps `glfwGetProcAddress` (a function
|
||||||
|
pointer respelled as `void*` by bit copy).
|
||||||
|
3. `Renderer::init()`.
|
||||||
|
4. Per frame: `glfwPollEvents()` → `refresh_sizes()` →
|
||||||
|
`controller.set_viewport(framebuffer rect)` → the host's `frame` callback →
|
||||||
|
`begin_frame(fb_width, fb_height, viewport)` → `draw(plot)` (or `draw(figure)`) →
|
||||||
|
`[screenshot]` → `end_frame()` → `glfwSwapBuffers()`.
|
||||||
|
|
||||||
|
Pointer translation: `on_cursor_pos()` multiplies the GLFW window coordinates by
|
||||||
|
`fb_scale_x_`/`fb_scale_y_` (framebuffer ÷ window) and synthesises `dx`/`dy` itself (GLFW
|
||||||
|
reports no delta); `down` comes from `glfwGetMouseButton`, the modifier mask is read
|
||||||
|
from the key state, because GLFW's pointer and scroll callbacks carry no mask, and
|
||||||
|
`time_s` is `glfwGetTime()`. Wheel notches are forwarded as `WheelEvent` unchanged
|
||||||
|
(zero deltas included — the controller owns that case), and keys as `KeyEvent`: the
|
||||||
|
arrows pan, Escape cancels the active drag, and neither closes the window (the window
|
||||||
|
manager's close button and Alt+F4 are the way to quit).
|
||||||
|
|
||||||
|
Figure mode: `host.set_figure(&figure)` makes the loop lay the figure over the framebuffer,
|
||||||
|
create **one `Controller` per row**, route each pointer/wheel/key event to the row it lands
|
||||||
|
in (`controller_at(x, y)`), and draw it with `Renderer::draw(Figure&)`. The host also spreads
|
||||||
|
one hover over every row (`sync_figure_crosshair`), since only the host knows which row the
|
||||||
|
pointer is in.
|
||||||
|
|
||||||
|
Routing has two rules worth knowing from the outside:
|
||||||
|
|
||||||
|
* **One gesture at a time (button-aware capture).** A press captures the row it landed
|
||||||
|
in *and the button that pressed*, and every motion and the release of *that button* go
|
||||||
|
to the captured row until it comes up — so a drag that crosses into a neighbouring row
|
||||||
|
still ends where it began. A second button pressed meanwhile (a chord) neither steals
|
||||||
|
the capture to another row nor ends it; only the owning button's release ends it. What
|
||||||
|
the second button's events do inside the controller is the controller's business; the
|
||||||
|
host guarantees they all arrive at the row the first press claimed.
|
||||||
|
* **Row rects are cached, refreshed once per frame.** The events never call
|
||||||
|
`Figure::layout()` — a non-const `layout()` recomputes, and a burst of pointer moves
|
||||||
|
would re-lay every row out per move. The run loop calls `refresh_row_rects()` once per
|
||||||
|
frame (right after the figure's viewport is set), `set_figure()` fills the cache
|
||||||
|
immediately so a scripted run routes against the figure it was just handed, and every
|
||||||
|
handler reads the cache. The consequence across a resize: GLFW delivers the resize and
|
||||||
|
any events queued behind it in one batch, so events before the resize see the rows the
|
||||||
|
previous frame drew — at most one frame stale — and the loop's next refresh brings the
|
||||||
|
cache onto the new geometry. A driver that injects events without running the loop
|
||||||
|
calls `refresh_row_rects()` itself; it is the scripted equivalent of the frame.
|
||||||
|
|
||||||
|
`run()` leaves the window open on success so a producer thread can be joined before the GL
|
||||||
|
resources go away; `close()` (or the destructor) releases the renderer, context and window
|
||||||
|
on the calling thread. A run with `PLOT_DEMO_EXIT_AFTER_MS` set creates the window hidden.
|
||||||
|
|
||||||
|
## Dear ImGui — `plot::imgui`
|
||||||
|
|
||||||
|
`#include <imgui/imgui_plot.hpp>`, link `plot::imgui`. Build-tree only: ImGui is supplied
|
||||||
|
by `FetchContent`, so `find_package(plot)` does not export this target.
|
||||||
|
|
||||||
|
`PlotWidget::draw(id, plot, size, renderer)` must be called between `ImGui::NewFrame()` and
|
||||||
|
`ImGui::Render()`, from inside a window. It:
|
||||||
|
|
||||||
|
1. opens a `BeginChild` region of the requested size (no scrollbar) and forces
|
||||||
|
`ImGuiCol_ChildBg` transparent, so the widget never paints over its own plot;
|
||||||
|
2. intersects the child's rectangle with ImGui's clip rect, so a child the host cuts off
|
||||||
|
does not paint over what is beside it;
|
||||||
|
3. covers the visible region with an `InvisibleButton` and reads `IsItemHovered()`; this is
|
||||||
|
the hover gate (false while another widget is being dragged) and the item that
|
||||||
|
`SetItemKeyOwner(ImGuiKey_MouseWheelY)` claims the wheel with, so the plot's zoom wins
|
||||||
|
over window scrolling;
|
||||||
|
4. maps the child's point rectangle to framebuffer pixels through
|
||||||
|
`io.DisplayFramebufferScale`, gives the controller that region, and renders immediately
|
||||||
|
(`begin_frame`/`draw`/`end_frame`) into it.
|
||||||
|
|
||||||
|
Because ImGui reports button *levels* and the controller wants *edges*, the widget
|
||||||
|
remembers the previous frame's buttons and synthesises `pressed`/`released`; a button
|
||||||
|
pressed over the child keeps being delivered until every button is up, so a drag that
|
||||||
|
leaves the region still ends. ImGui's `(-FLT_MAX, -FLT_MAX)` "no pointer" sentinel is
|
||||||
|
treated as no position update (no movement, buttons still flow). Every pointer event is
|
||||||
|
stamped with `ImGui::GetTime()`, which is what makes the controller's double-click
|
||||||
|
(re-fit) exist on this host; and every key the widget maps is asked with
|
||||||
|
`IsKeyPressed(..., repeat = true)`, because ImGui does not auto-repeat on its own —
|
||||||
|
the shared "a held key repeats" policy is opt-in here (see the conventions above).
|
||||||
|
|
||||||
|
Coordinate assumption: the points→pixels mapping is the whole framebuffer's only when the
|
||||||
|
host's GL viewport is the whole framebuffer at origin `(0,0)` and the bound target is the
|
||||||
|
default framebuffer. A host that renders into an FBO or a sub-viewport must place the plot
|
||||||
|
itself with `Renderer::begin_frame()` and `plot.set_viewport()`.
|
||||||
|
|
||||||
|
## Qt6 — `plot::qt6`
|
||||||
|
|
||||||
|
`#include <qt6/qt_plot_widget.hpp>`, link `plot::qt6`. `PlotWidget` is a `QOpenGLWidget`,
|
||||||
|
so it draws into the FBO Qt binds before `paintGL()`, and `QOpenGLWidget` needs no target
|
||||||
|
of its own.
|
||||||
|
|
||||||
|
**Precondition, set before the widget exists** (a `QOpenGLWidget` is handed the
|
||||||
|
application default `QSurfaceFormat` and cannot renegotiate):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
QSurfaceFormat fmt;
|
||||||
|
fmt.setVersion(3, 3);
|
||||||
|
fmt.setProfile(QSurfaceFormat::CoreProfile);
|
||||||
|
fmt.setSwapBehavior(QSurfaceFormat::DoubleBuffer);
|
||||||
|
fmt.setDepthBufferSize(0);
|
||||||
|
fmt.setStencilBufferSize(0);
|
||||||
|
QSurfaceFormat::setDefaultFormat(fmt);
|
||||||
|
QCoreApplication::setAttribute(Qt::AA_UseDesktopOpenGL); // before QApplication
|
||||||
|
```
|
||||||
|
|
||||||
|
Lifecycle: `initializeGL()` calls `initializeOpenGLFunctions()`, `plot::gl::load()`,
|
||||||
|
`Renderer::init()` and records the outcome in `init_status()` (a `QOpenGLWidget` virtual
|
||||||
|
cannot return a `Status`; it does not assert). `resizeGL()` refreshes the viewport so a
|
||||||
|
gesture arriving before the next paint maps against the new geometry. `paintGL()` sets the
|
||||||
|
viewport on the controller, runs the frame callback, sets `ui_scale` to
|
||||||
|
`devicePixelRatioF()`, then `begin_frame`/`draw`/`end_frame`, and records frame timings
|
||||||
|
(`frames_painted()`, `last_frame_ms()`, `mean_frame_ms()`).
|
||||||
|
|
||||||
|
Input translation: Qt reports press/release edges directly, so `mousePressEvent` /
|
||||||
|
`mouseMoveEvent` / `mouseReleaseEvent` / `mouseDoubleClickEvent` / `wheelEvent` /
|
||||||
|
`keyPressEvent` become `PointerEvent`/`WheelEvent`/`KeyEvent`, each stamped with
|
||||||
|
`QInputEvent::timestamp()` in seconds. Every position is multiplied by
|
||||||
|
`devicePixelRatioF()`; the *anchor* a move's delta is measured from is kept in logical
|
||||||
|
pixels, so a drag crossing onto a display with another scale factor pans by the distance
|
||||||
|
moved rather than a cross-scale jump (`PointerAnchor`, `qt_pointer.hpp`). The wheel is
|
||||||
|
converted from eighths of a degree, 120 to a notch; when a device reports
|
||||||
|
`pixelDelta()` instead (and the angle deltas are both zero), 40 scroll pixels are taken
|
||||||
|
to be one notch — a calibration, not a conversion, the same role the GTK adapter's
|
||||||
|
`kSurfacePixelsPerNotch` plays. A wheel event with neither a vertical nor a horizontal
|
||||||
|
component is refused before it reaches the controller; anything with one is forwarded,
|
||||||
|
and the controller pans x by the horizontal component. The widget also grabs
|
||||||
|
`Qt::PinchGesture`: each pinch update is folded by `PinchTracker` (`qt_pointer.hpp`)
|
||||||
|
into an incremental `GestureEvent` — this frame's zoom about the focal point, the focal
|
||||||
|
point's movement as the pan — and a `QEvent::DevicePixelRatioChange` refreshes the
|
||||||
|
controller's viewport (and, one frame later at the latest, the `ui_scale`) before any
|
||||||
|
event queued behind it.
|
||||||
|
|
||||||
|
Include-order note: this header includes `plot/gl/api.hpp` before `<QOpenGLWidget>`,
|
||||||
|
because Qt reaches `<GL/gl.h>` whose macros would rewrite `plot::gl`'s enumerators. A
|
||||||
|
translation unit using the widget must keep that order and must not name a `plot::gl::GL_*`
|
||||||
|
enumerator after a Qt GL header.
|
||||||
|
|
||||||
|
## GTK4 — `plot::gtk4`
|
||||||
|
|
||||||
|
`#include <gtk4/gtk_plot_area.hpp>`, link `plot::gtk4`. GTK cannot have a GObject subclass
|
||||||
|
declared around a C++ class (and `GtkGLArea`'s data is private), so this adapter is a
|
||||||
|
factory, not a subclass:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
plot::gtk4::PlotArea* area = plot::gtk4::PlotArea::create(plot);
|
||||||
|
GtkWidget* widget = area->widget(); // put it in your container
|
||||||
|
```
|
||||||
|
|
||||||
|
`create()` builds a `gtk_gl_area_new()`, registers the signals and controllers that drive
|
||||||
|
it, and returns the wrapper — which GTK owns and frees with the widget; never delete it.
|
||||||
|
|
||||||
|
Signals: `::realize` makes the context current, checks `gtk_gl_area_get_error()`, loads the
|
||||||
|
entry points and initialises the renderer (recording the outcome in `init_status()`);
|
||||||
|
`::unrealize` releases the renderer's GL objects while the context can still be made
|
||||||
|
current; `::resize` derives the device-pixel mapping from the widget's logical allocation
|
||||||
|
and the device size GTK reports, and refreshes the viewport; `::render` sets the viewport on
|
||||||
|
the controller, runs the frame callback, sets `ui_scale` to the mapping's factor, and draws.
|
||||||
|
|
||||||
|
Input translation uses GTK's gesture recognisers: `GtkGestureDrag` supplies motion,
|
||||||
|
`GtkGestureClick` supplies the press/release edges (stamped with the GDK event's time,
|
||||||
|
in seconds, like every pointer event this adapter forwards), `GtkGestureZoom` supplies
|
||||||
|
the two-finger pinch — each `scale-changed` folded by `ZoomAnchor` (`gtk_input.hpp`)
|
||||||
|
into an incremental `GestureEvent`: this frame's zoom about the fingers' bounding-box
|
||||||
|
centre, the centre's movement as the pan — `GtkEventControllerMotion` supplies
|
||||||
|
hover (forwarded with `down == false`, so a drag is not applied twice), and
|
||||||
|
`GtkEventControllerScroll` (vertical) and `GtkEventControllerKey` supply the wheel and
|
||||||
|
the keys (`+`/`-`/`f`, the arrows, Escape — the full shared key set, mapped in
|
||||||
|
`gtk_input.hpp`'s `key_from()` where the headless tests drive it). GTK emits
|
||||||
|
`drag-begin` before the click's `pressed`, so the adapter opens
|
||||||
|
its gesture from the first press and closes it from the first release. The area queues a
|
||||||
|
render after every input event that can change the picture (not on hover).
|
||||||
|
|
||||||
|
A `GtkGLArea` renders into its own FBO, so a screenshot of the *plot* is a readback of that
|
||||||
|
FBO, not of the window (GTK's chrome lives outside it) — see the demo's `dump_area_ppm`.
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
# Getting started
|
||||||
|
|
||||||
|
`plot` is a C++20 library for real-time, high-sample-rate time-series plots drawn with
|
||||||
|
OpenGL 3.3 core. The core library — the CMake target `plot::core` — has no third-party
|
||||||
|
dependency at all; the host adapters for GLFW, Dear ImGui, Qt6 and GTK4 are separate,
|
||||||
|
opt-in targets on top of it (see [embedding.md](embedding.md)).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- A C++20 compiler.
|
||||||
|
- CMake **3.24 or newer**. `cmake/plot_options.cmake` fails the configure with a
|
||||||
|
`FATAL_ERROR` below that, and the presets in `CMakePresets.json` declare
|
||||||
|
`cmakeMinimumRequired` 3.24.
|
||||||
|
- A driver exposing an **OpenGL 3.3 core** profile. `plot::gl::load()` resolves only the
|
||||||
|
3.3 entry points the library actually calls; anything newer (persistent buffer mapping,
|
||||||
|
sync objects) is optional, probed through `plot::gl::caps()` and only used when the
|
||||||
|
driver offers it.
|
||||||
|
- To build the **test suite** you also need EGL: `tests/CMakeLists.txt` fails the configure
|
||||||
|
without it, because the GL tests create a real headless EGL context. The library itself
|
||||||
|
does not need EGL.
|
||||||
|
- Each adapter needs its own toolkit (GLFW, Dear ImGui downloaded at configure time, Qt6 or
|
||||||
|
GTK4) — see [building-and-testing.md](building-and-testing.md) for the options.
|
||||||
|
|
||||||
|
## Build and install
|
||||||
|
|
||||||
|
The presets live in `CMakePresets.json`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake --preset linux-release # or linux-debug, linux-asan, linux-coverage
|
||||||
|
cmake --build build/release
|
||||||
|
cmake --install build/release --prefix /where/you/want
|
||||||
|
```
|
||||||
|
|
||||||
|
Install is on by default (`PLOT_INSTALL`, `ON`). It installs the `plot_core` archive, the
|
||||||
|
public headers under `include/plot/`, and a CMake package config under
|
||||||
|
`lib/cmake/plot/`. Adapters join the install/export set only when their toolkit can be
|
||||||
|
re-found on a consumer's machine; the configure log records which ones were exported and
|
||||||
|
why.
|
||||||
|
|
||||||
|
## Consuming it
|
||||||
|
|
||||||
|
```cmake
|
||||||
|
find_package(plot 0.1 REQUIRED)
|
||||||
|
target_link_libraries(my_app PRIVATE plot::core)
|
||||||
|
```
|
||||||
|
|
||||||
|
`plot::core` is the alias of the `plot_core` target (`EXPORT_NAME core`, so the installed
|
||||||
|
name matches the build-tree name). It carries the C++20 requirement and the include
|
||||||
|
directory as its public interface and nothing else. The exported adapters are named
|
||||||
|
`plot::glfw`, `plot::qt6`, `plot::gtk4` and (when built) `plot::font_ttf`;
|
||||||
|
`plot::imgui` is a build-tree target only, because ImGui is supplied by `FetchContent` and
|
||||||
|
there is no ImGui package for an installed config file to point at.
|
||||||
|
|
||||||
|
The version you ask for follows the package's own compatibility rule, **SameMinorVersion**:
|
||||||
|
`find_package(plot 0.1 REQUIRED)` accepts 0.1.x and refuses 0.2+, because at 0.x the minor
|
||||||
|
component is what moves when the surface breaks. What the surface *is* — the version this
|
||||||
|
header set was generated from — is in `<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:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
#include <plot/plot.hpp> // core, math, gl, data, render, plot, input — in layer order
|
||||||
|
```
|
||||||
|
|
||||||
|
Adapters are deliberately **not** in the umbrella: their headers are reached as
|
||||||
|
`<glfw/glfw_host.hpp>`, `<imgui/imgui_plot.hpp>`, `<qt6/qt_plot_widget.hpp>` and
|
||||||
|
`<gtk4/gtk_plot_area.hpp>`, so including the umbrella never drags a toolkit in.
|
||||||
|
|
||||||
|
## A minimal GLFW program
|
||||||
|
|
||||||
|
The program below opens a window, creates a 3.3 core context, loads the GL entry points,
|
||||||
|
and draws one trace. It uses the GLFW toolkit for the window and `plot::core` directly —
|
||||||
|
it does not use the shipped `plot::glfw::Host` adapter, so it also shows the pieces the
|
||||||
|
adapter hides (window, context, loader, renderer, controller, frame loop).
|
||||||
|
|
||||||
|
It was compiled and linked as part of writing this page (`-I build/debug/include` is where
|
||||||
|
CMake generates `plot/version.hpp`, which the umbrella pulls in):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// A minimal plot: one window, one axis pair, one trace.
|
||||||
|
#include <plot/plot.hpp> // the whole public surface, in layer order
|
||||||
|
#include <plot/gl/loader.hpp>
|
||||||
|
|
||||||
|
#define GLFW_INCLUDE_NONE // keep GLFW from including <GL/gl.h> and its macros
|
||||||
|
#include <GLFW/glfw3.h>
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// GLFW hands back `void (*)(void)`; plot::gl::ProcLoader wants `void*`.
|
||||||
|
void* resolve_gl(const char* name) {
|
||||||
|
GLFWglproc p = glfwGetProcAddress(name);
|
||||||
|
void* out = nullptr;
|
||||||
|
static_assert(sizeof(out) == sizeof(p));
|
||||||
|
std::memcpy(&out, &p, sizeof(out));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
if (glfwInit() != GLFW_TRUE) return 1;
|
||||||
|
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
|
||||||
|
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
|
||||||
|
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
|
||||||
|
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE);
|
||||||
|
GLFWwindow* window = glfwCreateWindow(800, 480, "plot quick-start", nullptr, nullptr);
|
||||||
|
if (window == nullptr) { glfwTerminate(); return 1; }
|
||||||
|
glfwMakeContextCurrent(window);
|
||||||
|
glfwSwapInterval(1);
|
||||||
|
|
||||||
|
plot::Status st;
|
||||||
|
if (!plot::gl::load(&resolve_gl)) { std::fprintf(stderr, "gl::load failed\n"); return 1; }
|
||||||
|
plot::Renderer renderer;
|
||||||
|
if (!renderer.init(&st)) { std::fprintf(stderr, "init failed: %s\n", st.message); return 1; }
|
||||||
|
|
||||||
|
// One trace: 512 samples of a sine, x = sample index.
|
||||||
|
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);
|
||||||
|
|
||||||
|
plot::Plot plot;
|
||||||
|
const plot::u32 x_axis = plot.add_x_axis({"x", {0.0, 511.0}, true});
|
||||||
|
const plot::u32 y_axis =
|
||||||
|
plot.add_y_axis({"signal", {-1.0, 1.0}, true, plot::AxisPlace::Left});
|
||||||
|
plot.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, x_axis, y_axis, "sine"});
|
||||||
|
|
||||||
|
plot::input::Controller controller(plot);
|
||||||
|
|
||||||
|
while (glfwWindowShouldClose(window) == GLFW_FALSE) {
|
||||||
|
glfwPollEvents();
|
||||||
|
int fb_w = 0, fb_h = 0;
|
||||||
|
glfwGetFramebufferSize(window, &fb_w, &fb_h);
|
||||||
|
if (fb_w == 0 || fb_h == 0) continue;
|
||||||
|
const plot::Rect viewport{0.0f, 0.0f, static_cast<plot::f32>(fb_w),
|
||||||
|
static_cast<plot::f32>(fb_h)};
|
||||||
|
controller.set_viewport(viewport);
|
||||||
|
renderer.style().ui_scale = 1.0f; // device pixels per logical unit
|
||||||
|
renderer.begin_frame(static_cast<plot::u32>(fb_w), static_cast<plot::u32>(fb_h), viewport);
|
||||||
|
renderer.draw(plot);
|
||||||
|
renderer.end_frame();
|
||||||
|
glfwSwapBuffers(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
glfwDestroyWindow(window);
|
||||||
|
glfwTerminate();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Things worth noticing, because they are the embedding contract in miniature:
|
||||||
|
|
||||||
|
- `plot::gl::load()` must run on the thread that owns the current context, and
|
||||||
|
`Renderer::init()` returns `false` with a `Status` when the load failed or the driver
|
||||||
|
refused a shader/buffer. The renderer never creates, binds or resizes a framebuffer.
|
||||||
|
- `begin_frame(fb_w, fb_h, viewport_px)` takes the framebuffer size (for the projection)
|
||||||
|
and the top-left-origin rectangle the plot occupies; `draw()` fills exactly that region;
|
||||||
|
`end_frame()` restores the GL state it changed.
|
||||||
|
- The `Plot` is pure CPU state. When you draw it through a `Renderer`, set colours and
|
||||||
|
metrics on `renderer.style()`, not on the plot — `draw()` copies the style into the plot
|
||||||
|
and hands it the `ui_scale` before laying out.
|
||||||
|
- `ui_scale` is device pixels per logical unit. Here it is `1.0` because the program uses
|
||||||
|
the framebuffer 1:1; a HiDPI host passes the framebuffer/window ratio instead (see
|
||||||
|
[embedding.md](embedding.md)).
|
||||||
|
|
||||||
|
Next: [architecture.md](architecture.md) for how the pieces fit, [embedding.md](embedding.md)
|
||||||
|
to put a plot into a real host, and [data-and-performance.md](data-and-performance.md) for
|
||||||
|
feeding it a live stream.
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
# Interaction
|
||||||
|
|
||||||
|
Everything in this page is driven by `input::Controller` (`include/plot/input/controller.hpp`,
|
||||||
|
`src/input/controller.cpp`), which turns host events into gestures knowing nothing about Qt,
|
||||||
|
GTK, GLFW or ImGui. It is pure CPU and needs no GL context and no font — everything it reads
|
||||||
|
comes from the `Plot`'s layout, ranges and transforms. It owns no viewport of its own:
|
||||||
|
`Controller::set_viewport(Rect)` forwards to `Plot::set_viewport()` and refreshes the layout,
|
||||||
|
so the two cannot disagree.
|
||||||
|
|
||||||
|
The plot's own state — cursors, regions, the measurement, the legend, the crosshair — is
|
||||||
|
owned by `Plot`; the controller only moves it and publishes what is hot.
|
||||||
|
|
||||||
|
## Cursors
|
||||||
|
|
||||||
|
A cursor is a draggable marker at a data coordinate (`Cursor`, `plot/cursor.hpp`). There are
|
||||||
|
two orientations, one type:
|
||||||
|
|
||||||
|
| orientation | created by | live coordinate | live snap | dead fields |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `CursorOrientation::Vertical` | `Plot::add_cursor(f64 x, Color)` | `x` on `axis_x` | `SnapMode` | `y`, `h_snap`, `axis_y` |
|
||||||
|
| `CursorOrientation::Horizontal` | `Plot::add_h_ruler(f64 y, Color)` | `y` on `axis_y` | `HRulerSnap` | `x`, `snap`, `axis_x` |
|
||||||
|
|
||||||
|
A vertical cursor is a marker at `x` spanning the axes' **height**; a horizontal ruler is at
|
||||||
|
`y` spanning their **width**. Dead fields are *ignored*, not honoured: `set_cursor_y()` on a
|
||||||
|
vertical cursor and `set_cursor_x()` on a ruler change nothing, and neither does the other
|
||||||
|
orientation's snap setter.
|
||||||
|
|
||||||
|
Snaps. `SnapMode::NearestSample` pulls a marker onto the nearest sample of the first visible
|
||||||
|
trace on its x axis; `SnapMode::Free` leaves it. `HRulerSnap::NearestTick` pulls a ruler onto
|
||||||
|
the nearest tick of its y axis' tick set (the *last* `layout()`'s set — ticks are generated
|
||||||
|
there and nowhere else); `HRulerSnap::Free` leaves it.
|
||||||
|
|
||||||
|
Grab radius. A pointer within **7 logical pixels** (`kCursorHitRadiusLogical`) of a cursor's
|
||||||
|
line grabs it. The nearest of a vertical and a horizontal cursor wins; neither orientation
|
||||||
|
widens the other's reach. The distance is logical, converted through `Style::device()` at the
|
||||||
|
plot's scale, so the aim is the same physical distance on any display.
|
||||||
|
|
||||||
|
Snap on drop. `set_cursor_x()`/`set_cursor_y()` take a `CursorPlacement`: `Snapped` (the
|
||||||
|
default) applies the cursor's snap rule immediately — the programmatic path; `Free` stores
|
||||||
|
the value verbatim and leaves the snap to `Plot::snap_cursor()`. The controller uses `Free`
|
||||||
|
while dragging, so the marker tracks the pointer under the hand and the snap is committed
|
||||||
|
once, on release. A dragged ruler is additionally clamped to its own y axis' range, because a
|
||||||
|
pointer past the top or bottom of the axes has no data under it.
|
||||||
|
|
||||||
|
## Regions
|
||||||
|
|
||||||
|
A region is a shaded x interval with exact statistics (`Region`, `plot/region.hpp`). Create
|
||||||
|
with `Plot::add_region(const Region&)`; `Region::snap` is `RegionSnap::NearestSample` or
|
||||||
|
`Free`. The bounds are **ordered**: `set_region(id, x0, x1)` stores min/max, so a drag that
|
||||||
|
crosses one bound over the other **swaps** them rather than inverting the region. A pair of
|
||||||
|
bounds that is not both finite is refused (nothing changes).
|
||||||
|
|
||||||
|
Three grab zones (`RegionGrab`): `Left` (the bound at `x0`), `Right` (the bound at `x1`) and
|
||||||
|
`Body` (the interior). The controller resolves them nearest-first against the geometry
|
||||||
|
`Plot::layout()` measured — **a bound within the grab radius beats the body**, and the nearer
|
||||||
|
of the two bounds wins; with no bound in range, the topmost region whose interior holds the
|
||||||
|
pointer is the body. That geometry is the same `RegionLayout` the renderer paints, so what is
|
||||||
|
drawn and what can be grabbed cannot drift apart.
|
||||||
|
|
||||||
|
The bound band is the same 7-logical-pixel radius, cut back so a narrow region keeps a
|
||||||
|
middle a body drag can reach (`min(radius, (width − 2)/2)`, with a minimum interior of
|
||||||
|
**2 logical pixels**, `kMinRegionBodyLogical`). A region narrower than **3 logical pixels**
|
||||||
|
(`kMinRegionEdgeGrabLogical`) is grabbed by its body instead, widened to that same minimum —
|
||||||
|
otherwise its two bounds would be one target and it could never be moved again. `Body` drag
|
||||||
|
translates both bounds by one delta, preserving the width exactly; a bound drag moves one end
|
||||||
|
and holds the other. A snapping region is put on the sample grid once, on release
|
||||||
|
(`snap_region()`), not on every motion. `remove_region(id)` drops the region and shifts the
|
||||||
|
ids above it down.
|
||||||
|
|
||||||
|
## The measurement band
|
||||||
|
|
||||||
|
`Plot::add_measure(cursor_a, cursor_b, trace = 0)` measures the interval between two cursors
|
||||||
|
over the series of `trace`. The renderer draws the shaded band the readout belongs to from
|
||||||
|
`Plot::measure_span(id)` (which reports the cursor pair), and the statistics come from
|
||||||
|
`Plot::measurement(id)`: `dx`, `dy`, `slope`, `mean`, `rms`, `ymin`, `ymax` and the
|
||||||
|
left-Riemann `integral`. The band's edges *are* those cursors, so it is resized by dragging
|
||||||
|
either of them. A measurement is re-evaluated only when a cursor moved, when it was added, or
|
||||||
|
when the series underneath changed — never once per frame; `const` because recomputing fills
|
||||||
|
a cache.
|
||||||
|
|
||||||
|
## The legend
|
||||||
|
|
||||||
|
The legend is on by default (`Legend::visible = true`) and free until a trace is named: with
|
||||||
|
no named visible trace — or no text backend to measure a name with — `Plot::layout()` produces
|
||||||
|
an empty layout and nothing is drawn. A trace earns an entry by being given a `name`
|
||||||
|
(`TraceDesc::name`/`Trace::name`). Configure it with `Plot::set_legend(const Legend&)`;
|
||||||
|
`Legend::place` anchors the panel to a corner of the axes (`TopLeft`, `TopRight`,
|
||||||
|
`BottomLeft`, `BottomRight`) and `Legend::margin` is both the anchor's inset and the budget
|
||||||
|
the panel has to fit in, so a panel that does not fit drops entries and ends with `...` rather
|
||||||
|
than overhanging. See [styling.md](styling.md) for the knobs and their defaults.
|
||||||
|
|
||||||
|
## The crosshair
|
||||||
|
|
||||||
|
The crosshair is a **hover** instrument, not a cursor: it follows the pointer and exists only
|
||||||
|
while the pointer is over the data area (`Crosshair`, `plot/crosshair.hpp`). There is at most
|
||||||
|
one per plot.
|
||||||
|
|
||||||
|
- `Plot::set_crosshair(f64 x, bool active)` places a **vertical-only** crosshair: the column
|
||||||
|
of `x`, a marker on every visible trace at that x, and the readout panel beside it. This is
|
||||||
|
the form for a caller that has a coordinate and not a pointer.
|
||||||
|
- `Plot::set_crosshair(f64 x, f64 y, bool active)` adds the **horizontal** line at `y`. This is
|
||||||
|
the form the controller uses, because a pointer over the axes has both coordinates.
|
||||||
|
- `Plot::clear_crosshair()` hides it, leaving it where it was. The controller calls this when
|
||||||
|
the pointer leaves the axes; a press, wheel or key that moves the data under the pointer
|
||||||
|
*re-anchors* the crosshair rather than clearing it.
|
||||||
|
- `set_crosshair_readout(bool)` switches the panel off — or back on — without moving the
|
||||||
|
crosshair. `set_crosshair_style(const CrosshairStyle&)` overrides this plot's stroke and
|
||||||
|
dash.
|
||||||
|
- `set_crosshair_value_mode(CrosshairValue)` chooses what a readout *is* — see below.
|
||||||
|
|
||||||
|
In a `Figure`, the crosshair is applied to **every row** (one shared `x`): each row's readout
|
||||||
|
lists its own traces at that instant. `Figure::set_crosshair(x, active)` is vertical-only
|
||||||
|
across the figure, and `Figure::set_crosshair(x, hovered_row, y_in_row, active)` adds the
|
||||||
|
horizontal line **only in the row the pointer is in** — a figure's rows share x and not y, so
|
||||||
|
a figure-wide horizontal line would be a value that means something different in each row.
|
||||||
|
|
||||||
|
### What a readout is: the two value modes
|
||||||
|
|
||||||
|
`Crosshair::value_mode` (`set_crosshair_value_mode()`) answers two different questions, and
|
||||||
|
which one a caller is asking decides the mode:
|
||||||
|
|
||||||
|
| mode | the value | the marker | answers |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `CrosshairValue::NearestSample` (default) | the nearer bracketing sample's y (a tie reads the earlier) | **snapped to that sample's own x** | *which datum is under the pointer?* |
|
||||||
|
| `CrosshairValue::Interpolated` | a linear blend of the two bracketing samples | at the crosshair's own x | *what is the signal at this instant?* |
|
||||||
|
|
||||||
|
The default is the sample, because a readout that quotes a number the data does not contain is
|
||||||
|
a second estimate laid over the first — and once the view is zoomed past the sample spacing the
|
||||||
|
polyline between two samples is a line the *renderer* drew, so reading a value off it reports
|
||||||
|
the drawing rather than the signal. A host whose x is a continuous quantity it is willing to
|
||||||
|
interpolate (a smooth analytic curve, a resampled series) states `Interpolated` and gets the
|
||||||
|
blend, exactly at the pointer. Either way the value is a **stored** sample or a blend of two of
|
||||||
|
them, never an LOD bucket's aggregate (see
|
||||||
|
[data-and-performance.md](data-and-performance.md)).
|
||||||
|
|
||||||
|
`CrosshairRow` carries both halves: `x` and `y` are the data coordinate the value belongs to —
|
||||||
|
the quoted sample's own in `NearestSample`, the crosshair's x in `Interpolated` — and `marker`
|
||||||
|
is that point in screen space. `Plot::crosshair_point(trace, x, out_x, out_y)` is the same
|
||||||
|
lookup for a host that draws instruments of its own; `Plot::crosshair_value()` is it with the x
|
||||||
|
discarded.
|
||||||
|
|
||||||
|
The mark is drawn `Style::crosshair_marker_px` across (9 logical px) at
|
||||||
|
`crosshair_marker_width`, both deliberately **larger than a trace's own sample dots**
|
||||||
|
(`marker_px`, 3): in `NearestSample` the mark lands *among* those dots, and at the same size it
|
||||||
|
would read as one more sample instead of as the one the panel is quoting. `0` across draws no
|
||||||
|
mark.
|
||||||
|
|
||||||
|
A row whose trace has no sample at the crosshair's x is listed with the em dash `"—"` and no
|
||||||
|
marker, rather than a fabricated value.
|
||||||
|
|
||||||
|
## The gesture table (exactly as implemented)
|
||||||
|
|
||||||
|
A press opens the first of these that applies, in this order (from `Controller::begin_drag`
|
||||||
|
and `Controller::begin_handle_drag`):
|
||||||
|
|
||||||
|
| gesture | drag mode | effect |
|
||||||
|
|---|---|---|
|
||||||
|
| **shift + left drag** | `BoxZoom` | both ranges from the selection rect, applied on release |
|
||||||
|
| **middle drag on the x label strip** | `AxisZoomX` | x-only zoom |
|
||||||
|
| **middle drag on the y label strip** | `AxisZoomY` | y-only zoom |
|
||||||
|
| middle drag anywhere else | — | nothing |
|
||||||
|
| **left or right drag on a handle** | `MoveCursor` / `MoveRegion` / `ResizeRegionLeft` / `ResizeRegionRight` | move the grabbed handle |
|
||||||
|
| **left or right drag elsewhere** | `Pan` | translate both ranges |
|
||||||
|
| **wheel** | — | zoom about the pointer (shift: x only, ctrl: y only; neither or both: both axes) |
|
||||||
|
| **`+` / `-`** | — | zoom about the plot centre |
|
||||||
|
| **`f`** (or `F`) | — | `auto_fit_x()` / `auto_fit_y()` every axis |
|
||||||
|
|
||||||
|
Precedence for a press that is not shift+left and not middle: **cursor marker → region bound
|
||||||
|
→ region body → pan**. Shift is checked first of all, so a selection box is what shift+left
|
||||||
|
means even when it starts over a handle. The right button **pans and grabs** exactly as the
|
||||||
|
left does — both are buttons a host drives drags with, so neither is singled out for handles.
|
||||||
|
`Ctrl` is not needed for a cursor move and does not change what a press on one does.
|
||||||
|
|
||||||
|
Values behind the gestures:
|
||||||
|
|
||||||
|
- one wheel notch (or one `+`/`-` press) changes the span by `exp(-0.15)` (~14%,
|
||||||
|
`kZoomPerNotch`); positive = zoom in;
|
||||||
|
- a middle-drag axis zoom is one decade per **200 logical pixels**
|
||||||
|
(`kZoomPerPixel = ln10 / 200`), with the wheel's sign convention (dragging down zooms in);
|
||||||
|
- a box-zoom selection under **4 logical pixels** on either side (`kMinBoxLogical`) is a
|
||||||
|
click and leaves the ranges alone.
|
||||||
|
|
||||||
|
Events are sanitised before they act: a non-finite coordinate or delta becomes the last known
|
||||||
|
position and no movement, but the buttons still flow, so a drag whose release arrives with a
|
||||||
|
garbage position still ends. A wheel amount that is not a number is refused outright (a
|
||||||
|
zeroed notch would still run the range setters, which clear auto scale). A gesture that sets
|
||||||
|
a range also clears that axis' `auto_scale` — one place, in `Plot`'s range setters.
|
||||||
|
|
||||||
|
What the pointer is on is published to the plot (`Plot::set_hot_handles()`) after every event
|
||||||
|
that can move the geometry under the pointer, so the renderer draws hovered/grab highlights
|
||||||
|
without knowing what a pointer is. `hovered_cursor()`, `grabbed_cursor()`, `hovered_region()`,
|
||||||
|
`grabbed_region()` and `region_grab(u32)` expose the same answers to a host (a resize cursor, a
|
||||||
|
tooltip, a status line).
|
||||||
|
|
||||||
|
## Live follow
|
||||||
|
|
||||||
|
An x axis has a follow state, `XFollow::Off` (the default) or `XFollow::Live`
|
||||||
|
(`Plot::set_x_follow(axis, f)`). `Live` makes the visible window a view from the live edge:
|
||||||
|
every `layout()` places it at `[newest − offset − span, newest − offset]`, where `newest` is
|
||||||
|
the newest x any visible streaming trace bound to the axis holds (`Plot::x_live_edge()`).
|
||||||
|
`Off` means the range is exactly what a caller set or an auto-fit produced.
|
||||||
|
|
||||||
|
**`set_x_range()` on a live axis is not an absolute window.** A range handed to
|
||||||
|
`Plot::set_x_range(axis, r)` while the axis is `Live` *and* has a live edge is read as the
|
||||||
|
**span** the window should have and the **offset** it implies — `offset = newest − r.max`,
|
||||||
|
clamped to what the traces still retain — and the visible window follows from those at the
|
||||||
|
live edge of the moment. `XFollow::Off` stores the range verbatim. The point of the
|
||||||
|
reinterpretation is that every gesture (pan, wheel, box zoom, axis zoom) reaches the x range
|
||||||
|
through this one setter, so they all keep following for free.
|
||||||
|
|
||||||
|
The offset is clamped to `[0, retained span]` (`Plot::set_x_offset()`, read back with
|
||||||
|
`Plot::x_offset()`): the upper limit is what the traces still hold, so the window's right
|
||||||
|
edge never goes behind the oldest retained sample (and the view is left showing the dropped
|
||||||
|
data rather than inventing samples that are gone); the lower limit is why dragging toward the
|
||||||
|
live edge stops instead of turning the window inside out. A non-finite offset is refused. The
|
||||||
|
clamp is applied again by `layout()` every frame.
|
||||||
|
|
||||||
|
Consequences while an axis is `Live` *and* has a live edge:
|
||||||
|
|
||||||
|
- `auto_scale` is **inert** on that axis: `layout()` does not fit it, because a fit would
|
||||||
|
overwrite the span (the user's zoom) on every frame. The follow state wins; `auto_fit_x()`
|
||||||
|
is how such an axis is framed (it fits, sets the span to the retained window's, and returns
|
||||||
|
the offset to 0 without turning following off).
|
||||||
|
- Nothing but `set_x_follow()` disarms following. No gesture and no `set_x_range()` reaches
|
||||||
|
the follow state.
|
||||||
|
- **Zoom is relative (multiplicative)**: the wheel multiplies the span, so notches compound,
|
||||||
|
and the anchor is kept still by adjusting the offset. **Pan** shifts the offset by the
|
||||||
|
drag's data delta and leaves the span alone, so a user can walk back through the retained
|
||||||
|
window and stops at the live edge.
|
||||||
|
|
||||||
|
Arming `Live` on an axis with a live edge places its window at the live edge at once, from the
|
||||||
|
span it already had. Turning `Live` off leaves the axis where it is: the last live window
|
||||||
|
becomes an ordinary stored range.
|
||||||
|
|
||||||
|
**Figures.** In a `Figure`, the follow state belongs to the link **group**, not the row: a
|
||||||
|
group pushes one window to many rows, so the state has to be the group's or the same range
|
||||||
|
would mean a different window in each member. Use `Figure::add_x_link()`, `link_x(row, group)`,
|
||||||
|
`set_x_link_range(group, r)` and `set_x_link_follow(group, XFollow::Live)`; a group derives
|
||||||
|
its window once per frame from the newest x across all its members' traces. `link_x()` refuses
|
||||||
|
a member whose own axis is `Live`.
|
||||||
+188
@@ -0,0 +1,188 @@
|
|||||||
|
# Styling
|
||||||
|
|
||||||
|
Every colour and metric the renderer draws with lives in `render::Style`
|
||||||
|
(`include/plot/render/style.hpp`). A caller sets fields on `Renderer::style()`; the next frame
|
||||||
|
picks them up, because nothing here is baked into a shader.
|
||||||
|
|
||||||
|
Set colours and metrics on the **renderer**, not on the plot: `Renderer::draw()` copies its
|
||||||
|
`Style` into the plot and hands it the `ui_scale` *before* it lays out, so the strips, tick
|
||||||
|
densities and strokes the plot measures with are the values the renderer draws with. A plot
|
||||||
|
drawn without a renderer lays out with whatever `Plot::set_style()` was given.
|
||||||
|
|
||||||
|
## The unit rule: logical versus device
|
||||||
|
|
||||||
|
A metric a user perceives as a **size** — a font, a stroke width, a padding, a marker, a
|
||||||
|
swatch, the minimum spacing between ticks — is written in **logical** units, so a 12 px label
|
||||||
|
is the same physical size on a 1x and a 2x panel. The host states its display with `ui_scale`
|
||||||
|
(device pixels per logical unit); every consumer converts exactly once, through `device()`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
f32 device(f32 logical) const noexcept {
|
||||||
|
return ui_scale > 0.0f ? logical * ui_scale : logical; // non-positive scale converts 1:1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Plot::layout()` uses it to size the label strips, title strips and legend chrome; the renderer
|
||||||
|
uses it to convert widths and font px (the font is reset at `device(axis_font_px)`).
|
||||||
|
|
||||||
|
The one exception is `dense_threshold_spp`, which is a **density** (samples per *rasterised*
|
||||||
|
pixel), compared against a ratio computed from the framebuffer's own width. Scaling it would
|
||||||
|
make the chosen LOD level depend on the display rather than on the data, so it is used raw.
|
||||||
|
|
||||||
|
## `Style` fields
|
||||||
|
|
||||||
|
Colours (neither logical nor device — nothing to scale):
|
||||||
|
|
||||||
|
| field | default | meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `background` | `0x101418` | frame clear colour and legend/readout panel base |
|
||||||
|
| `grid_major` | `0x39414C` | major (labelled-tick) grid lines |
|
||||||
|
| `grid_minor` | `0x1C2126` at 50% alpha | minor (subdivision) grid lines |
|
||||||
|
| `axis_line` | `0x8A94A6` | axis lines |
|
||||||
|
| `axis_text` | `0xC8D0DC` | tick labels and axis titles |
|
||||||
|
| `cursor` | `0xFFB020` | cursor markers; also the hovered/grab highlight |
|
||||||
|
| `measure_fill` | `0xFFB020` at α 0.15 | the measurement band |
|
||||||
|
| `crosshair` | `0xE6EBF2` | crosshair lines and the readout panel's border (hue-neutral by design; trace markers keep each trace's own colour) |
|
||||||
|
|
||||||
|
The scale:
|
||||||
|
|
||||||
|
| field | default | unit |
|
||||||
|
|---|---|---|
|
||||||
|
| `ui_scale` | `1.0` | device pixels per logical unit; supplied by the host |
|
||||||
|
|
||||||
|
Logical metrics (scaled by `device()`):
|
||||||
|
|
||||||
|
| field | default | meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `axis_font_px` | `12.0` | logical em the axis text is drawn at |
|
||||||
|
| `grid_label_pad` | `4.0` | gap between a tick label and the axes, and the inset inside a title strip |
|
||||||
|
| `marker_below_samples` | `50` | a dot per raw sample when the window holds at most this many (`0` = never) |
|
||||||
|
| `marker_px` | `3.0` | logical size of that dot |
|
||||||
|
| `grid_major_width` | `1.0` | width of a major grid line |
|
||||||
|
| `grid_minor_width` | `0.75` | width of a minor grid line (sub-pixel on purpose) |
|
||||||
|
| `grid_minor_dash_on` | `3.0` | minor grid dash: ink length in logical pixels |
|
||||||
|
| `grid_minor_dash_off` | `3.0` | minor grid dash: gap; `0` draws the minor grid solid |
|
||||||
|
| `grid_major_visible` | `true` | stage the major grid at all |
|
||||||
|
| `grid_minor_visible` | `true` | stage the minor grid at all |
|
||||||
|
| `line_width_px` | `1.5` | width of the strokes framing the data (axis lines, cursor markers) |
|
||||||
|
| `handle_width_px` | `5.0` | width of a highlighted draggable handle (a hovered/grabbed cursor tab, a region's hot bound) |
|
||||||
|
| `tick_min_px` | `60.0` | minimum spacing between major ticks |
|
||||||
|
|
||||||
|
Crosshair stroke and dash (logical):
|
||||||
|
|
||||||
|
| field | default | meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `crosshair_width` | `0.75` | the crosshair's stroke width (deliberately thinner than a trace's `1.5`) |
|
||||||
|
| `crosshair_dash_on` | `4.0` | ink length of the dash |
|
||||||
|
| `crosshair_dash_off` | `4.0` | gap length of the dash; **`0` means solid** |
|
||||||
|
|
||||||
|
Device metric (never scaled):
|
||||||
|
|
||||||
|
| field | default | meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `dense_threshold_spp` | `1.5` | samples per device pixel at or above which a trace is gathered from the LOD pyramid instead of from raw samples |
|
||||||
|
|
||||||
|
## The grid
|
||||||
|
|
||||||
|
The grid's **positions** come from the tick set alone: `Grid::build()` emits one `GridLine`
|
||||||
|
per tick, spanning the perpendicular axis' full range, tagged `major` (a labelled tick) or
|
||||||
|
not. `Style` decides only how each class is drawn — its width and visibility — and the two
|
||||||
|
colours above.
|
||||||
|
|
||||||
|
A class switched off stages **no geometry at all**: the renderer skips it before the first
|
||||||
|
segment is staged, so a dense plot's minor grid costs nothing rather than being drawn
|
||||||
|
transparent. `Grid` therefore carries both classes' geometry whatever the style says; a
|
||||||
|
caller that draws the grid itself applies the same two switches. `Grid::vertical` and
|
||||||
|
`Grid::horizontal` (both `true`) control which directions emit lines at all.
|
||||||
|
|
||||||
|
The labelled class is the **structure** and the subdivisions are **texture**, and the minor
|
||||||
|
grid's three defaults are what makes that read: it is thinner than the major hairline
|
||||||
|
(`grid_minor_width`, a sub-pixel width that at 1x is expressed as coverage and on a HiDPI
|
||||||
|
display as a narrower line), its colour carries an **alpha** so it blends into the
|
||||||
|
background instead of sitting on it, and it is **dashed** (`grid_minor_dash_on`/`_off`) so an
|
||||||
|
unlabelled line at an unlabelled position is never mistaken for a labelled one. Together
|
||||||
|
those put a minor line at an order of magnitude less ink than a major one — at full opacity and the
|
||||||
|
same width a dense minor grid reads as a second set of ticks. The dash is a fragment-shader
|
||||||
|
discard, not extra geometry: the vertices are the line either way. Set
|
||||||
|
`grid_minor_dash_off = 0` for the old solid subdivision.
|
||||||
|
|
||||||
|
## A cursor's stroke
|
||||||
|
|
||||||
|
`set_cursor_color()` gives a cursor its own colour and `set_cursor_stroke()` its own width and
|
||||||
|
dash (`Cursor::width`, `Cursor::dash`). Per cursor rather than per plot, because a host uses
|
||||||
|
cursors for two different jobs: the instruments that frame a reading — a marker at an edge, a pull
|
||||||
|
handle — and the *readout marks* a computation places on the data (a measured level, a peak, a
|
||||||
|
pulse's edges). Those want different weights. `width <= 0` means the style's default for that
|
||||||
|
cursor (`handle_width_px` while it is hovered or grabbed, `line_width_px` otherwise), and a hover
|
||||||
|
can only make the stroke *thicker*: the cue that the pointer has hold of something must not be the
|
||||||
|
thing that hides it. `dash.off <= 0` (the default) is solid — the same convention as a trace's,
|
||||||
|
the crosshair's and the grid's.
|
||||||
|
|
||||||
|
## The crosshair's dash
|
||||||
|
|
||||||
|
`crosshair_dash_on`/`crosshair_dash_off` are the fragment shader's discard pattern, so no
|
||||||
|
geometry is added. `dash_off == 0` draws a solid line — the value every other line in the
|
||||||
|
library uses, so the one pipeline serves them unchanged. A single plot may override all three
|
||||||
|
knobs without touching the global `Style` through `CrosshairStyle` (below).
|
||||||
|
|
||||||
|
## `Legend`
|
||||||
|
|
||||||
|
`Legend` (`include/plot/legend.hpp`), set with `Plot::set_legend()`. All sizes are **logical**,
|
||||||
|
converted through `Style::device()` when `Plot::layout()` measures the panel.
|
||||||
|
|
||||||
|
| field | default | meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `visible` | `true` | draw the legend at all (free when nothing is named) |
|
||||||
|
| `place` | `LegendPlace::TopRight` | corner of `axes` the panel is anchored to |
|
||||||
|
| `frame` | `true` | paint the panel (background at partial alpha plus a 1-px border) |
|
||||||
|
| `pad` | `6.0` | inset from the panel edge to its first row, and the swatch↔name gutter (one knob for both) |
|
||||||
|
| `swatch_w` | `14.0` | swatch width |
|
||||||
|
| `swatch_h` | `8.0` | swatch height; the row height is `max(swatch_h, line height)` |
|
||||||
|
| `row_gap` | `3.0` | blank space between rows |
|
||||||
|
| `margin` | `8.0` | inset from the axes edge, *and* the budget the panel must fit in (so it drops entries rather than overhanging) |
|
||||||
|
|
||||||
|
There is no "outside the plot" placement: the strips around `axes` belong to the tick labels
|
||||||
|
and axis titles, and a legend that reserved space would resize the plot the moment a trace was
|
||||||
|
named. A truncated legend ends with the constant `kLegendEllipsis` (`"..."`).
|
||||||
|
|
||||||
|
## `CrosshairStyle`
|
||||||
|
|
||||||
|
A per-`Crosshair` override of the three crosshair knobs, in logical pixels
|
||||||
|
(`include/plot/crosshair.hpp`), set with `Plot::set_crosshair_style()`. Every field defaults to
|
||||||
|
`-1.0`, which means "not overridden": the field falls back to the matching `Style::crosshair_*`
|
||||||
|
metric, so a default-constructed `CrosshairStyle` is exactly the global style.
|
||||||
|
|
||||||
|
| field | default | meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `width` | `-1.0` | logical stroke width; `< 0` takes `Style::crosshair_width` |
|
||||||
|
| `dash_on` | `-1.0` | logical ink length; `< 0` takes `Style::crosshair_dash_on` |
|
||||||
|
| `dash_off` | `-1.0` | logical gap; `< 0` takes `Style::crosshair_dash_off`, and `0` (once resolved) means solid |
|
||||||
|
|
||||||
|
## Traces
|
||||||
|
|
||||||
|
A trace's own `width` (`TraceDesc::width`, `Trace::width`) is **logical** too — a stroke like
|
||||||
|
`line_width_px`, converted by the same `device()` call. Its default colour is `0x4FA3FF` and
|
||||||
|
its default width `1.5`. Region fill/edge colours are the region's own (`Region::fill`
|
||||||
|
`0xFFB020` at α 0.15, `Region::edge` `0xFFB020`); a cursor's colour is per-cursor
|
||||||
|
(`0xFFB020` by default).
|
||||||
|
|
||||||
|
## Installing a text backend
|
||||||
|
|
||||||
|
The font a plot measures with and the renderer draws with is a `render::TextBackend`
|
||||||
|
(`include/plot/render/text.hpp`): `measure()`, `height()`, `ascent()`, `em_px()`, `glyph()`,
|
||||||
|
`cell_uv()`, `atlas_pixels()`, `atlas_width()`, `atlas_height()` and `revision()`.
|
||||||
|
|
||||||
|
- `Plot::set_text_backend(const render::TextBackend*)` sets the backend a plot lays its labels
|
||||||
|
out with. It is **borrowed**, not owned, and may be null (then the label areas fall back to
|
||||||
|
`PlotLayout`'s defaults and no string is measured).
|
||||||
|
- `Renderer::set_text_backend(const render::TextBackend*)` sets the face the renderer draws
|
||||||
|
with. It is also borrowed and must outlive the frames.
|
||||||
|
|
||||||
|
**Note the interaction:** `Renderer::init()` binds `render::EmbeddedMonoFont`, and every
|
||||||
|
`Renderer::draw()` installs that binding on the plot it is given — replacing whatever a host
|
||||||
|
set with `Plot::set_text_backend()` (a debug build names the one it dropped). So installing a
|
||||||
|
backend on the *plot* only takes effect for a plot no renderer draws. To draw with your own
|
||||||
|
face (say a `plot::text::TrueTypeFont` from the optional `plot::font_ttf` target), install it
|
||||||
|
with `Renderer::set_text_backend()`; the swap takes effect on the next frame, which re-reads
|
||||||
|
the backend's metrics at `style().device(axis_font_px)` and re-uploads its atlas when the
|
||||||
|
backend's `revision()` moved. Passing `nullptr` restores the embedded face.
|
||||||
@@ -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).
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
// The reference application: the whole library running as a real program, fed by
|
// The reference application: the whole library running as a real program, fed by
|
||||||
// a 1 MS/s producer thread through the GLFW adapter.
|
// a 1 MS/s producer thread through the GLFW adapter.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
// The GTK4 application: the same 1 MS/s stream the other demos draw, as the
|
// The GTK4 application: the same 1 MS/s stream the other demos draw, as the
|
||||||
// content of a GtkApplicationWindow whose header bar carries its own "Fit"
|
// content of a GtkApplicationWindow whose header bar carries its own "Fit"
|
||||||
// button, with GTK drawing the window chrome around a plot the area renders into
|
// button, with GTK drawing the window chrome around a plot the area renders into
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
// The Dear ImGui application: the same stream the GLFW demo draws, embedded as a
|
// The Dear ImGui application: the same stream the GLFW demo draws, embedded as a
|
||||||
// widget inside an ImGui window that also owns a few controls, with ImGui drawing
|
// widget inside an ImGui window that also owns a few controls, with ImGui drawing
|
||||||
// its own chrome into the same framebuffer.
|
// its own chrome into the same framebuffer.
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
// The multi-plot application: three plots stacked in one `Figure`, sharing one x
|
// The multi-plot application: three plots stacked in one `Figure`, sharing one x
|
||||||
// axis, each carrying its own signals and its own legend.
|
// axis, each carrying its own signals and its own legend.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
// The Qt application: the same 1 MS/s stream the other demos draw, as the central
|
// The Qt application: the same 1 MS/s stream the other demos draw, as the central
|
||||||
// widget of a QMainWindow that also carries its own toolbar, with Qt drawing the
|
// widget of a QMainWindow that also carries its own toolbar, with Qt drawing the
|
||||||
// window chrome around a plot the widget renders into its own framebuffer.
|
// window chrome around a plot the widget renders into its own framebuffer.
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <plot/core/types.hpp>
|
#include <plot/core/types.hpp>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <plot/core/types.hpp>
|
#include <plot/core/types.hpp>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <span>
|
#include <span>
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <plot/core/types.hpp>
|
#include <plot/core/types.hpp>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -19,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;
|
||||||
@@ -26,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, f64 x_origin, 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;
|
||||||
@@ -38,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
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#ifdef PLOT_PYRAMID_COUNT_COVERS
|
#ifdef PLOT_PYRAMID_COUNT_COVERS
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#ifdef PLOT_PYRAMID_COUNT_MERGES
|
#ifdef PLOT_PYRAMID_COUNT_MERGES
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <deque>
|
#include <deque>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -16,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
|
||||||
@@ -34,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
|
||||||
@@ -61,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.
|
||||||
@@ -97,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
|
||||||
@@ -116,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 {
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <bit>
|
#include <bit>
|
||||||
@@ -9,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.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -6,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
|
||||||
@@ -40,21 +57,60 @@ 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 `x_origin`**: the emitted value is `x_at(i) - x_origin` as f32, never
|
||||||
/// envelope pair per *complete* level bucket wholly inside the retained window,
|
/// the absolute coordinate. A consumer reconstructs the absolute x in f64
|
||||||
/// bounded by the two partial buckets at the edges of the requested range — the
|
/// (`x_origin + emitted`) before mapping it to the screen; staging an absolute epoch-time x
|
||||||
/// bucket the window starts inside and the bucket the newest samples are in —
|
/// as f32 would quantise it to a grid coarser than the pixel (f32 carries 24 bits -- 1 s at
|
||||||
/// which it emits as raw samples through copy_raw(). A bucket that is only partly
|
/// 1.7e9). y is unchanged.
|
||||||
/// retained holds aggregates of samples the window no longer has and a partial
|
|
||||||
/// trailing bucket is not stored at all, so neither may be drawn as an envelope.
|
|
||||||
///
|
///
|
||||||
/// The emitted geometry therefore always spans [begin, end) exactly, for at most
|
/// **The caller names the origin because only the caller knows the span the f32 has to be
|
||||||
/// two buckets of extra raw work (about two pixels at the level level_for()
|
/// exact against.** The origin was `x0()` -- the start of the series' *retained window* --
|
||||||
/// picks), and never reaches outside the retained window. Pairs are appended to
|
/// and a relative value's ULP is then a fraction of the whole window however little of it is
|
||||||
/// out_xy, which is never cleared.
|
/// on screen: 1.9 us over 20 s of retained data, which at a view 10 us wide is more than a
|
||||||
virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const = 0;
|
/// hundred pixels between where a sample is *drawn* and the x a readout quotes for the same
|
||||||
|
/// sample (`x_at()`, exact f64). Passing the visible range's minimum instead makes the error
|
||||||
|
/// 2^-24 of the *visible span* -- a fixed 4e-5 of a pixel over a 700 px axis -- at every
|
||||||
|
/// zoom. A caller with no view in mind may pass `x0()` and get the old behaviour.
|
||||||
|
///
|
||||||
|
/// Level 0 emits the raw samples of [begin, end). Level >= 1 emits one
|
||||||
|
/// geometry element per *complete* level bucket wholly inside the retained
|
||||||
|
/// window, bounded by the two partial buckets at the edges of the requested
|
||||||
|
/// range -- the bucket the window starts inside and the bucket the newest
|
||||||
|
/// samples are in -- which it emits as raw samples through copy_raw(). A
|
||||||
|
/// bucket that is only partly retained holds aggregates of samples the
|
||||||
|
/// window no longer has and a partial trailing bucket is not stored at all,
|
||||||
|
/// so neither may be drawn from aggregates. `mode` picks what a bucket
|
||||||
|
/// contributes (see `GatherMode`); the edge raw samples are the same for
|
||||||
|
/// every mode. A bucket whose samples are all non-finite is empty, not
|
||||||
|
/// zero-valued (the library-wide NaN policy): Envelope and Mean emit nothing
|
||||||
|
/// for it, Nearest draws the raw centre sample. `level` is clamped to
|
||||||
|
/// `kMaxPyramidLevel`.
|
||||||
|
///
|
||||||
|
/// The emitted geometry therefore always spans [begin, end) exactly, for at
|
||||||
|
/// most two buckets of extra raw work (about two pixels at the level
|
||||||
|
/// level_for() picks), and never reaches outside the retained window -- nor
|
||||||
|
/// outside [begin, end). Pairs are appended to out_xy, which is never
|
||||||
|
/// cleared.
|
||||||
|
virtual void gather(u32 level, u64 begin, u64 end, f64 x_origin, 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 brackets 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
|
||||||
@@ -72,8 +128,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, f64 x_origin, 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
|
||||||
@@ -94,7 +151,7 @@ class Series {
|
|||||||
/// 0 is entirely this, and a level >= 1 gather uses it for the two partial edge
|
/// 0 is entirely this, and a level >= 1 gather uses it for the two partial edge
|
||||||
/// buckets a pyramid of complete buckets cannot answer for. Precondition:
|
/// buckets a pyramid of complete buckets cannot answer for. Precondition:
|
||||||
/// begin < end, end <= size().
|
/// begin < end, end <= size().
|
||||||
void gather_raw(u64 begin, u64 end, std::vector<f32>& out_xy) const;
|
void gather_raw(u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy) const;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace plot::data
|
} // namespace plot::data
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#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>
|
||||||
@@ -54,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.
|
||||||
@@ -65,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;
|
||||||
@@ -72,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, f64 x_origin, 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; }
|
||||||
|
|||||||
@@ -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 `x_origin`.
|
||||||
|
void gather(u32 level, u64 begin, u64 end, f64 x_origin, 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
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <plot/core/types.hpp>
|
#include <plot/core/types.hpp>
|
||||||
|
|
||||||
@@ -55,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,
|
||||||
@@ -124,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)) \
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <plot/gl/api.hpp>
|
#include <plot/gl/api.hpp>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <plot/core/color.hpp>
|
#include <plot/core/color.hpp>
|
||||||
#include <plot/core/status.hpp>
|
#include <plot/core/status.hpp>
|
||||||
@@ -33,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 {
|
||||||
@@ -70,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`;
|
||||||
@@ -169,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
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <plot/gl/api.hpp>
|
#include <plot/gl/api.hpp>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#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>
|
||||||
@@ -44,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
|
||||||
@@ -71,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:
|
||||||
///
|
///
|
||||||
@@ -200,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.
|
||||||
@@ -234,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();
|
||||||
@@ -314,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
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <plot/core/types.hpp>
|
#include <plot/core/types.hpp>
|
||||||
@@ -32,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
|
||||||
@@ -62,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
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <plot/core/types.hpp>
|
#include <plot/core/types.hpp>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <plot/core/types.hpp>
|
#include <plot/core/types.hpp>
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <plot/core/types.hpp>
|
#include <plot/core/types.hpp>
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
/// Umbrella header: pulls in every public component of the plot library, in
|
/// Umbrella header: pulls in every public component of the plot library, in
|
||||||
@@ -34,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>
|
||||||
@@ -61,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>
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -49,19 +52,42 @@ struct Axis {
|
|||||||
AxisPlace place = AxisPlace::Bottom;
|
AxisPlace place = AxisPlace::Bottom;
|
||||||
bool log = false;
|
bool log = false;
|
||||||
TickFormat format = TickFormat::Si;
|
TickFormat format = TickFormat::Si;
|
||||||
|
/// The value the labels are written *relative to*: every value on this axis is formatted as
|
||||||
|
/// `v - origin`, and the ticks are generated on that shifted grid. A host uses it to read a
|
||||||
|
/// zoomed-in window against its own left edge -- the labels then say `0, 0.5ms, 1ms` with the
|
||||||
|
/// window's start printed once beside the axis, instead of `12.345671s, 12.345672s` (which
|
||||||
|
/// three significant digits cannot even distinguish). 0 by default, which is the plain
|
||||||
|
/// absolute axis; the *positions* of the ticks are unchanged by it in the sense that only
|
||||||
|
/// their printed value shifts.
|
||||||
|
f64 origin = 0.0;
|
||||||
|
|
||||||
|
/// When set, `origin` is re-taken from the axis' own range on every `refresh()` -- the labels
|
||||||
|
/// then count from whatever the left edge currently is, and the tick on that edge reads zero
|
||||||
|
/// exactly, without a host having to predict where the follow will place the window (which it
|
||||||
|
/// cannot: the window moves with the samples, and a value written a frame earlier is a frame
|
||||||
|
/// of scroll stale). The window-start label a host draws beside the axis is then
|
||||||
|
/// `origin` *after* the frame, which is the value these labels were built with.
|
||||||
|
bool origin_follows_range = false;
|
||||||
|
|
||||||
/// Whether this axis follows the newest sample. Only meaningful on an x axis --
|
/// Whether this axis follows the newest sample. Only meaningful on an x 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.
|
||||||
@@ -76,6 +102,13 @@ struct Axis {
|
|||||||
/// group stops following or the row leaves it.
|
/// group stops following or the row leaves it.
|
||||||
bool live_owner = false;
|
bool live_owner = false;
|
||||||
f64 live_owner_newest = 0.0;
|
f64 live_owner_newest = 0.0;
|
||||||
|
/// Set while this plot is a member of a `Figure` x link group (`Figure::link_x()`
|
||||||
|
/// writes it, `unlink_row()` clears it). The group owns the shared x window *and*
|
||||||
|
/// its follow state -- `Figure::link_x()` refuses a `Live` member joiner -- so
|
||||||
|
/// `Plot::set_x_follow(Live)` on this axis is refused after linking too, rather
|
||||||
|
/// than letting a later call put the member's axis back into a state the group
|
||||||
|
/// cannot drive (see `Plot::set_x_follow()`).
|
||||||
|
bool link_member = false;
|
||||||
/// Ticks for the last `refresh()`, ascending, majors and (when asked) minors.
|
/// Ticks for the last `refresh()`, ascending, majors and (when asked) minors.
|
||||||
std::vector<Tick> ticks;
|
std::vector<Tick> ticks;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -19,12 +22,43 @@ namespace plot {
|
|||||||
/// and `dash_off` are independent, so a caller may override only the dash, or only the
|
/// and `dash_off` are independent, so a caller may override only the dash, or only the
|
||||||
/// width, and leave the rest to the style.
|
/// width, and leave the rest to the style.
|
||||||
struct CrosshairStyle {
|
struct CrosshairStyle {
|
||||||
f32 width = -1.0f; ///< logical stroke width; `< 0` takes `Style::crosshair_width`.
|
f32 width = -1.0f; ///< logical stroke width; `< 0` takes `Style::crosshair_width`
|
||||||
|
///< (the default). Only a *negative* value defers: `0` is used
|
||||||
|
///< literally and draws an invisible line.
|
||||||
f32 dash_on = -1.0f; ///< logical ink length; `< 0` takes `Style::crosshair_dash_on`.
|
f32 dash_on = -1.0f; ///< logical ink length; `< 0` takes `Style::crosshair_dash_on`.
|
||||||
f32 dash_off = -1.0f; ///< logical gap; `< 0` takes `Style::crosshair_dash_off`, and `0`
|
f32 dash_off = -1.0f; ///< logical gap; `< 0` takes `Style::crosshair_dash_off`, and `0`
|
||||||
///< (once resolved) means solid.
|
///< (once resolved) means solid.
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// What a crosshair readout's number *is*: one of the series' own samples, or a value
|
||||||
|
/// computed between two of them.
|
||||||
|
///
|
||||||
|
/// The two answer different questions and the right one is a property of the data, which the
|
||||||
|
/// plot cannot know — so it is the caller's to state.
|
||||||
|
///
|
||||||
|
/// * `NearestSample` answers **"which datum is under the pointer"**. The row reports the y of
|
||||||
|
/// whichever bracketing sample is nearer the crosshair's x (a tie reading the earlier), and
|
||||||
|
/// the marker is placed at **that sample's own x**, so the mark says *which* sample the
|
||||||
|
/// number came from. This is the default, and it is the honest mode for a source whose
|
||||||
|
/// samples are already averaged or decimated before they arrive: a number between two of
|
||||||
|
/// those is a second estimate of a signal that nothing measured, and at a zoom past the
|
||||||
|
/// sample spacing the polyline between two samples is a line the renderer drew, so a
|
||||||
|
/// reading off it reports the drawing.
|
||||||
|
/// * `Interpolated` answers **"what is the signal at this instant"**, by linear interpolation
|
||||||
|
/// between the two bracketing samples (`crosshair_interpolate()`). The marker stays on the
|
||||||
|
/// crosshair's own x, on the polyline, because that is where the number is. Right for a
|
||||||
|
/// continuous quantity sampled finely enough that the line between two samples is a model
|
||||||
|
/// the caller believes — a smooth analytical curve, an interpolating instrument — and
|
||||||
|
/// wrong wherever that line is an artefact of plotting.
|
||||||
|
///
|
||||||
|
/// Set per plot through `Plot::set_crosshair_value_mode()`. It reaches the readout's numbers,
|
||||||
|
/// the markers' positions and `Plot::crosshair_value()` alike, so a host and the panel beside
|
||||||
|
/// the pointer can never disagree about what the value means.
|
||||||
|
enum class CrosshairValue : u8 {
|
||||||
|
NearestSample, ///< The nearer bracketing sample's y, marked at that sample's x. Default.
|
||||||
|
Interpolated, ///< A linear blend of the two, marked at the crosshair's own x.
|
||||||
|
};
|
||||||
|
|
||||||
/// The crosshair's state: where it is, which of its two lines it draws, and whether the
|
/// The crosshair's state: where it is, which of its two lines it draws, and whether the
|
||||||
/// per-signal readout is wanted.
|
/// per-signal readout is wanted.
|
||||||
///
|
///
|
||||||
@@ -39,10 +73,10 @@ struct CrosshairStyle {
|
|||||||
/// drawn -- the single-coordinate `Plot::set_crosshair()` leaves the vertical one only,
|
/// drawn -- the single-coordinate `Plot::set_crosshair()` leaves the vertical one only,
|
||||||
/// and a `Figure` is always vertical-only because its rows share x and not y.
|
/// and a `Figure` is always vertical-only because its rows share x and not y.
|
||||||
///
|
///
|
||||||
/// **The readout's values are interpolated, and deliberately so.** The y a row reports for
|
/// **The readout's values are samples by default** -- the y a row reports is the nearer of
|
||||||
/// a trace is *linear interpolation between the two samples adjacent to `x`*, not a sample
|
/// the two samples adjacent to `x`, and its marker sits on that sample. `value_mode` is what
|
||||||
/// and not an interval statistic -- see `crosshair_span()` for the rule stated in full, and
|
/// chooses otherwise; see `CrosshairValue` for the two questions it picks between, and
|
||||||
/// for why it is the opposite convention from `Series::reduce()`/`sum()`/`sumsq()`.
|
/// `crosshair_span()` for how both differ from `Series::reduce()`/`sum()`/`sumsq()`.
|
||||||
struct Crosshair {
|
struct Crosshair {
|
||||||
/// Whether the crosshair is drawn at all. Set by `Plot::set_crosshair()`; cleared by
|
/// Whether the crosshair is drawn at all. Set by `Plot::set_crosshair()`; cleared by
|
||||||
/// `Plot::clear_crosshair()`, which `input::Controller` calls when the pointer leaves the
|
/// `Plot::clear_crosshair()`, which `input::Controller` calls when the pointer leaves the
|
||||||
@@ -53,7 +87,7 @@ struct Crosshair {
|
|||||||
/// Draw the horizontal line (the row of `y`). On for the two-coordinate overload only:
|
/// Draw the horizontal line (the row of `y`). On for the two-coordinate overload only:
|
||||||
/// a crosshair placed at one coordinate has no second value to mark a row with.
|
/// a crosshair placed at one coordinate has no second value to mark a row with.
|
||||||
bool horizontal = true;
|
bool horizontal = true;
|
||||||
/// List the visible traces and their interpolated values in a panel beside the pointer.
|
/// List the visible traces and their values in a panel beside the pointer.
|
||||||
/// On by default, and free to leave on: a plot with no visible trace has no rows to
|
/// On by default, and free to leave on: a plot with no visible trace has no rows to
|
||||||
/// measure and the panel is then not placed at all.
|
/// measure and the panel is then not placed at all.
|
||||||
bool show_readout = true;
|
bool show_readout = true;
|
||||||
@@ -62,6 +96,10 @@ struct Crosshair {
|
|||||||
/// The data y of the horizontal line, on y axis 0. Meaningless for a vertical-only
|
/// The data y of the horizontal line, on y axis 0. Meaningless for a vertical-only
|
||||||
/// crosshair, which draws no row and anchors its readout at the top of the axes.
|
/// crosshair, which draws no row and anchors its readout at the top of the axes.
|
||||||
f64 y = 0.0;
|
f64 y = 0.0;
|
||||||
|
/// Whether a row's value is one of the series' samples or a blend of two -- see
|
||||||
|
/// `CrosshairValue`, which also says where each mode puts the marker. Set through
|
||||||
|
/// `Plot::set_crosshair_value_mode()`.
|
||||||
|
CrosshairValue value_mode = CrosshairValue::NearestSample;
|
||||||
/// This plot's own stroke and dash, when it wants to differ from the global style; a
|
/// This plot's own stroke and dash, when it wants to differ from the global style; a
|
||||||
/// default-constructed `CrosshairStyle` defers every field to `render::Style` (see
|
/// default-constructed `CrosshairStyle` defers every field to `render::Style` (see
|
||||||
/// `CrosshairStyle`). Set through `Plot::set_crosshair_style()`.
|
/// `CrosshairStyle`). Set through `Plot::set_crosshair_style()`.
|
||||||
@@ -79,14 +117,17 @@ struct Crosshair {
|
|||||||
/// arithmetic, which is O(1) in the series' length. The crosshair runs on every pointer
|
/// arithmetic, which is O(1) in the series' length. The crosshair runs on every pointer
|
||||||
/// move, so that is a requirement rather than a nicety.
|
/// move, so that is a requirement rather than a nicety.
|
||||||
///
|
///
|
||||||
/// **Interpolated, not exact.** The value a caller builds from this span by
|
/// **`t` is a position, not yet an answer.** It says where the query sits between the two
|
||||||
/// `crosshair_interpolate()` between the two samples is the signal *at* `x`, which is
|
/// samples and nothing more; what a caller makes of it is `CrosshairValue`'s to decide. In
|
||||||
/// generally between two samples -- the opposite convention from `Series::reduce()`,
|
/// `NearestSample` -- the default -- it *selects*, `t <= 0.5` taking `begin`, so the readout
|
||||||
/// `sum()` and `sumsq()`, which are **exact over a sample range**. The two conventions sit
|
/// quotes a sample the series actually holds. In `Interpolated` it *blends*, through
|
||||||
/// next to each other in this library on purpose and they do not agree: `reduce()` answers
|
/// `crosshair_interpolate()`. A plot cannot tell which is right, because the question is
|
||||||
/// "what are the samples' extremes over this interval", this answers "what is the signal at
|
/// about the data and not about the drawing; `CrosshairValue` is where that is argued.
|
||||||
/// this instant". A readout that pretended to be exact would be quoting an extreme or a
|
///
|
||||||
/// mean of a window as if it were the value under the pointer.
|
/// Either way this is a different convention from `Series::reduce()`, `sum()` and `sumsq()`,
|
||||||
|
/// which are **exact over a sample range**: those answer "what are the samples'
|
||||||
|
/// extremes/total over this interval", a crosshair answers "what is the signal at this one
|
||||||
|
/// x". What differs is a statistic of many samples against a reading at one place.
|
||||||
///
|
///
|
||||||
/// `valid` is false -- and every field left at its default -- for an **empty series**, a
|
/// `valid` is false -- and every field left at its default -- for an **empty series**, a
|
||||||
/// non-positive `dx()`, a non-finite `x`, and an `x` strictly outside the series' window
|
/// non-positive `dx()`, a non-finite `x`, and an `x` strictly outside the series' window
|
||||||
@@ -99,13 +140,14 @@ struct CrosshairSpan {
|
|||||||
/// Index of the first sample whose x is at or after the query: `begin` when the query
|
/// Index of the first sample whose x is at or after the query: `begin` when the query
|
||||||
/// lands on a sample, `begin + 1` between two of them.
|
/// lands on a sample, `begin + 1` between two of them.
|
||||||
u64 end = 0;
|
u64 end = 0;
|
||||||
/// The query's position between the two samples, in `[0, 1]`.
|
/// The query's position between the two samples, in `[0, 1]`. Whether it selects one of
|
||||||
|
/// them or blends both is `CrosshairValue`'s -- see the struct comment.
|
||||||
f64 t = 0.0;
|
f64 t = 0.0;
|
||||||
bool valid = false;
|
bool valid = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The sample span `x` falls in, on `series`; see `CrosshairSpan` for the rule, for the
|
/// The sample span `x` falls in, on `series`; see `CrosshairSpan` for what `t` means, for the
|
||||||
/// interpolation convention it is half of, and for the cases that yield `valid == false`.
|
/// convention it is half of, and for the cases that yield `valid == false`.
|
||||||
///
|
///
|
||||||
/// O(1) and allocation-free: a subtraction, a division and a truncation. The series' own sample
|
/// O(1) and allocation-free: a subtraction, a division and a truncation. The series' own sample
|
||||||
/// fetch is the caller's -- `Plot::crosshair_value()` reads the span through
|
/// fetch is the caller's -- `Plot::crosshair_value()` reads the span through
|
||||||
@@ -113,12 +155,14 @@ struct CrosshairSpan {
|
|||||||
/// allocates.
|
/// allocates.
|
||||||
CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept;
|
CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept;
|
||||||
|
|
||||||
/// The interpolated y at `t` between two samples: `y_begin` at `t == 0`, `y_end` at
|
/// The linear blend of a span's two end samples at `t`: `y_begin + t * (y_end - y_begin)`,
|
||||||
/// `t == 1`. The one place the interpolation's arithmetic lives, so the lookup's rule and
|
/// which is what `CrosshairValue::Interpolated` reports.
|
||||||
/// its documentation cannot drift apart.
|
///
|
||||||
inline f64 crosshair_interpolate(f64 y_begin, f64 y_end, f64 t) noexcept {
|
/// Written as that form rather than `(1 - t) * a + t * b` so `t == 0` returns `y_begin`
|
||||||
return y_begin + (y_end - y_begin) * t;
|
/// exactly and `t == 1` returns `y_end` exactly, with no rounding of its own at the ends: a
|
||||||
}
|
/// query that landed on a sample must read that sample's value back unchanged, whichever mode
|
||||||
|
/// is in force. `NearestSample` does not call it at all.
|
||||||
|
f64 crosshair_interpolate(f64 y_begin, f64 y_end, f64 t) noexcept;
|
||||||
|
|
||||||
/// One trace's line of the readout: which trace it is about, the value under the pointer
|
/// One trace's line of the readout: which trace it is about, the value under the pointer
|
||||||
/// and the boxes the renderer stages.
|
/// and the boxes the renderer stages.
|
||||||
@@ -145,8 +189,14 @@ struct CrosshairRow {
|
|||||||
/// entries -- so a row that is not listed is still a listed *trace*, with a marker, just
|
/// entries -- so a row that is not listed is still a listed *trace*, with a marker, just
|
||||||
/// not a line in a panel that has no room for it.
|
/// not a line in a panel that has no room for it.
|
||||||
bool listed = false;
|
bool listed = false;
|
||||||
/// The trace's interpolated y at the crosshair's x, on the trace's own y axis. Only
|
/// The data x the row's value belongs to, on the trace's own x axis: in
|
||||||
/// meaningful when `valid`.
|
/// `CrosshairValue::NearestSample` the **quoted sample's own x**, which is generally not the
|
||||||
|
/// crosshair's; in `Interpolated` the crosshair's x itself, since that is where an
|
||||||
|
/// interpolated number is. `marker` is placed from this and from `y`. Only meaningful when
|
||||||
|
/// `valid`.
|
||||||
|
f64 x = 0.0;
|
||||||
|
/// The row's value, on the trace's own y axis: the nearer sample's y, or the blend of the
|
||||||
|
/// two -- see `CrosshairValue`. Only meaningful when `valid`.
|
||||||
f64 y = 0.0;
|
f64 y = 0.0;
|
||||||
/// The trace's name, or the positional placeholder of an unnamed one.
|
/// The trace's name, or the positional placeholder of an unnamed one.
|
||||||
std::string label;
|
std::string label;
|
||||||
@@ -156,9 +206,24 @@ struct CrosshairRow {
|
|||||||
/// `kCrosshairNoValue`, the em dash. The absence is therefore *legible* -- a blank cell reads as
|
/// `kCrosshairNoValue`, the em dash. The absence is therefore *legible* -- a blank cell reads as
|
||||||
/// "still loading" rather than as "nothing here" -- and nothing numeric is fabricated for it.
|
/// "still loading" rather than as "nothing here" -- and nothing numeric is fabricated for it.
|
||||||
std::string value;
|
std::string value;
|
||||||
/// The trace's intersection with the crosshair, in framebuffer pixels: the pen the
|
/// Where the renderer draws the marker, in framebuffer pixels: the pen for `(x, y)`, placed
|
||||||
/// renderer draws the marker at. Placed through the *trace's own* axis pair, so the
|
/// through the *trace's own* axis pair, so a trace on a secondary y axis is marked on the
|
||||||
/// marker is where that trace is actually drawn. Meaningless when `!valid`.
|
/// scale it is drawn against.
|
||||||
|
///
|
||||||
|
/// It marks **the point the row reports**, which is the whole job: in
|
||||||
|
/// `CrosshairValue::NearestSample` it is *snapped to the quoted sample*, sitting on the datum
|
||||||
|
/// rather than beside it on the crosshair's column, so the mark says which of the samples
|
||||||
|
/// under the pointer the number came from. (Placing it at the pointer's x with the sample's y
|
||||||
|
/// put it off the data in both coordinates at a deep zoom -- a mark on nothing.) In
|
||||||
|
/// `Interpolated` it stays on the crosshair's column, on the polyline, which is equally where
|
||||||
|
/// *that* mode's number is. One `(x, y)` feeding both the mark and the text is what keeps them
|
||||||
|
/// from ever disagreeing.
|
||||||
|
///
|
||||||
|
/// It is drawn `Style::crosshair_marker_px` across -- **bigger than a trace's own sample
|
||||||
|
/// markers** on purpose, since a snapped mark lands among them and has to read as "this one"
|
||||||
|
/// rather than as one more sample. A mark for a sample outside the visible range is staged
|
||||||
|
/// where that sample is and left to the axes clip, rather than being dropped by a range test
|
||||||
|
/// of the drawing layer's own. Meaningless when `!valid`.
|
||||||
Vec2 marker{};
|
Vec2 marker{};
|
||||||
/// The swatch's box, `Legend::swatch_w` x `Legend::swatch_h` centred in the row. Empty
|
/// The swatch's box, `Legend::swatch_w` x `Legend::swatch_h` centred in the row. Empty
|
||||||
/// when `!listed`.
|
/// when `!listed`.
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
#include <plot/core/color.hpp>
|
#include <plot/core/color.hpp>
|
||||||
#include <plot/core/types.hpp>
|
#include <plot/core/types.hpp>
|
||||||
|
#include <plot/math/vec2.hpp>
|
||||||
|
|
||||||
namespace plot {
|
namespace plot {
|
||||||
|
|
||||||
@@ -83,6 +87,25 @@ struct Cursor {
|
|||||||
u32 axis_x = 0; ///< the x axis `x` is in. Dead for `Horizontal`.
|
u32 axis_x = 0; ///< the x axis `x` is in. Dead for `Horizontal`.
|
||||||
u32 axis_y = 0; ///< the y axis `y` is in. Dead for `Vertical`.
|
u32 axis_y = 0; ///< the y axis `y` is in. Dead for `Vertical`.
|
||||||
bool visible = true;
|
bool visible = true;
|
||||||
|
/// Whether the pointer may hit this cursor at all: `false` for a *readout* -- a marker a
|
||||||
|
/// host keeps to show something it computed, which the user must not be able to drag. The
|
||||||
|
/// controller's `cursor_at()` skips it, so it is neither hovered nor grabbed, and the
|
||||||
|
/// hit radius it would have taken is left to whatever is behind it. Drawn exactly as a
|
||||||
|
/// visible one is: this is about the gesture, not the ink.
|
||||||
|
bool selectable = true;
|
||||||
|
/// The stroke's logical width; `<= 0` means the style's default for this cursor
|
||||||
|
/// (`Style::handle_width_px` while it is hovered or grabbed, `Style::line_width_px`
|
||||||
|
/// otherwise). Per cursor, like the colour, because a host uses cursors for two different
|
||||||
|
/// jobs: the instruments that frame a reading (a marker at an edge, a pull handle) and the
|
||||||
|
/// *readout marks* a computation places on the data (a measured level, a peak). Those want
|
||||||
|
/// different weights, and a hover can only make the stroke thicker, never thinner -- the cue
|
||||||
|
/// that the pointer has hold of something must not be the thing that hides it.
|
||||||
|
f32 width = 0.0f;
|
||||||
|
/// The dash, logical pixels: ink length and gap. `off <= 0` (the default) is a solid stroke --
|
||||||
|
/// the same convention as a trace's, the crosshair's and the grid's. A dashed readout mark is
|
||||||
|
/// distinguishable by *form* from the solid chrome around it, which is what an instrument that
|
||||||
|
/// reports a number should be.
|
||||||
|
Vec2 dash{};
|
||||||
|
|
||||||
/// True when this is a horizontal ruler (spans the axes' width, reads its `y`). The one
|
/// True when this is a horizontal ruler (spans the axes' width, reads its `y`). The one
|
||||||
/// place the orientation becomes a branch, so every consumer takes the same decision and
|
/// place the orientation becomes a branch, so every consumer takes the same decision and
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#ifdef PLOT_COUNT_MEASURE_RECOMPUTES
|
#ifdef PLOT_COUNT_MEASURE_RECOMPUTES
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <span>
|
#include <span>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -123,6 +126,28 @@ class Figure {
|
|||||||
void set_row_gap(f32 device_px) noexcept { row_gap_ = device_px > 0.0f ? device_px : 0.0f; }
|
void set_row_gap(f32 device_px) noexcept { row_gap_ = device_px > 0.0f ? device_px : 0.0f; }
|
||||||
f32 row_gap() const noexcept { return row_gap_; }
|
f32 row_gap() const noexcept { return row_gap_; }
|
||||||
|
|
||||||
|
/// A floor under the figure's common left gutter, in device pixels. The gutter
|
||||||
|
/// `layout()` imposes is the maximum over the rows' *measured* needs, so it follows
|
||||||
|
/// the tick labels: a `TickFormat::Si` axis whose range moves from `1.5` to `150` to
|
||||||
|
/// `1.5k` changes the width of its labels, and with it `axes.x` and `axes.w` of every
|
||||||
|
/// row. A caller that needs a layout which does not depend on content reserves the
|
||||||
|
/// widest label its formatter can produce once, through this, and the figure takes the
|
||||||
|
/// larger of the two. Negative values are clamped to 0 (the default: measure only).
|
||||||
|
void set_min_y_gutter(f32 device_px) noexcept {
|
||||||
|
min_y_gutter_ = device_px > 0.0f ? device_px : 0.0f;
|
||||||
|
}
|
||||||
|
f32 min_y_gutter() const noexcept { return min_y_gutter_; }
|
||||||
|
|
||||||
|
/// The empty band every row keeps to the right of its `axes`, in device pixels (0.0
|
||||||
|
/// by default, so `axes` reaches the viewport's right edge). It is set here rather
|
||||||
|
/// than row by row for the left gutter's reason: the rows' axes have to line up, and
|
||||||
|
/// they end on a common column only if they are told one width. See
|
||||||
|
/// `Plot::set_right_gutter()` for what it is for. Negative values are clamped to 0.
|
||||||
|
void set_right_gutter(f32 device_px) noexcept {
|
||||||
|
right_gutter_ = device_px > 0.0f ? device_px : 0.0f;
|
||||||
|
}
|
||||||
|
f32 right_gutter() const noexcept { return right_gutter_; }
|
||||||
|
|
||||||
/// Whether the shared x axis is the figure's: every row reserves no x label strip
|
/// Whether the shared x axis is the figure's: every row reserves no x label strip
|
||||||
/// and one shared axis is drawn under the last row (the default), or every row
|
/// and one shared axis is drawn under the last row (the default), or every row
|
||||||
/// draws its own labels and the figure draws nothing extra.
|
/// draws its own labels and the figure draws nothing extra.
|
||||||
@@ -140,7 +165,8 @@ class Figure {
|
|||||||
/// It is also the one place the rows' left chrome can be made to agree (spec §16):
|
/// It is also the one place the rows' left chrome can be made to agree (spec §16):
|
||||||
/// each row is laid out once to learn the gutter its own labels and title column
|
/// each row is laid out once to learn the gutter its own labels and title column
|
||||||
/// need, the figure takes the maximum and imposes it on every row through
|
/// need, the figure takes the maximum and imposes it on every row through
|
||||||
/// `Plot::set_min_y_gutter()` before laying the rows out once more -- so every row's
|
/// `Plot::set_min_y_gutter()` -- never below `set_min_y_gutter()`'s own floor -- before
|
||||||
|
/// laying the rows out once more, so every row's
|
||||||
/// `axes.x` and `axes.w` are equal and a vertical feature lands in the same pixel
|
/// `axes.x` and `axes.w` are equal and a vertical feature lands in the same pixel
|
||||||
/// column in each. Two passes per row per call, not a loop: the gutter is a maximum
|
/// column in each. Two passes per row per call, not a loop: the gutter is a maximum
|
||||||
/// over rows and a row's own strip width does not depend on it.
|
/// over rows and a row's own strip width does not depend on it.
|
||||||
@@ -309,6 +335,8 @@ class Figure {
|
|||||||
std::vector<XLink> links_;
|
std::vector<XLink> links_;
|
||||||
FigureLayout layout_{};
|
FigureLayout layout_{};
|
||||||
f32 row_gap_ = 0.0f;
|
f32 row_gap_ = 0.0f;
|
||||||
|
f32 min_y_gutter_ = 0.0f;
|
||||||
|
f32 right_gutter_ = 0.0f;
|
||||||
bool labels_bottom_only_ = true;
|
bool labels_bottom_only_ = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <plot/core/types.hpp>
|
#include <plot/core/types.hpp>
|
||||||
#include <plot/data/series.hpp>
|
#include <plot/data/series.hpp>
|
||||||
|
|||||||
+133
-14
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -85,6 +88,11 @@ struct PlotLayout {
|
|||||||
/// no text backend there is no title to lay out at all and both stay 0.0f.
|
/// no text backend there is no title to lay out at all and both stay 0.0f.
|
||||||
f32 y_title_width = 0.0f;
|
f32 y_title_width = 0.0f;
|
||||||
f32 x_title_height = 0.0f;
|
f32 x_title_height = 0.0f;
|
||||||
|
/// The empty band kept between `axes`' right edge and the viewport's, 0.0f unless a
|
||||||
|
/// caller asked for one (see `Plot::set_right_gutter()`). Reported because it is
|
||||||
|
/// clamped by the same cap as the strips above, so what was asked for and what was
|
||||||
|
/// reserved are not always the same number.
|
||||||
|
f32 right_gutter = 0.0f;
|
||||||
/// The measured legend: inside `axes`, never reserving any part of it, and empty
|
/// The measured legend: inside `axes`, never reserving any part of it, and empty
|
||||||
/// when there is nothing to draw. It is measured with the same text backend the
|
/// when there is nothing to draw. It is measured with the same text backend the
|
||||||
/// strips above are, and against the same `axes` rect the caller reads back, so
|
/// strips above are, and against the same `axes` rect the caller reads back, so
|
||||||
@@ -98,8 +106,8 @@ struct PlotLayout {
|
|||||||
/// where a pointer can grab it cannot drift apart.
|
/// where a pointer can grab it cannot drift apart.
|
||||||
std::vector<RegionLayout> regions;
|
std::vector<RegionLayout> regions;
|
||||||
/// The crosshair's geometry, when one is active: the two lines' stroke boxes, the
|
/// The crosshair's geometry, when one is active: the two lines' stroke boxes, the
|
||||||
/// readout panel and one row per visible trace with the interpolated value the pointer
|
/// readout panel and one row per visible trace with the value of the sample nearest the
|
||||||
/// is over. Placed here rather than derived by the renderer, exactly as the legend and
|
/// pointer. Placed here rather than derived by the renderer, exactly as the legend and
|
||||||
/// the regions are -- and, like them, entirely inside `axes`: the panel is flipped and
|
/// the regions are -- and, like them, entirely inside `axes`: the panel is flipped and
|
||||||
/// clamped against the rect it was measured for, so nothing of the readout can be drawn
|
/// clamped against the rect it was measured for, so nothing of the readout can be drawn
|
||||||
/// over a label strip or into a figure's neighbouring row.
|
/// over a label strip or into a figure's neighbouring row.
|
||||||
@@ -149,8 +157,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
|
||||||
@@ -218,6 +234,13 @@ class Plot {
|
|||||||
/// Measures the interval between two cursors over the series of `trace`.
|
/// Measures the interval between two cursors over the series of `trace`.
|
||||||
u32 add_measure(u32 cursor_a, u32 cursor_b, u32 trace = 0);
|
u32 add_measure(u32 cursor_a, u32 cursor_b, u32 trace = 0);
|
||||||
u32 add_annotation(const Annotation& annotation);
|
u32 add_annotation(const Annotation& annotation);
|
||||||
|
/// Moves an annotation that is already in the plot, keeping its id -- what a host whose
|
||||||
|
/// annotation is a *readout* needs (the point where a cursor crosses a trace moves with
|
||||||
|
/// the cursor). `Plot` has no `remove_annotation`, so the same parking discipline as the
|
||||||
|
/// cursors applies: set `visible = false` rather than dropping it, and re-place where it
|
||||||
|
/// belongs. An id that names no annotation is ignored, not an error, exactly as every
|
||||||
|
/// other setter here treats a stale id.
|
||||||
|
void set_annotation(u32 id, const Annotation& annotation);
|
||||||
/// Adds a region. Its bounds are ordered and, when the region asks to snap, pulled
|
/// Adds a region. Its bounds are ordered and, when the region asks to snap, pulled
|
||||||
/// onto the sample grid as it is added -- exactly as `add_cursor()` places a
|
/// onto the sample grid as it is added -- exactly as `add_cursor()` places a
|
||||||
/// snapping cursor -- so a region handed to a plot that already holds samples is on
|
/// snapping cursor -- so a region handed to a plot that already holds samples is on
|
||||||
@@ -326,6 +349,11 @@ class Plot {
|
|||||||
/// newest sample* rather than a pair of numbers. With no live edge -- no trace on
|
/// newest sample* rather than a pair of numbers. With no live edge -- no trace on
|
||||||
/// the axis, or none of them streaming -- nothing is recomputed and the axis
|
/// the axis, or none of them streaming -- nothing is recomputed and the axis
|
||||||
/// keeps its stored range, `auto_scale` included, exactly as an `Off` axis does.
|
/// keeps its stored range, `auto_scale` included, exactly as an `Off` axis does.
|
||||||
|
///
|
||||||
|
/// On an axis that is a member of a `Figure` x link group, arming `Live` is
|
||||||
|
/// **refused** (the state is left as it was): the group owns the shared window and
|
||||||
|
/// its follow state, exactly as `Figure::link_x()` refuses a `Live` member joiner.
|
||||||
|
/// See `set_x_link_member()`.
|
||||||
void set_x_follow(u32 axis, XFollow f);
|
void set_x_follow(u32 axis, XFollow f);
|
||||||
/// The follow state of one x axis. `Off` for an axis that does not exist.
|
/// The follow state of one x axis. `Off` for an axis that does not exist.
|
||||||
XFollow x_follow(u32 axis) const;
|
XFollow x_follow(u32 axis) const;
|
||||||
@@ -360,6 +388,14 @@ class Plot {
|
|||||||
/// axis no owner has claimed, and for an axis that does not exist.
|
/// axis no owner has claimed, and for an axis that does not exist.
|
||||||
bool x_live_owner(u32 axis, f64& newest) const noexcept;
|
bool x_live_owner(u32 axis, f64& newest) const noexcept;
|
||||||
|
|
||||||
|
/// Publishes -- or clears -- that this axis is a member of a `Figure` x link group.
|
||||||
|
/// `Figure::link_x()` sets it when a row joins and `unlink_row()` clears it when one
|
||||||
|
/// leaves; it is what makes `set_x_follow(Live)` refused on a linked axis, because
|
||||||
|
/// the group owns the shared window and the follow state. A no-op for an axis that
|
||||||
|
/// does not exist. Not for callers outside the figure: a `Plot` that is not in a
|
||||||
|
/// figure never has a linked axis.
|
||||||
|
void set_x_link_member(u32 axis, bool linked) noexcept;
|
||||||
|
|
||||||
/// Data -> pixels for one axis, built from the axis' range, log flag and the
|
/// Data -> pixels for one axis, built from the axis' range, log flag and the
|
||||||
/// current layout. Always invertible: a collapsed range or a collapsed axes
|
/// current layout. Always invertible: a collapsed range or a collapsed axes
|
||||||
/// rect is widened by math::Transform, so to_data_*(to_screen_*(v)) == v.
|
/// rect is widened by math::Transform, so to_data_*(to_screen_*(v)) == v.
|
||||||
@@ -448,6 +484,46 @@ class Plot {
|
|||||||
///
|
///
|
||||||
/// Ignored for a vertical cursor, whose snap is `SnapMode` -- see `set_cursor_snap()`.
|
/// Ignored for a vertical cursor, whose snap is `SnapMode` -- see `set_cursor_snap()`.
|
||||||
void set_cursor_h_snap(u32 id, HRulerSnap mode);
|
void set_cursor_h_snap(u32 id, HRulerSnap mode);
|
||||||
|
/// Recolours a cursor that already exists. A *readout* cursor -- one a host keeps as a
|
||||||
|
/// marker for something it computed, rather than an instrument the user placed -- has to
|
||||||
|
/// be told apart from its neighbours, and its colour is how it says which readout it
|
||||||
|
/// belongs to. An id that names no cursor is ignored, like every other setter here.
|
||||||
|
void set_cursor_color(u32 id, Color color);
|
||||||
|
|
||||||
|
/// A cursor's stroke: its logical width (`<= 0` for the style's default) and its dash
|
||||||
|
/// (`dash.off <= 0` for solid). The instrument's own, like its colour -- see `Cursor::width`.
|
||||||
|
void set_cursor_stroke(u32 id, f32 width, Vec2 dash);
|
||||||
|
/// Makes a cursor unselectable (see `Cursor::selectable`): the pointer cannot hover or
|
||||||
|
/// grab it, which is what a host wants for a marker it draws as a *readout*. An id that
|
||||||
|
/// names no cursor is ignored, like every other setter here.
|
||||||
|
void set_cursor_selectable(u32 id, bool selectable);
|
||||||
|
/// Sets the value an axis' labels are written relative to (see `Axis::origin`). The ticks
|
||||||
|
/// are generated on that shifted grid too, so a host that zooms into a window can label it
|
||||||
|
/// from its own left edge. An id that names no axis of that orientation is ignored, like the
|
||||||
|
/// other setters here -- x and y ids are separate sequences, so the orientation is part of
|
||||||
|
/// the call.
|
||||||
|
void set_x_axis_origin(u32 axis, f64 origin);
|
||||||
|
void set_y_axis_origin(u32 axis, f64 origin);
|
||||||
|
/// The origin the matching setter stored (0 for an axis that never had one).
|
||||||
|
f64 x_axis_origin(u32 axis) const;
|
||||||
|
f64 y_axis_origin(u32 axis) const;
|
||||||
|
/// Makes an axis' label origin follow its own range (see `Axis::origin_follows_range`): the
|
||||||
|
/// labels then count from the left edge the frame actually has, which is the only way a
|
||||||
|
/// host can label a *moving* window from its start without being a frame behind.
|
||||||
|
void set_x_axis_origin_from_range(u32 axis, bool on);
|
||||||
|
void set_y_axis_origin_from_range(u32 axis, bool on);
|
||||||
|
|
||||||
|
/// A one-line caption for the x axis, drawn as **chrome** in the bottom-left corner of the
|
||||||
|
/// axes with the legend's own backplate behind it. It exists for the thing a shifted origin
|
||||||
|
/// leaves unsaid: labels counted from a window's left edge are only readable beside the value
|
||||||
|
/// that edge has, and the reader adds the two. An `Annotation` cannot serve -- its anchor is a
|
||||||
|
/// data coordinate, so the caption crawls across the plot under a pan and drifts under a zoom,
|
||||||
|
/// which is the one thing a legend-like panel must not do. The text is the host's, because only
|
||||||
|
/// the host knows how much precision the sum needs: the axis' own label form carries three
|
||||||
|
/// significant figures, which is exactly what made the shifted origin necessary.
|
||||||
|
/// Empty (the default) draws nothing.
|
||||||
|
void set_x_note(std::string text);
|
||||||
|
const std::string& x_note() const noexcept { return x_note_; }
|
||||||
|
|
||||||
/// The measurement for `id`. Re-evaluated only when a cursor moved, when the
|
/// The measurement for `id`. Re-evaluated only when a cursor moved, when the
|
||||||
/// measure was added, or when the series underneath it changed: a different
|
/// measure was added, or when the series underneath it changed: a different
|
||||||
@@ -551,9 +627,11 @@ class Plot {
|
|||||||
|
|
||||||
/// A floor on the left gutter `layout()` reserves, in device pixels: the label
|
/// A floor on the left gutter `layout()` reserves, in device pixels: the label
|
||||||
/// strip and the title column together are never narrower than this, so `axes`
|
/// strip and the title column together are never narrower than this, so `axes`
|
||||||
/// never starts left of `viewport().x + min_y_gutter()`. 0.0 -- the default --
|
/// starts no further left than `viewport().x + min_y_gutter()` **up to the cap
|
||||||
/// imposes nothing and the plot sizes its strip from its own widest tick label,
|
/// below** -- a floor wider than half the viewport is clamped exactly as the
|
||||||
/// which is what a plot standing on its own has always done.
|
/// measured strip is, so the guarantee is `min(min_y_gutter(), y_budget)`. 0.0
|
||||||
|
/// -- the default -- imposes nothing and the plot sizes its strip from its own
|
||||||
|
/// widest tick label, which is what a plot standing on its own has always done.
|
||||||
///
|
///
|
||||||
/// It exists for `Figure`, which is the only thing that can know the answer: a row
|
/// It exists for `Figure`, which is the only thing that can know the answer: a row
|
||||||
/// cannot see its siblings' tick labels, so the figure lays each row out once to
|
/// cannot see its siblings' tick labels, so the figure lays each row out once to
|
||||||
@@ -567,6 +645,20 @@ class Plot {
|
|||||||
}
|
}
|
||||||
f32 min_y_gutter() const noexcept { return min_y_gutter_; }
|
f32 min_y_gutter() const noexcept { return min_y_gutter_; }
|
||||||
|
|
||||||
|
/// An empty band reserved between `axes`' right edge and the viewport's, in device
|
||||||
|
/// pixels. 0.0 -- the default -- is what the layout has always done: `axes` reaches
|
||||||
|
/// the viewport's right edge, which leaves the rightmost x tick label, *centred* on
|
||||||
|
/// that edge, half of it to be drawn in and nothing to draw it in -- and puts the
|
||||||
|
/// newest sample's ink on the frame. It is the mirror of the left gutter in purpose
|
||||||
|
/// and not in kind: nothing is drawn here, so there is nothing to measure and the
|
||||||
|
/// value is the caller's to choose. Reserved inside the same cap as the label and
|
||||||
|
/// title strips, so it can no more squeeze `axes` below the half-viewport floor than
|
||||||
|
/// they can. Negative values are clamped to 0.
|
||||||
|
void set_right_gutter(f32 device_px) noexcept {
|
||||||
|
right_gutter_ = device_px > 0.0f ? device_px : 0.0f;
|
||||||
|
}
|
||||||
|
f32 right_gutter() const noexcept { return right_gutter_; }
|
||||||
|
|
||||||
/// Grid segments for the primary axis pair (x axis 0, y axis 0), in data space.
|
/// Grid segments for the primary axis pair (x axis 0, y axis 0), in data space.
|
||||||
const Grid& grid() const noexcept { return grid_; }
|
const Grid& grid() const noexcept { return grid_; }
|
||||||
|
|
||||||
@@ -627,20 +719,31 @@ class Plot {
|
|||||||
/// dash, which live on `CrosshairLayout` because they depend on `ui_scale`.
|
/// dash, which live on `CrosshairLayout` because they depend on `ui_scale`.
|
||||||
const CrosshairStyle& crosshair_style() const noexcept { return crosshair_.style; }
|
const CrosshairStyle& crosshair_style() const noexcept { return crosshair_.style; }
|
||||||
|
|
||||||
|
/// Chooses what a readout's numbers *are*: one of the series' own samples, or a value
|
||||||
|
/// computed between two of them. `CrosshairValue` argues the two cases and says where each
|
||||||
|
/// puts the marker; the default is `NearestSample`.
|
||||||
|
///
|
||||||
|
/// Deliberately **not** part of `set_crosshair()`, like the readout switch and the style
|
||||||
|
/// override: it is a statement about the data, so a pointer move must not be able to change
|
||||||
|
/// it. It takes effect on the next `layout()` and reaches `crosshair_value()` immediately.
|
||||||
|
void set_crosshair_value_mode(CrosshairValue mode) noexcept { crosshair_.value_mode = mode; }
|
||||||
|
/// The readout's value mode, as set -- see `set_crosshair_value_mode()`.
|
||||||
|
CrosshairValue crosshair_value_mode() const noexcept { return crosshair_.value_mode; }
|
||||||
|
|
||||||
/// The crosshair's state: where it is, which lines it draws and whether the readout is
|
/// The crosshair's state: where it is, which lines it draws and whether the readout is
|
||||||
/// on. Read-only -- `set_crosshair()` and `clear_crosshair()` are how it moves -- so the
|
/// on. Read-only -- `set_crosshair()` and `clear_crosshair()` are how it moves -- so the
|
||||||
/// coordinates it holds and the geometry `layout()` places from them cannot disagree.
|
/// coordinates it holds and the geometry `layout()` places from them cannot disagree.
|
||||||
const Crosshair& crosshair() const noexcept { return crosshair_; }
|
const Crosshair& crosshair() const noexcept { return crosshair_; }
|
||||||
|
|
||||||
/// The **interpolated** value of `trace`'s series at a data `x`: `crosshair_span()`
|
/// The value this plot's readout would report for `trace` at a data `x`: the nearer
|
||||||
/// between the two adjacent samples, then `crosshair_interpolate()` -- the same lookup
|
/// bracketing sample's y, or the blend of the two, according to
|
||||||
/// the readout's rows are built from, exposed for a host that wants the number for
|
/// `crosshair_value_mode()` -- the same lookup the readout's rows are built from, exposed
|
||||||
/// itself (a status line, a tooltip of its own).
|
/// for a host that wants the number for itself (a status line, a tooltip of its own).
|
||||||
///
|
///
|
||||||
/// False, leaving `out_y` untouched, for an id that names no trace, a trace with no
|
/// False, leaving `out_y` untouched, for an id that names no trace, a trace with no
|
||||||
/// series, and an `x` the series has no sample at -- the cases `CrosshairSpan::valid`
|
/// series, and an `x` the series has no sample at -- the cases `CrosshairSpan::valid`
|
||||||
/// documents, plus the trace itself. Never a clamped or nearest-sample answer: see
|
/// documents, plus the trace itself. Never a clamped answer: see `crosshair_span()` for why
|
||||||
/// `crosshair_span()` for why an out-of-window x is not a value.
|
/// an out-of-window x is not a value.
|
||||||
///
|
///
|
||||||
/// O(1) and allocation-free once the plot has looked a value up before: the span is
|
/// O(1) and allocation-free once the plot has looked a value up before: the span is
|
||||||
/// index arithmetic and the samples are read through one `Series::gather()` of at most
|
/// index arithmetic and the samples are read through one `Series::gather()` of at most
|
||||||
@@ -648,6 +751,17 @@ class Plot {
|
|||||||
/// on every pointer move -- once per visible trace, so nothing here may scan a series.
|
/// on every pointer move -- once per visible trace, so nothing here may scan a series.
|
||||||
bool crosshair_value(u32 trace, f64 x, f64& out_y) const;
|
bool crosshair_value(u32 trace, f64 x, f64& out_y) const;
|
||||||
|
|
||||||
|
/// The whole **point** the readout reports, x as well as y: `crosshair_value()`'s number
|
||||||
|
/// together with the data x it belongs to. In `CrosshairValue::NearestSample` that x is the
|
||||||
|
/// quoted sample's own, which is generally not the `x` asked for; in `Interpolated` it is
|
||||||
|
/// `x` itself.
|
||||||
|
///
|
||||||
|
/// This is what places `CrosshairRow::marker`, and it is public because a host that draws
|
||||||
|
/// instruments of its own -- a cursor pinned to the value it reads, an annotation naming the
|
||||||
|
/// sample -- needs the same snap, and recomputing it would be a second rule that can drift
|
||||||
|
/// from this one. Same failure cases, same cost, same scratch buffer as `crosshair_value()`.
|
||||||
|
bool crosshair_point(u32 trace, f64 x, f64& out_x, f64& out_y) const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
/// One measure plus its cache. The dirty flag is what keeps measurement() from
|
/// One measure plus its cache. The dirty flag is what keeps measurement() from
|
||||||
/// re-evaluating every frame; `last_series`, `last_size` and `last_x0` catch a
|
/// re-evaluating every frame; `last_series`, `last_size` and `last_x0` catch a
|
||||||
@@ -755,7 +869,7 @@ class Plot {
|
|||||||
/// never names a trace.
|
/// never names a trace.
|
||||||
void build_legend();
|
void build_legend();
|
||||||
/// Measures the crosshair into `layout_.crosshair`: the two lines' stroke boxes, one row
|
/// Measures the crosshair into `layout_.crosshair`: the two lines' stroke boxes, one row
|
||||||
/// per visible trace with its interpolated value and marker, and the readout panel --
|
/// per visible trace with its nearest sample's value and marker, and the readout panel --
|
||||||
/// placed beside the crosshair, flipped where it would cross an edge and clamped, so
|
/// placed beside the crosshair, flipped where it would cross an edge and clamped, so
|
||||||
/// every rect it produces is inside `axes`. A plot with no visible trace still gets its
|
/// every rect it produces is inside `axes`. A plot with no visible trace still gets its
|
||||||
/// lines: the rows are the readout's, not the crosshair's.
|
/// lines: the rows are the readout's, not the crosshair's.
|
||||||
@@ -765,11 +879,16 @@ class Plot {
|
|||||||
render::Style style_{};
|
render::Style style_{};
|
||||||
const render::TextBackend* text_ = nullptr;
|
const render::TextBackend* text_ = nullptr;
|
||||||
Legend legend_{};
|
Legend legend_{};
|
||||||
|
/// The x axis' corner caption; see set_x_note(). Empty draws nothing.
|
||||||
|
std::string x_note_;
|
||||||
/// Which parts of the bottom x chrome are reserved at all; see set_x_strip_mode().
|
/// Which parts of the bottom x chrome are reserved at all; see set_x_strip_mode().
|
||||||
XStrip x_strip_ = XStrip::Both;
|
XStrip x_strip_ = XStrip::Both;
|
||||||
/// The floor a figure imposes on the left gutter; see set_min_y_gutter(). 0.0 means
|
/// The floor a figure imposes on the left gutter; see set_min_y_gutter(). 0.0 means
|
||||||
/// none, which is what a plot standing on its own keeps.
|
/// none, which is what a plot standing on its own keeps.
|
||||||
f32 min_y_gutter_ = 0.0f;
|
f32 min_y_gutter_ = 0.0f;
|
||||||
|
/// The empty band kept to the right of `axes`; see set_right_gutter(). 0.0 means none,
|
||||||
|
/// which is what the layout did before the setter existed.
|
||||||
|
f32 right_gutter_ = 0.0f;
|
||||||
/// Which handles a pointer is on, published by the input layer (see HotHandles).
|
/// Which handles a pointer is on, published by the input layer (see HotHandles).
|
||||||
HotHandles hot_{};
|
HotHandles hot_{};
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -45,7 +48,10 @@ void format_si(f64 v, char* buf, usize n, const char* unit = nullptr);
|
|||||||
/// field is derived from that one number, so no field can ever reach its radix:
|
/// field is derived from that one number, so no field can ever reach its radix:
|
||||||
/// 119.5 s is `2:00`, not `1:60`, and 3599.5 s is `1:00:00`.
|
/// 119.5 s is `2:00`, not `1:60`, and 3599.5 s is `1:00:00`.
|
||||||
///
|
///
|
||||||
/// Shapes, by magnitude: below 1 s `500ms`; below 60 s `1.5s`; below 1 h `1:30`;
|
/// Shapes, by magnitude: below 1 s the prefixed unit that keeps the mantissa in
|
||||||
|
/// `[1, 1000)` -- `500ms`, `5ms`, `500us`, `5us`, `5ns`, `5ps` (the micro sign is
|
||||||
|
/// UTF-8 U+00B5, as in `format_si`) -- with an exact zero printed as `0` rather
|
||||||
|
/// than as a zero of some unit; below 60 s `1.5s`; below 1 h `1:30`;
|
||||||
/// at or above 24 h, as a decimal day count `2.5d`. Between 1 h and 24 h the
|
/// at or above 24 h, as a decimal day count `2.5d`. Between 1 h and 24 h the
|
||||||
/// shape is `1:01:01` -- and because a value that rounds up to 24 h takes the day
|
/// shape is `1:01:01` -- and because a value that rounds up to 24 h takes the day
|
||||||
/// shape, the hour field of that shape is always below 24. The 60 s and 1 h
|
/// shape, the hour field of that shape is always below 24. The 60 s and 1 h
|
||||||
@@ -65,14 +71,25 @@ 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.
|
||||||
|
///
|
||||||
|
/// A range that straddles the origin gets a tick of **exactly** `0.0` there, labelled
|
||||||
|
/// `0`. The walk cannot land on the origin by itself -- it starts at
|
||||||
|
/// `ceil(min / stride) * stride`, which is not representable whenever the stride is
|
||||||
|
/// not a power of two -- so the tick that means zero arrives a few ULP off and would
|
||||||
|
/// otherwise be labelled from the far end of the SI table (`-139a` between `-500m` and
|
||||||
|
/// `500m`). Every tick is an integer multiple of the stride, so anything within a
|
||||||
|
/// millionth of one *is* the origin and nothing else; a range whose ticks are
|
||||||
|
/// legitimately that small keeps them.
|
||||||
///
|
///
|
||||||
/// 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
|
||||||
/// single-sample series), a non-positive `px` or `min_px`, and a span whose step
|
/// single-sample series), a non-positive `px` or `min_px`, and a span whose step
|
||||||
/// overflows to infinity.
|
/// overflows to infinity.
|
||||||
void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out,
|
void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out,
|
||||||
bool include_minors = false);
|
bool include_minors = false, f64 origin = 0.0);
|
||||||
|
|
||||||
} // namespace plot
|
} // namespace plot
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <plot/core/color.hpp>
|
#include <plot/core/color.hpp>
|
||||||
#include <plot/core/types.hpp>
|
#include <plot/core/types.hpp>
|
||||||
@@ -6,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 {
|
||||||
@@ -13,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
|
||||||
@@ -23,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
|
||||||
@@ -32,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; }
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <plot/core/status.hpp>
|
#include <plot/core/status.hpp>
|
||||||
#include <plot/core/types.hpp>
|
#include <plot/core/types.hpp>
|
||||||
@@ -32,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;
|
||||||
@@ -43,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
|
||||||
@@ -134,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; }
|
||||||
@@ -162,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.
|
||||||
@@ -183,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(); }
|
||||||
|
|
||||||
@@ -203,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(); }
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#ifdef PLOT_RING_STATS
|
#ifdef PLOT_RING_STATS
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <plot/core/types.hpp>
|
#include <plot/core/types.hpp>
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <plot/core/types.hpp>
|
#include <plot/core/types.hpp>
|
||||||
#include <plot/math/vec2.hpp>
|
#include <plot/math/vec2.hpp>
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// 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>
|
||||||
@@ -57,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
|
||||||
@@ -89,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
|
||||||
@@ -101,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 --
|
||||||
@@ -121,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_{};
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <plot/core/color.hpp>
|
#include <plot/core/color.hpp>
|
||||||
#include <plot/core/status.hpp>
|
#include <plot/core/status.hpp>
|
||||||
@@ -32,8 +35,19 @@ 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;
|
||||||
|
/// `u_snap`: with it on, an **axis-aligned** segment's ink is rounded onto whole
|
||||||
|
/// pixels along the axis it is constant on, so a hairline lights one column (or
|
||||||
|
/// row) fully instead of two at half coverage. It is for the chrome that frames a
|
||||||
|
/// plot -- grid lines, axis lines, panel and legend borders. Anything that reports
|
||||||
|
/// a data position -- a trace, a cursor, a region edge, the crosshair -- leaves it
|
||||||
|
/// off, because the rounding moves a line by up to half a pixel. A diagonal segment
|
||||||
|
/// is untouched whatever this says.
|
||||||
|
void set_snap(bool on) const noexcept;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
gl::Program program_;
|
gl::Program program_;
|
||||||
@@ -63,8 +77,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_;
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <span>
|
#include <span>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -26,8 +29,18 @@ namespace plot {
|
|||||||
struct FrameStats {
|
struct FrameStats {
|
||||||
u32 raw_traces = 0; ///< traces drawn from raw samples (level 0)
|
u32 raw_traces = 0; ///< traces drawn from raw samples (level 0)
|
||||||
u32 lod_traces = 0; ///< traces drawn from a min/max pyramid level
|
u32 lod_traces = 0; ///< traces drawn from a min/max pyramid level
|
||||||
|
/// Traces whose geometry was reduced onto the pixel grid: one vertical segment
|
||||||
|
/// per column, lit from that column's own extremes. Counts a trace from either
|
||||||
|
/// path above -- the reduction is about the *density* of the gathered points,
|
||||||
|
/// not about which level produced them.
|
||||||
|
u32 column_traces = 0;
|
||||||
|
/// Traces whose columns were graded by dwell rather than drawn as one flat band
|
||||||
|
/// (`Style::intensity_grading`). A subset of `column_traces`: grading is a way of
|
||||||
|
/// drawing the reduction, not a third path.
|
||||||
|
u32 graded_traces = 0;
|
||||||
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
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,10 +80,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
|
||||||
@@ -212,7 +241,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*:
|
||||||
@@ -281,15 +310,14 @@ class Renderer {
|
|||||||
/// Writes the framebuffer's viewport region as a binary PPM (P6, 8 bits per
|
/// Writes the framebuffer's viewport region as a binary PPM (P6, 8 bits per
|
||||||
/// channel, rows top-down) so a headless render can be looked at without a
|
/// channel, rows top-down) so a headless render can be looked at without a
|
||||||
/// window. Reads the framebuffer that is bound now, which is the one the frame
|
/// window. Reads the framebuffer that is bound now, which is the one the frame
|
||||||
/// was drawn into. Returns false, with `st` filled in, on a bad argument or an
|
/// was drawn into. Returns false, with `st` filled in, on a bad argument, an
|
||||||
/// unreadable pixel.
|
/// unreadable pixel, or an allocation the readback could not make -- which is
|
||||||
///
|
/// reported as `ErrorCode::OutOfMemory`, exactly as `TrueTypeFont::from_file()`
|
||||||
/// The one entry point here that is not `noexcept`: it sizes its readback hole
|
/// reports the same failure. `noexcept` like every other entry point here: the
|
||||||
/// and its output row, so an allocation failure propagates `std::bad_alloc`
|
/// buffers are sized before anything is on disk, so the two failures that can
|
||||||
/// rather than terminating a process that could have carried on without a
|
/// arise from sizing them are a `Status`, not a `std::bad_alloc` across the API
|
||||||
/// screenshot. A dump is a diagnostic, and no `Status` code could say more than
|
/// boundary and a half-written file.
|
||||||
/// the exception already does.
|
bool dump_frame_ppm(const char* path, Status* st = nullptr) const noexcept;
|
||||||
bool dump_frame_ppm(const char* path, Status* st = nullptr) const;
|
|
||||||
|
|
||||||
private:
|
private:
|
||||||
/// The backend the plot layer sees, at the size the text pipeline draws it:
|
/// The backend the plot layer sees, at the size the text pipeline draws it:
|
||||||
@@ -344,12 +372,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.
|
||||||
@@ -368,12 +403,27 @@ 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
|
||||||
void flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash = Vec2{}) noexcept;
|
/// likewise set on every flush, from `style().line_aa_px` in device pixels.
|
||||||
/// Draws every staged glyph in `style().axis_text` and empties the batch. Text
|
///
|
||||||
/// is texture-bound, so this runs once per frame, after all line geometry.
|
/// `snap` is `LineProgram::set_snap()`'s pixel-grid rounding: on for the chrome
|
||||||
void flush_text() noexcept;
|
/// that frames a plot -- the grid, the axis lines, a panel or legend border --
|
||||||
|
/// and off for everything drawn at a data position, since it moves an
|
||||||
|
/// axis-aligned line by up to half a pixel. Set on every flush, like `dash`.
|
||||||
|
void flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash = Vec2{},
|
||||||
|
bool snap = false) noexcept;
|
||||||
|
/// The same, for a batch other than the renderer's own `lines_`. The graded trace
|
||||||
|
/// pass stages one batch per alpha step, and a step has to be a draw call of its
|
||||||
|
/// own because the line pipeline takes its colour from a uniform -- so it needs to
|
||||||
|
/// flush a batch it chose rather than the member one. Every other caller flushes
|
||||||
|
/// `lines_` and uses the overload above.
|
||||||
|
void flush_lines(render::LineBatch& batch, const Mapping& map, Color color, f32 width,
|
||||||
|
Vec2 dash = Vec2{}, bool snap = false) noexcept;
|
||||||
|
/// Draws every staged glyph in `batch` -- each instance carries its own tint
|
||||||
|
/// (`a_colour`) -- and empties the batch. Text is texture-bound, so this runs
|
||||||
|
/// once per text batch, after the line geometry it belongs over.
|
||||||
|
void flush_text(render::TextBatch& batch) noexcept;
|
||||||
|
|
||||||
/// Narrows the frame's clip to `rect` -- framebuffer pixels, top-left origin, like every
|
/// 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
|
||||||
@@ -406,13 +456,21 @@ 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 `Series::gather`): x
|
||||||
Vec2 stage_text(Vec2 pos_px, const char* utf8);
|
/// **relative to `origin_x`**, which the caller asked the gather for, y absolute. On linear
|
||||||
|
/// axes that is already what the batch wants and the buffer is handed over as it is; a log
|
||||||
|
/// axis reconstructs the absolute x in f64 per segment and stages
|
||||||
|
/// `to_space(x_abs) - to_space(origin_x)` -- O(visible pixels), not O(epoch).
|
||||||
|
void stage_xy(const f32* xy, u32 n, 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.
|
||||||
@@ -428,7 +486,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
|
||||||
@@ -457,6 +516,29 @@ class Renderer {
|
|||||||
/// The data helpers take it too; they read the axes rect and the transforms it
|
/// The data helpers take it too; they read the axes rect and the transforms it
|
||||||
/// pins.
|
/// pins.
|
||||||
void draw_traces(const Plot& plot, const PlotLayout& layout);
|
void draw_traces(const Plot& plot, const PlotLayout& layout);
|
||||||
|
/// One gathered window reduced onto the pixel grid and staged in framebuffer
|
||||||
|
/// pixels: for each integer column of `layout.axes`, one exactly vertical
|
||||||
|
/// segment from the lowest to the highest screen y any gathered point put in
|
||||||
|
/// it. Returns how many columns were staged (0 stages nothing); the caller
|
||||||
|
/// flushes with `pixel_mapping()` and `snap = true`.
|
||||||
|
///
|
||||||
|
/// `gather_` is the input, read with the same origin `draw_traces` asked the
|
||||||
|
/// series for. See `draw_traces` for when this path is taken and why.
|
||||||
|
u32 stage_trace_columns(const Plot& plot, const PlotLayout& layout, const Trace& trace,
|
||||||
|
f64 x_origin, u32 points);
|
||||||
|
/// The same reduction, graded by **dwell**: a column is drawn as a stack of runs,
|
||||||
|
/// each at the alpha step its own share of the column's samples earns, so a flat
|
||||||
|
/// top is bright and the edge leading to it is faint (`Style::intensity_grading`).
|
||||||
|
///
|
||||||
|
/// Stages into `steps_`, one `LineBatch` per alpha step, which the caller flushes
|
||||||
|
/// in turn -- the line pipeline's colour is a uniform, so a step is a draw call.
|
||||||
|
/// Returns the segments staged across all steps (0 stages nothing).
|
||||||
|
///
|
||||||
|
/// `gather_` is the input and must have been asked for at the *finer* level
|
||||||
|
/// `Style::intensity_sub_columns` implies; a column's dwell is not recoverable
|
||||||
|
/// from its own min and max (see `Style::intensity_grading`).
|
||||||
|
u32 stage_trace_density(const Plot& plot, const PlotLayout& layout, const Trace& trace,
|
||||||
|
f64 x_origin, u32 points);
|
||||||
/// The cursors, both orientations: one stroke per cursor, placed at the coordinate its own
|
/// The cursors, both orientations: one stroke per cursor, placed at the coordinate its own
|
||||||
/// orientation owns (`Plot::cursor_screen_coordinate()`) and spanning the other axis' whole
|
/// orientation owns (`Plot::cursor_screen_coordinate()`) and spanning the other axis' whole
|
||||||
/// range, in its own colour -- or in the accent at handle width while it is hovered or
|
/// range, in its own colour -- or in the accent at handle width while it is hovered or
|
||||||
@@ -492,6 +574,12 @@ class Renderer {
|
|||||||
/// them again, so the panel a test asserts and the panel this draws are the same
|
/// them again, so the panel a test asserts and the panel this draws are the same
|
||||||
/// numbers.
|
/// numbers.
|
||||||
void draw_legend(const Plot& plot, const PlotLayout& layout);
|
void draw_legend(const Plot& plot, const PlotLayout& layout);
|
||||||
|
/// `Plot::x_note()`, in the bottom-left corner of the axes on the legend's backplate. It is
|
||||||
|
/// measured here rather than in `Plot::layout()` on purpose: unlike a legend it reserves no
|
||||||
|
/// space and nothing can be picked on it, so there is no measurement a host or a test needs to
|
||||||
|
/// read back, and a caption that explains the x labels must not be able to move the layout the
|
||||||
|
/// labels were measured into.
|
||||||
|
void draw_x_note(const Plot& plot, const PlotLayout& layout);
|
||||||
/// The backplate the legend and the crosshair's readout share: the frame's background at
|
/// The backplate the legend and the crosshair's readout share: the frame's background at
|
||||||
/// `kLegendPanelAlpha` over `panel`, with a one-device-pixel border in `border` around the
|
/// `kLegendPanelAlpha` over `panel`, with a one-device-pixel border in `border` around the
|
||||||
/// outermost pixels the fill covers (the crisp-hairline rule -- a border drawn on the measured
|
/// outermost pixels the fill covers (the crisp-hairline rule -- a border drawn on the measured
|
||||||
@@ -529,6 +617,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
|
||||||
@@ -547,8 +641,34 @@ 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
|
||||||
|
/// One (lowest y, highest y) pair per pixel column of the axes, re-used:
|
||||||
|
/// `stage_trace_columns()`'s working set. Two floats per column, so an 8K-wide
|
||||||
|
/// axis is 64 kB -- bounded by the *screen*, where `gather_` is bounded by the
|
||||||
|
/// window.
|
||||||
|
std::vector<f32> columns_;
|
||||||
|
/// `stage_trace_density()`'s working set, and deliberately **one column wide**: the
|
||||||
|
/// gathered points arrive in x order, so a column can be accumulated, read and
|
||||||
|
/// cleared before the next one starts. A full-axes density image would be
|
||||||
|
/// `axes.w * axes.h` (33 MB at 4K); this is `axes.h` (a few kB) and stays in L1.
|
||||||
|
///
|
||||||
|
/// Each sub-bucket's span is written as a +1 at its first row and a -1 one past
|
||||||
|
/// its last, so accumulating a span costs two writes whatever its height; the
|
||||||
|
/// running sum over the column is then how many sub-buckets cover each row, which
|
||||||
|
/// is the dwell.
|
||||||
|
std::vector<f32> column_rows_;
|
||||||
|
/// One batch per alpha step of the grading (`Style::intensity_steps`), re-used
|
||||||
|
/// across frames: the line pipeline takes its colour from a uniform, so segments
|
||||||
|
/// at different alphas cannot share a draw call and must be staged apart.
|
||||||
|
std::vector<render::LineBatch> steps_;
|
||||||
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
|
||||||
@@ -557,6 +677,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;
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
namespace plot::render {
|
namespace plot::render {
|
||||||
@@ -13,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.
|
||||||
///
|
///
|
||||||
@@ -39,15 +44,27 @@ extern const char* const kLineFS;
|
|||||||
/// the cell's own +v direction -- so `a_axis` of `(0, -1)` draws a run that
|
/// the cell's own +v direction -- so `a_axis` of `(0, -1)` draws a run that
|
||||||
/// advances up the screen, and it maps `a_uv` over the cell with the same
|
/// advances up the screen, and it maps `a_uv` over the cell with the same
|
||||||
/// 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. It also passes the cell's origin through `flat`, which is the bound
|
||||||
/// single-channel `GL_R8` atlas at the nearest texel centre, so `GL_NEAREST`
|
/// the fragment shader filters against.
|
||||||
/// never lands on a cell boundary. `u_color` tints the straight alpha.
|
///
|
||||||
|
/// The fragment shader converts back to texels and blends the four texels the
|
||||||
|
/// fragment sits between, **clamped to the instance's own cell**. Filtering is
|
||||||
|
/// the shader's, not the sampler's: every fetch is an explicit texel centre, so
|
||||||
|
/// `GL_NEAREST` stays correct, and the clamp keeps a footprint reaching past a
|
||||||
|
/// cell edge from blending in the neighbouring glyph of what is a tight,
|
||||||
|
/// unpadded strip of cells. A point sample would have been enough only at an
|
||||||
|
/// integer scale: at the library's own default of a 12 px style on the 13 px
|
||||||
|
/// embedded bake it *deleted* one of every 8 columns and one of every 17 rows of
|
||||||
|
/// every glyph. At scale 1 with a whole pen the two taps coincide and the result
|
||||||
|
/// is exactly as crisp as a point sample. 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; read in *both* stages, the fragment
|
||||||
/// texels), `u_atlas` (sampler2D, unit 0), `u_color` (vec4).
|
/// one needing it for the clamp), `u_tex_size` (vec2, atlas size in 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;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <plot/core/color.hpp>
|
#include <plot/core/color.hpp>
|
||||||
|
|
||||||
@@ -35,8 +38,19 @@ struct Style {
|
|||||||
// --- colours: neither logical nor device, so nothing to scale ------------
|
// --- colours: neither logical nor device, so nothing to scale ------------
|
||||||
|
|
||||||
Color background = Color::hex(0x101418);
|
Color background = Color::hex(0x101418);
|
||||||
Color grid_major = Color::hex(0x2A3038);
|
/// The labelled grid. Brighter than the subdivisions and still well under `axis_line`
|
||||||
Color grid_minor = Color::hex(0x1C2126);
|
/// (0x8A94A6): the grid is structure the eye can read, the axis is the frame, and the
|
||||||
|
/// hierarchy between the three is what keeps a dense plot legible. It is already fully
|
||||||
|
/// opaque, so "more prominent" means a brighter colour and nothing else.
|
||||||
|
Color grid_major = Color::hex(0x39414C);
|
||||||
|
/// The minor grid's colour, **with an alpha** so it blends into the background rather than
|
||||||
|
/// sitting on it: a subdivision is a reading aid, and at full opacity a dense minor grid
|
||||||
|
/// reads as a second set of ticks competing with the labelled one. The line shader applies
|
||||||
|
/// `u_color.a` (see `src/render/shaders.cpp`), so this one blends and the major -- opaque at
|
||||||
|
/// 0x39414C -- stays the structure the eye picks out. Together with `grid_minor_width` and
|
||||||
|
/// the dash below, the defaults put a minor line at an order of magnitude less ink than a
|
||||||
|
/// major one.
|
||||||
|
Color grid_minor = Color::hex(0x1C2126, 0.5f);
|
||||||
Color axis_line = Color::hex(0x8A94A6);
|
Color axis_line = Color::hex(0x8A94A6);
|
||||||
Color axis_text = Color::hex(0xC8D0DC);
|
Color axis_text = Color::hex(0xC8D0DC);
|
||||||
Color cursor = Color::hex(0xFFB020);
|
Color cursor = Color::hex(0xFFB020);
|
||||||
@@ -50,8 +64,12 @@ struct Style {
|
|||||||
/// (`Trace`'s default, 0x40C0FF) that a hue-based reading cannot tell from a cyan crosshair --
|
/// (`Trace`'s default, 0x40C0FF) that a hue-based reading cannot tell from a cyan crosshair --
|
||||||
/// only lightness would separate them, which no reader can rely on. A near-white can never be a
|
/// only lightness would separate them, which no reader can rely on. A near-white can never be a
|
||||||
/// saturated trace of *any* hue, and it is still plainly not `cursor`'s orange, which is what
|
/// saturated trace of *any* hue, and it is still plainly not `cursor`'s orange, which is what
|
||||||
/// the spec asks for. The 1 px stroke is kept: form (a full-height thin line over a polyline) is
|
/// the spec asks for.
|
||||||
/// already distinct, so no dashes.
|
///
|
||||||
|
/// The stroke is thin and dashed by default -- see `crosshair_width`, `crosshair_dash_on` and
|
||||||
|
/// `crosshair_dash_off` below for the values: thinner than a trace so the instrument reads as
|
||||||
|
/// chrome rather than data, and dashed so it is distinguishable by *form* as well as by
|
||||||
|
/// lightness.
|
||||||
Color crosshair = Color::hex(0xE6EBF2);
|
Color crosshair = Color::hex(0xE6EBF2);
|
||||||
|
|
||||||
/// The crosshair's stroke and dash, in logical pixels (spec §17). The crosshair is a
|
/// The crosshair's stroke and dash, in logical pixels (spec §17). The crosshair is a
|
||||||
@@ -68,6 +86,21 @@ struct Style {
|
|||||||
f32 crosshair_width = 0.75f;
|
f32 crosshair_width = 0.75f;
|
||||||
f32 crosshair_dash_on = 4.0f;
|
f32 crosshair_dash_on = 4.0f;
|
||||||
f32 crosshair_dash_off = 4.0f;
|
f32 crosshair_dash_off = 4.0f;
|
||||||
|
/// The full extent, in logical pixels, of the little cross drawn at each trace's readout
|
||||||
|
/// point (`CrosshairRow::marker`), in that trace's own colour.
|
||||||
|
///
|
||||||
|
/// **Deliberately larger than `marker_px`**, which is what a trace's own samples are dotted
|
||||||
|
/// with at a deep zoom. In the default `CrosshairValue::NearestSample` the readout's mark is
|
||||||
|
/// *snapped to a sample*, so it lands among those dots -- at the same size it would read as
|
||||||
|
/// one more sample instead of as "this is the one the panel is quoting". The lines are chrome
|
||||||
|
/// and recede; this mark is the answer to the question the hover asks, and it is the one
|
||||||
|
/// crosshair element that should not.
|
||||||
|
///
|
||||||
|
/// Its stroke is `crosshair_marker_width`, thicker than the lines' for the same reason. `0`
|
||||||
|
/// across draws no mark at all, which is how a caller that wants the panel without the marks
|
||||||
|
/// says so.
|
||||||
|
f32 crosshair_marker_px = 9.0f;
|
||||||
|
f32 crosshair_marker_width = 1.5f;
|
||||||
|
|
||||||
// --- the scale ----------------------------------------------------------
|
// --- the scale ----------------------------------------------------------
|
||||||
|
|
||||||
@@ -89,12 +122,39 @@ struct Style {
|
|||||||
/// skips it before the first segment is staged, so a dense plot's minor grid
|
/// skips it before the first segment is staged, so a dense plot's minor grid
|
||||||
/// costs nothing rather than being drawn transparent (spec §11). The colours
|
/// costs nothing rather than being drawn transparent (spec §11). The colours
|
||||||
/// above are already per class.
|
/// above are already per class.
|
||||||
|
/// When a trace's window holds at most this many raw samples, each one is also drawn as a dot
|
||||||
|
/// `marker_px` logical pixels across, in the trace's own colour. `0` draws none. Zoomed in far
|
||||||
|
/// enough to see individual samples -- a handful of them across a whole panel -- the polyline
|
||||||
|
/// *between* them stops being the shape, and the samples themselves are what a reader is looking
|
||||||
|
/// at; a marker makes each one a place rather than a bend in a line.
|
||||||
|
f32 marker_below_samples = 50.0f;
|
||||||
|
f32 marker_px = 3.0f;
|
||||||
f32 grid_major_width = 1.0f; ///< logical width of a major (labelled-tick) line.
|
f32 grid_major_width = 1.0f; ///< logical width of a major (labelled-tick) line.
|
||||||
f32 grid_minor_width = 1.0f; ///< logical width of a minor (subdivision) line.
|
/// Logical width of a minor (subdivision) line: **thinner than the major hairline**, and
|
||||||
|
/// sub-pixel on purpose. At 1x that is expressed as *coverage* -- the anti-aliased ramp
|
||||||
|
/// (`line_aa_px`) lights one column at roughly three quarters of the ink -- and on a HiDPI
|
||||||
|
/// display as a line that is literally narrower. The point is hierarchy: the labelled grid is
|
||||||
|
/// structure, the subdivisions are texture.
|
||||||
|
f32 grid_minor_width = 0.75f;
|
||||||
bool grid_major_visible = true; ///< stage the major grid at all.
|
bool grid_major_visible = true; ///< stage the major grid at all.
|
||||||
bool grid_minor_visible = true; ///< stage the minor grid at all.
|
bool grid_minor_visible = true; ///< stage the minor grid at all.
|
||||||
|
/// The minor grid's dash, in logical pixels: ink length and gap. **`off == 0` draws it
|
||||||
|
/// solid** -- the same convention as the crosshair's and a trace's dash -- which is what a
|
||||||
|
/// caller that wants the old solid subdivision states. Dashed is the default because an
|
||||||
|
/// unlabelled line at an unlabelled position should never be mistaken for a labelled one, and
|
||||||
|
/// because a broken line carries less weight over data than a continuous one. The dash is a
|
||||||
|
/// fragment discard, never extra geometry, so a dense minor grid costs no more vertices for
|
||||||
|
/// being dashed.
|
||||||
|
f32 grid_minor_dash_on = 3.0f;
|
||||||
|
f32 grid_minor_dash_off = 3.0f;
|
||||||
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
|
||||||
@@ -117,6 +177,82 @@ struct Style {
|
|||||||
///< sides draw through the one instanced
|
///< sides draw through the one instanced
|
||||||
///< anti-aliased quad path.
|
///< anti-aliased quad path.
|
||||||
|
|
||||||
|
// --- intensity grading: counts and ratios, never sizes -------------------
|
||||||
|
|
||||||
|
/// Draw a dense trace's pixel columns **graded by how long the signal dwells at
|
||||||
|
/// each height**, the way a digital-phosphor scope does, instead of as one flat
|
||||||
|
/// band per column: bright where samples pile up (a flat top, a peak), faint
|
||||||
|
/// where they only pass through (an edge, a noise excursion).
|
||||||
|
///
|
||||||
|
/// Off by default, because it is a change of *look* and not a correction: the
|
||||||
|
/// flat band is the honest answer to "what did this column reach", and a host
|
||||||
|
/// that wants that -- or that draws few enough samples never to take the column
|
||||||
|
/// path at all -- should not have its traces restyled by an upgrade. A scope-like
|
||||||
|
/// host turns it on.
|
||||||
|
///
|
||||||
|
/// It is not derivable from the flat band. A column's min and max are the same
|
||||||
|
/// whether its samples fill the span or sit piled at its two ends, which is
|
||||||
|
/// exactly the difference between a ramp and a pulse -- so grading needs the
|
||||||
|
/// gather asked at a *finer* level than the pixel grid (`intensity_sub_columns`)
|
||||||
|
/// and reads the distribution from those sub-buckets.
|
||||||
|
///
|
||||||
|
/// The measure is *dwell per row*, and the weighting is the whole of it: each
|
||||||
|
/// sub-column segment deposits **one unit spread over the rows it spans**, so a
|
||||||
|
/// tall crossing leaves a thin smear and a short one at a turning point leaves a
|
||||||
|
/// bright mark. Counting a row at +1 per segment that touches it instead -- the
|
||||||
|
/// obvious thing -- grades a sine's fast middle as brightly as its rails, which
|
||||||
|
/// is the pattern inverted.
|
||||||
|
bool intensity_grading = false;
|
||||||
|
|
||||||
|
/// Sub-buckets per pixel column the gather is asked for when grading: the level
|
||||||
|
/// is chosen for `samples_per_pixel / intensity_sub_columns`, so a column's
|
||||||
|
/// density is read from this many envelopes instead of from one.
|
||||||
|
///
|
||||||
|
/// It buys resolution in *dwell*, linearly, for the same factor in gathered
|
||||||
|
/// points -- and the points stay bounded by the screen, which is the property
|
||||||
|
/// that matters (8 of them over a 1000 px axis is 16 000 points whatever the
|
||||||
|
/// window holds). Below about 4 a pulse's flat levels and its edge are too few
|
||||||
|
/// samples apart to separate.
|
||||||
|
///
|
||||||
|
/// It is a *want*, not a promise: the renderer floors the level it asks for at the
|
||||||
|
/// pyramid's coarsest bucket level, so a window too sparse to hold this many buckets
|
||||||
|
/// a column is graded from however many it does hold. Without that floor `level_for`
|
||||||
|
/// answers 0 -- raw samples -- the moment a sub-column falls under one bucket, which
|
||||||
|
/// turns a frame the flat path draws off the pyramid into an O(visible) gather. So
|
||||||
|
/// raising this past about 8 costs resolution nothing and buys it nothing either: the
|
||||||
|
/// bucket size is the limit, not this number.
|
||||||
|
u32 intensity_sub_columns = 8;
|
||||||
|
|
||||||
|
/// Quantisation of the grading, in alpha steps. Each step is one draw call (the
|
||||||
|
/// line pipeline's colour is a uniform, so a per-segment alpha would mean a
|
||||||
|
/// per-instance one), and the segments are bounded by the columns, so the cost
|
||||||
|
/// of a step is a draw call over a few hundred instances.
|
||||||
|
///
|
||||||
|
/// Four is enough to read a waveform's structure and few enough that a step is a
|
||||||
|
/// visible tier rather than a gradient -- the point is to separate "dwelt here"
|
||||||
|
/// from "passed through", not to measure.
|
||||||
|
u32 intensity_steps = 4;
|
||||||
|
|
||||||
|
/// Alpha the faintest graded step is drawn at, as a fraction of the trace's own.
|
||||||
|
///
|
||||||
|
/// Not 0: a column the signal merely passed through is still a column the signal
|
||||||
|
/// *reached*, and a riser dimmed out of existence is a square wave drawn as two
|
||||||
|
/// disconnected lines. This is the floor that keeps the trace continuous.
|
||||||
|
f32 intensity_floor = 0.3f;
|
||||||
|
|
||||||
|
/// Exponent the normalised dwell is raised to before it is quantised. Below 1 it
|
||||||
|
/// lifts the faint end, which is what makes a fast edge visible beside a flat top
|
||||||
|
/// whose dwell is three orders of magnitude greater.
|
||||||
|
///
|
||||||
|
/// The dwell is normalised **against the column's own peak**, not against a scale
|
||||||
|
/// for the whole frame, so the steps spend their range on the structure *inside*
|
||||||
|
/// each column. One scale for the frame needs the whole `w * h` density image
|
||||||
|
/// (33 MB at 4K, which is why the accumulator is one column wide) and reads
|
||||||
|
/// worse: a column the signal flew straight through holds little dwell at any
|
||||||
|
/// height, so it would grade uniformly faint -- a trace that fades out exactly
|
||||||
|
/// where it moves fastest.
|
||||||
|
f32 intensity_gamma = 0.45f;
|
||||||
|
|
||||||
/// `logical` in device pixels: the single conversion point for every logical
|
/// `logical` in device pixels: the single conversion point for every logical
|
||||||
/// metric above, so the arithmetic appears once per consumer instead of a `*
|
/// metric above, so the arithmetic appears once per consumer instead of a `*
|
||||||
/// ui_scale` scattered over the renderer and the layout.
|
/// ui_scale` scattered over the renderer and the layout.
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <plot/core/status.hpp>
|
#include <plot/core/status.hpp>
|
||||||
#include <plot/core/types.hpp>
|
#include <plot/core/types.hpp>
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <plot/core/status.hpp>
|
#include <plot/core/status.hpp>
|
||||||
#include <plot/core/types.hpp>
|
#include <plot/core/types.hpp>
|
||||||
@@ -51,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(); }
|
||||||
@@ -81,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
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <span>
|
#include <span>
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#include <plot/core/status.hpp>
|
#include <plot/core/status.hpp>
|
||||||
|
|
||||||
namespace plot {
|
namespace plot {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#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>
|
||||||
|
|
||||||
@@ -20,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_; }
|
||||||
@@ -49,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, f64 x_origin, std::vector<f32>& out_xy,
|
||||||
gather_impl(level, begin, end, out_xy);
|
GatherMode mode) const {
|
||||||
|
gather_impl(level, begin, end, x_origin, 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 {
|
||||||
|
|||||||
+42
-14
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#include <plot/data/detail/cover_count.hpp>
|
#include <plot/data/detail/cover_count.hpp>
|
||||||
#include <plot/data/detail/merge_count.hpp>
|
#include <plot/data/detail/merge_count.hpp>
|
||||||
#include <plot/data/minmax_pyramid.hpp>
|
#include <plot/data/minmax_pyramid.hpp>
|
||||||
@@ -9,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) {
|
||||||
@@ -69,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);
|
||||||
@@ -93,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,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
|
||||||
@@ -138,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
|
||||||
@@ -149,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,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};
|
||||||
@@ -262,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;
|
||||||
|
|||||||
+140
-27
@@ -1,6 +1,10 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#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 {
|
||||||
@@ -10,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 {
|
||||||
@@ -72,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;
|
||||||
@@ -81,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,77 +125,177 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
void Series::gather_raw(u64 begin, u64 end, std::vector<f32>& out_xy) const {
|
f64 Series::x_at(u64 i) const { return x0() + f64(i) * dx(); }
|
||||||
const f64 x_0 = x0();
|
|
||||||
|
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();
|
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, f64 x_origin, std::vector<f32>& out_xy) const {
|
||||||
|
const f64 step = dx();
|
||||||
|
// Sample i sits at `x0() + i*step`, so this is `x_at(i) - x_origin` with the subtraction done
|
||||||
|
// once in f64 rather than per sample: see gather() for why the caller owns the origin.
|
||||||
|
const f64 shift = x0() - x_origin;
|
||||||
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(shift + f64(i + k) * step)); // x relative to x_origin: 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, f64 x_origin, 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);
|
||||||
if (b >= e) return;
|
if (b >= e) return;
|
||||||
|
|
||||||
if (level == 0) {
|
if (level == 0) {
|
||||||
gather_raw(b, e, out_xy);
|
gather_raw(b, e, x_origin, 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 f64 shift = x0() - x_origin; // as in gather_raw(): x_at(i) - x_origin = shift + i*step
|
||||||
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.
|
||||||
gather_raw(b, e, out_xy);
|
gather_raw(b, e, x_origin, out_xy);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
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), x_origin, 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(shift + f64(first_sample) * step)); // x relative to x_origin
|
||||||
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(shift + 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(shift + f64(centre) * step)); // x relative to x_origin
|
||||||
|
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(shift + f64(centre) * step)); // x relative to x_origin
|
||||||
|
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, x_origin, out_xy);
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace plot::data
|
} // namespace plot::data
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#include <plot/data/stream_series.hpp>
|
#include <plot/data/stream_series.hpp>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
@@ -15,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); }
|
||||||
@@ -57,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_; }
|
||||||
@@ -72,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, f64 x_origin, std::vector<f32>& out_xy,
|
||||||
gather_impl(level, begin, end, out_xy);
|
GatherMode mode) const {
|
||||||
|
gather_impl(level, begin, end, x_origin, 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 {
|
||||||
|
|||||||
@@ -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, f64 x_origin, 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 the caller's origin: see Series::gather(). The x array is f64, so
|
||||||
|
// the subtraction happens wide and only the small, exact offset narrows.
|
||||||
|
const f64 origin = x_origin;
|
||||||
|
|
||||||
|
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 the caller's origin.
|
||||||
|
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
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#include <plot/gl/handle.hpp>
|
#include <plot/gl/handle.hpp>
|
||||||
|
|
||||||
#include <plot/gl/loader.hpp>
|
#include <plot/gl/loader.hpp>
|
||||||
@@ -314,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 {
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#include <plot/gl/loader.hpp>
|
#include <plot/gl/loader.hpp>
|
||||||
|
|
||||||
#include <plot/gl/caps.hpp>
|
#include <plot/gl/caps.hpp>
|
||||||
|
|||||||
+300
-21
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#include <plot/input/controller.hpp>
|
#include <plot/input/controller.hpp>
|
||||||
|
|
||||||
#include <plot/math/transform.hpp>
|
#include <plot/math/transform.hpp>
|
||||||
@@ -36,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
|
||||||
@@ -133,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) {
|
||||||
@@ -155,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);
|
||||||
@@ -180,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
|
||||||
@@ -194,29 +246,106 @@ 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;
|
||||||
const f64 factor = zoom_factor(notches);
|
const f64 factor = zoom_factor(notches);
|
||||||
|
// Ctrl restricts the zoom to x, Alt to y; bare +/- zooms both. (Shift cannot be
|
||||||
|
// used: '+' is shift+'=' on common layouts, so key_from() folds them together and
|
||||||
|
// a bare zoom-in already arrives with Mod::Shift set.) Ctrl+Alt zooms neither,
|
||||||
|
// which is harmless and needs no special case.
|
||||||
|
const bool only_x = has_mod(e.mods, Mod::Ctrl);
|
||||||
|
const bool only_y = has_mod(e.mods, Mod::Alt);
|
||||||
|
if (!only_y) {
|
||||||
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
|
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
|
||||||
const Axis& a = plot_->x_axis(i);
|
const Axis& a = plot_->x_axis(i);
|
||||||
const f64 lo = to_space(a.range.min, a.log);
|
const f64 lo = to_space(a.range.min, a.log);
|
||||||
const f64 hi = to_space(a.range.max, a.log);
|
const f64 hi = to_space(a.range.max, a.log);
|
||||||
zoom_x_axis(i, from_space(0.5 * (lo + hi), a.log), factor);
|
zoom_x_axis(i, from_space(0.5 * (lo + hi), a.log), factor);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (!only_x) {
|
||||||
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
|
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
|
||||||
const Axis& a = plot_->y_axis(i);
|
const Axis& a = plot_->y_axis(i);
|
||||||
const f64 lo = to_space(a.range.min, a.log);
|
const f64 lo = to_space(a.range.min, a.log);
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -360,14 +489,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)) {
|
||||||
@@ -469,10 +618,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
|
||||||
@@ -507,12 +759,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.
|
||||||
@@ -525,18 +782,25 @@ 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) {
|
||||||
@@ -605,12 +869,25 @@ void Controller::axis_zoom(const PointerEvent& e) {
|
|||||||
// A pointer drag reports pixels, not wheel notches, so it uses its own
|
// A pointer drag reports pixels, not wheel notches, so it uses its own
|
||||||
// sensitivity: 200 px is one decade. The gain is a physical distance the hand
|
// sensitivity: 200 px is one decade. The gain is a physical distance the hand
|
||||||
// moves, so the device-pixel delta is converted to logical pixels first -- the
|
// moves, so the device-pixel delta is converted to logical pixels first -- the
|
||||||
// same drag must zoom the same amount on a 2x display. A scale no host should
|
// same drag must zoom the same amount on a 2x display.
|
||||||
// ever set (0, or negative) makes the factor non-positive, and the guard below
|
//
|
||||||
// turns that into no zoom at all rather than an axis re-centred on infinity.
|
// A scale that is not a positive number is not a scale -- a host that has not
|
||||||
const f64 logical_dy = static_cast<f64>(e.dy) / static_cast<f64>(plot_->ui_scale());
|
// measured its display, a field nobody filled in -- the same state
|
||||||
|
// `Style::device()` tolerates. Here it means *no zoom*: dividing by it would make
|
||||||
|
// `logical_dy` infinite and the factor infinite (or NaN), and that factor reaches
|
||||||
|
// `set_x_range()`/`set_y_range()`, which store it verbatim and clear `auto_scale`
|
||||||
|
// -- an axis left non-finite until an auto-fit. A negative scale would silently
|
||||||
|
// invert the zoom direction too, so the whole class is refused here.
|
||||||
|
const f64 scale = static_cast<f64>(plot_->ui_scale());
|
||||||
|
if (!(scale > 0.0) || !std::isfinite(scale)) return;
|
||||||
|
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);
|
||||||
@@ -658,7 +935,9 @@ u64 Controller::cursor_at(f32 px, f32 py) const {
|
|||||||
f32 best_d = 0.0f;
|
f32 best_d = 0.0f;
|
||||||
for (u32 i = 0; i < plot_->cursor_count(); ++i) {
|
for (u32 i = 0; i < plot_->cursor_count(); ++i) {
|
||||||
const Cursor& cursor = plot_->cursor(i);
|
const Cursor& cursor = plot_->cursor(i);
|
||||||
if (!cursor.visible) continue;
|
// A readout is drawn but not offered to the pointer: skipping it here is what makes it
|
||||||
|
// undraggable, rather than fighting the drag a frame at a time.
|
||||||
|
if (!cursor.visible || !cursor.selectable) continue;
|
||||||
// One hit test for both orientations, because a cursor *is* one line across the axes:
|
// One hit test for both orientations, because a cursor *is* one line across the axes:
|
||||||
// what the pointer has to be near is that line's own coordinate -- the column of a
|
// what the pointer has to be near is that line's own coordinate -- the column of a
|
||||||
// vertical marker, the row of a horizontal ruler -- measured with the same radius
|
// vertical marker, the row of a horizontal ruler -- measured with the same radius
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#include <plot/math/transform.hpp>
|
#include <plot/math/transform.hpp>
|
||||||
|
|
||||||
namespace plot::math {
|
namespace plot::math {
|
||||||
|
|||||||
+20
-3
@@ -1,17 +1,34 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#include <plot/plot/axis.hpp>
|
#include <plot/plot/axis.hpp>
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
namespace plot {
|
namespace plot {
|
||||||
|
|
||||||
void Axis::format_value(f64 v, char* buf, usize n) const {
|
void Axis::format_value(f64 v, char* buf, usize n) const {
|
||||||
|
// The one place a value on this axis becomes text, so the origin is applied here and
|
||||||
|
// everything that goes through it -- tick labels, and the crosshair's readout -- reads in
|
||||||
|
// the same frame of reference as the axis itself.
|
||||||
|
// The subtraction leaves a residue when the value and the origin are the same distant number
|
||||||
|
// (a tick exactly on the origin then formatted as `1.78e-12ms` instead of `0ms`). Snapping it
|
||||||
|
// is a *relative* test, so a genuinely small value on a large axis is not swallowed -- what
|
||||||
|
// this removes is arithmetic dust, not data.
|
||||||
|
f64 shown = v - origin;
|
||||||
|
if (std::fabs(shown) < 1.0e-12 * std::max(1.0, std::fabs(v))) shown = 0.0;
|
||||||
if (format == TickFormat::Time) {
|
if (format == TickFormat::Time) {
|
||||||
format_time(v, buf, n);
|
format_time(shown, buf, n);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
format_si(v, buf, n);
|
format_si(shown, buf, n);
|
||||||
}
|
}
|
||||||
|
|
||||||
void Axis::refresh(f32 px, f32 min_px, bool include_minors) {
|
void Axis::refresh(f32 px, f32 min_px, bool include_minors) {
|
||||||
generate_ticks(range, px, min_px, ticks, include_minors);
|
// The range first, so a following origin is this frame's edge and not the last one's: the
|
||||||
|
// zero tick then sits exactly on the window start, which is the whole point of asking.
|
||||||
|
if (origin_follows_range) origin = range.min;
|
||||||
|
generate_ticks(range, px, min_px, ticks, include_minors, origin);
|
||||||
if (format != TickFormat::Time) return;
|
if (format != TickFormat::Time) return;
|
||||||
// generate_ticks labels with format_si; a time axis relabels its majors here,
|
// generate_ticks labels with format_si; a time axis relabels its majors here,
|
||||||
// because the tick generator has no business knowing about a clock -- through the one
|
// because the tick generator has no business knowing about a clock -- through the one
|
||||||
|
|||||||
+99
-28
@@ -1,22 +1,43 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#include <plot/plot/figure.hpp>
|
#include <plot/plot/figure.hpp>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cstdio>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
|
|
||||||
namespace plot {
|
namespace plot {
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
/// The height a row never drops below, in device pixels: the floor that keeps a
|
/// The height a row never drops below, in **logical** pixels -- the same unit as every other
|
||||||
/// plot's chrome from erasing it in a figure with many rows or a small viewport. It
|
/// chrome size, converted to device pixels by the row's own `ui_scale()` where the floor is
|
||||||
/// yields to the figure -- it is capped at an equal share of the height there is --
|
/// applied. The floor that keeps a plot's chrome from erasing it in a figure with many rows
|
||||||
/// rather than pushing rows past the viewport.
|
/// or a small viewport. It yields to the figure -- it is capped at an equal share of the
|
||||||
|
/// height there is -- rather than pushing rows past the viewport.
|
||||||
///
|
///
|
||||||
/// Device pixels, like every rect a figure computes: a `Figure` carries no `Style`
|
/// A `Figure` carries no `Style` -- the renderer's is the one its rows are laid out with --
|
||||||
/// -- the renderer's is the one its rows are laid out with -- so there is no logical
|
/// but every row's `Plot` has the `ui_scale` the renderer installed on it, so the conversion
|
||||||
/// unit here to convert from. A host that wants a taller floor gives the figure a
|
/// is the row's own and the guarantee is the same *physical* height at any DPI. A host that
|
||||||
/// taller viewport, or gives the rows weights.
|
/// wants a taller floor gives the figure a taller viewport, or gives the rows weights.
|
||||||
constexpr f32 kMinRowHeightPx = 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
|
||||||
|
|
||||||
@@ -69,8 +90,12 @@ u32 Figure::x_link(u32 row) const noexcept {
|
|||||||
void Figure::unlink_row(u32 row) noexcept {
|
void Figure::unlink_row(u32 row) noexcept {
|
||||||
// A row that leaves its group stops following anything, so the hint a following group
|
// A row that leaves its group stops following anything, so the hint a following group
|
||||||
// published to it goes with the membership: leaving `has_live` set behind would leave a
|
// published to it goes with the membership: leaving `has_live` set behind would leave a
|
||||||
// former member anchoring a zoom on an edge it no longer follows.
|
// former member anchoring a zoom on an edge it no longer follows. Its linkage flag goes
|
||||||
if (row < plots_.size()) plots_[row].set_x_live_owner(0u, false, 0.0);
|
// with it too: an unlinked row is an ordinary plot again, free to arm `Live` itself.
|
||||||
|
if (row < plots_.size()) {
|
||||||
|
plots_[row].set_x_live_owner(0u, false, 0.0);
|
||||||
|
plots_[row].set_x_link_member(0u, false);
|
||||||
|
}
|
||||||
for (XLink& group : links_) {
|
for (XLink& group : links_) {
|
||||||
for (std::size_t i = 0; i < group.rows.size(); ++i) {
|
for (std::size_t i = 0; i < group.rows.size(); ++i) {
|
||||||
if (group.rows[i] != row) continue;
|
if (group.rows[i] != row) continue;
|
||||||
@@ -111,6 +136,10 @@ Status Figure::link_x(u32 row, u32 group) {
|
|||||||
const std::size_t index = static_cast<std::size_t>(at - g.rows.begin());
|
const std::size_t index = static_cast<std::size_t>(at - g.rows.begin());
|
||||||
g.rows.insert(at, row);
|
g.rows.insert(at, row);
|
||||||
g.pushed.insert(g.pushed.begin() + static_cast<std::ptrdiff_t>(index), Range{});
|
g.pushed.insert(g.pushed.begin() + static_cast<std::ptrdiff_t>(index), Range{});
|
||||||
|
// A member's axis 0 is now owned by the group (its window and its follow state), so
|
||||||
|
// `set_x_follow(Live)` on it is refused from here on -- the same rule the refusal above
|
||||||
|
// applies at the door.
|
||||||
|
p.set_x_link_member(0u, true);
|
||||||
if (!g.has_range) {
|
if (!g.has_range) {
|
||||||
// The first row into a group is the one that frames it: there is no shared range
|
// The first row into a group is the one that frames it: there is no shared range
|
||||||
// to apply yet, and adopting this row's is what makes the next joiner land on a
|
// to apply yet, and adopting this row's is what makes the next joiner land on a
|
||||||
@@ -139,9 +168,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)) {
|
||||||
@@ -149,8 +180,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);
|
||||||
@@ -237,18 +269,36 @@ 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.
|
||||||
group.offset = std::clamp(group.offset, 0.0, std::max(0.0, live.max - live.min));
|
//
|
||||||
|
// The bound is about the whole *window*, not just its right edge -- but only when the window
|
||||||
|
// *can* sit on the data. `retained >= span` is that case, and there the offset may not exceed
|
||||||
|
// `retained - span`: with the loose bound a scroll could put the window's right edge exactly on
|
||||||
|
// the oldest sample, which leaves the entire span to its left over data the scope never had --
|
||||||
|
// an empty margin as wide as the view, reached by a pan or by a zoom-in near that edge. When the
|
||||||
|
// window is *wider* than what the members retain (a ring still filling, or one just trimmed)
|
||||||
|
// nothing can avoid a margin, and the honest placement is the old one: the window's right edge
|
||||||
|
// on the newest sample, the shortfall on the left, which is what a scope that just started
|
||||||
|
// should look like.
|
||||||
|
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));
|
||||||
|
const f64 reach = retained >= group.span ? retained - group.span : retained;
|
||||||
|
group.offset = std::clamp(group.offset, 0.0, std::max(0.0, reach));
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,15 +338,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -353,7 +408,15 @@ const FigureLayout& Figure::layout() {
|
|||||||
// heights still sum to exactly the usable height, so the rows tile the figure and
|
// heights still sum to exactly the usable height, so the rows tile the figure and
|
||||||
// the last one ends on its bottom edge.
|
// the last one ends on its bottom edge.
|
||||||
const bool equal = row_weights_.empty();
|
const bool equal = row_weights_.empty();
|
||||||
const f32 floor_px = std::min(kMinRowHeightPx, usable / static_cast<f32>(rows));
|
// The floor is a logical size like every other chrome metric, so it is converted through
|
||||||
|
// the row's own `ui_scale()` -- the one the renderer installed on every row -- which is
|
||||||
|
// what makes the guarantee the same *physical* height at any DPI instead of half as strong
|
||||||
|
// at 2x exactly where the chrome it protects is twice as large. A scale that is not
|
||||||
|
// positive is not a scale and converts 1:1, exactly as `Style::device()` documents.
|
||||||
|
const f32 floor_scale = plots_[0].ui_scale();
|
||||||
|
const f32 floor_device = floor_scale > 0.0f ? kMinRowHeightLogical * floor_scale
|
||||||
|
: kMinRowHeightLogical;
|
||||||
|
const f32 floor_px = std::min(floor_device, usable / static_cast<f32>(rows));
|
||||||
const f32 extra = usable - floor_px * static_cast<f32>(rows);
|
const f32 extra = usable - floor_px * static_cast<f32>(rows);
|
||||||
f32 total_w = 0.0f;
|
f32 total_w = 0.0f;
|
||||||
for (const f32 w : row_weights_) total_w += w;
|
for (const f32 w : row_weights_) total_w += w;
|
||||||
@@ -388,6 +451,10 @@ const FigureLayout& Figure::layout() {
|
|||||||
// not drawn once per row. A figure told to label every row keeps them everywhere,
|
// not drawn once per row. A figure told to label every row keeps them everywhere,
|
||||||
// and then there is no shared axis to speak of: each row has its own.
|
// and then there is no shared axis to speak of: each row has its own.
|
||||||
p.set_x_strip_mode(labels_bottom_only_ && row != bottom_row ? XStrip::None : XStrip::Both);
|
p.set_x_strip_mode(labels_bottom_only_ && row != bottom_row ? XStrip::None : XStrip::Both);
|
||||||
|
// Before either pass below, and the same value for every row: nothing is measured on
|
||||||
|
// the right, so unlike the left gutter this needs no measurement pass -- the rows end
|
||||||
|
// on a common column because they are all told the one width.
|
||||||
|
p.set_right_gutter(right_gutter_);
|
||||||
}
|
}
|
||||||
// After the viewports, so a row's ticks and grid -- built from its axes rect -- and
|
// After the viewports, so a row's ticks and grid -- built from its axes rect -- and
|
||||||
// its transforms describe this frame's rects; and before the rows lay themselves
|
// its transforms describe this frame's rects; and before the rows lay themselves
|
||||||
@@ -415,7 +482,11 @@ const FigureLayout& Figure::layout() {
|
|||||||
// per row settles the answer exactly. Measuring with a floor from the previous frame
|
// per row settles the answer exactly. Measuring with a floor from the previous frame
|
||||||
// would be the only reason to iterate, and clearing it before the measurement pass is
|
// would be the only reason to iterate, and clearing it before the measurement pass is
|
||||||
// what removes it.
|
// what removes it.
|
||||||
f32 gutter = 0.0f;
|
//
|
||||||
|
// `set_min_y_gutter()` seeds the maximum rather than being imposed on a row before the
|
||||||
|
// measurement, so a reserved floor cannot widen what a row reports and then be widened
|
||||||
|
// by its own reservation on the next frame.
|
||||||
|
f32 gutter = min_y_gutter_;
|
||||||
for (u32 row = 0; row < rows; ++row) {
|
for (u32 row = 0; row < rows; ++row) {
|
||||||
plots_[row].set_min_y_gutter(0.0f);
|
plots_[row].set_min_y_gutter(0.0f);
|
||||||
const PlotLayout& row_layout = plots_[row].layout();
|
const PlotLayout& row_layout = plots_[row].layout();
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#include <plot/plot/grid.hpp>
|
#include <plot/plot/grid.hpp>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#include <plot/plot/measure.hpp>
|
#include <plot/plot/measure.hpp>
|
||||||
|
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
|||||||
+320
-91
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#include <plot/plot/plot.hpp>
|
#include <plot/plot/plot.hpp>
|
||||||
#include <plot/plot/detail/measure_count.hpp>
|
#include <plot/plot/detail/measure_count.hpp>
|
||||||
|
|
||||||
@@ -28,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,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);
|
||||||
@@ -269,6 +306,57 @@ u32 Plot::add_annotation(const Annotation& annotation) {
|
|||||||
return static_cast<u32>(annotations_.size() - 1);
|
return static_cast<u32>(annotations_.size() - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Plot::set_cursor_color(u32 id, Color color) {
|
||||||
|
if (id >= cursors_.size()) return;
|
||||||
|
cursors_[id].color = color;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Plot::set_cursor_stroke(u32 id, f32 width, Vec2 dash) {
|
||||||
|
if (id >= cursors_.size()) return;
|
||||||
|
cursors_[id].width = width;
|
||||||
|
cursors_[id].dash = dash;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Plot::set_cursor_selectable(u32 id, bool selectable) {
|
||||||
|
if (id >= cursors_.size()) return;
|
||||||
|
cursors_[id].selectable = selectable;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Plot::set_x_axis_origin(u32 axis, f64 origin) {
|
||||||
|
if (axis >= x_axes_.size()) return;
|
||||||
|
x_axes_[axis].origin = origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Plot::set_y_axis_origin(u32 axis, f64 origin) {
|
||||||
|
if (axis >= y_axes_.size()) return;
|
||||||
|
y_axes_[axis].origin = origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Plot::set_x_axis_origin_from_range(u32 axis, bool on) {
|
||||||
|
if (axis >= x_axes_.size()) return;
|
||||||
|
x_axes_[axis].origin_follows_range = on;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Plot::set_y_axis_origin_from_range(u32 axis, bool on) {
|
||||||
|
if (axis >= y_axes_.size()) return;
|
||||||
|
y_axes_[axis].origin_follows_range = on;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Plot::set_x_note(std::string text) { x_note_ = std::move(text); }
|
||||||
|
|
||||||
|
f64 Plot::x_axis_origin(u32 axis) const {
|
||||||
|
return axis < x_axes_.size() ? x_axes_[axis].origin : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
f64 Plot::y_axis_origin(u32 axis) const {
|
||||||
|
return axis < y_axes_.size() ? y_axes_[axis].origin : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Plot::set_annotation(u32 id, const Annotation& annotation) {
|
||||||
|
if (id >= annotations_.size()) return;
|
||||||
|
annotations_[id] = annotation;
|
||||||
|
}
|
||||||
|
|
||||||
u32 Plot::add_region(const Region& region) {
|
u32 Plot::add_region(const Region& region) {
|
||||||
// A region whose bounds are not numbers is not an interval this plot can hold, and a
|
// A region whose bounds are not numbers is not an interval this plot can hold, and a
|
||||||
// `Free` one stored that way could never be dragged back (every motion re-derives both
|
// `Free` one stored that way could never be dragged back (every motion re-derives both
|
||||||
@@ -360,15 +448,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;
|
||||||
@@ -377,6 +477,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;
|
||||||
}
|
}
|
||||||
@@ -391,6 +494,13 @@ void Plot::set_y_auto_scale(u32 axis, bool on) {
|
|||||||
|
|
||||||
void Plot::set_x_follow(u32 axis, XFollow f) {
|
void Plot::set_x_follow(u32 axis, XFollow f) {
|
||||||
if (axis >= x_axes_.size()) return;
|
if (axis >= x_axes_.size()) return;
|
||||||
|
// A figure link group owns this axis' shared window *and* its follow state:
|
||||||
|
// `Figure::link_x()` refuses a `Live` member joiner, and arming `Live` here *after*
|
||||||
|
// linking would be the same state reached by the back door -- `push_link()` would
|
||||||
|
// write the group's range into an axis that reinterprets it against its own live
|
||||||
|
// edge, `adopt_member_changes()` would read it straight back as an external change,
|
||||||
|
// and the group would track one member instead of the union of them. Refused.
|
||||||
|
if (f == XFollow::Live && x_axes_[axis].link_member) return;
|
||||||
x_axes_[axis].follow = f;
|
x_axes_[axis].follow = f;
|
||||||
// Arming a live axis places its window at once, so x_range() reports the window
|
// Arming a live axis places its window at once, so x_range() reports the window
|
||||||
// the next layout produces rather than the one the axis held while it was Off.
|
// the next layout produces rather than the one the axis held while it was Off.
|
||||||
@@ -399,6 +509,11 @@ void Plot::set_x_follow(u32 axis, XFollow f) {
|
|||||||
place_live_x_window(axis);
|
place_live_x_window(axis);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Plot::set_x_link_member(u32 axis, bool linked) noexcept {
|
||||||
|
if (axis >= x_axes_.size()) return;
|
||||||
|
x_axes_[axis].link_member = linked;
|
||||||
|
}
|
||||||
|
|
||||||
XFollow Plot::x_follow(u32 axis) const {
|
XFollow Plot::x_follow(u32 axis) const {
|
||||||
return axis < x_axes_.size() ? x_axes_[axis].follow : XFollow::Off;
|
return axis < x_axes_.size() ? x_axes_[axis].follow : XFollow::Off;
|
||||||
}
|
}
|
||||||
@@ -406,7 +521,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
|
||||||
@@ -474,19 +592,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -654,12 +783,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;
|
||||||
@@ -703,6 +847,24 @@ void Plot::snap_region(u32 id) {
|
|||||||
void Plot::remove_region(u32 id) {
|
void Plot::remove_region(u32 id) {
|
||||||
if (id >= regions_.size()) return;
|
if (id >= regions_.size()) return;
|
||||||
regions_.erase(regions_.begin() + static_cast<std::ptrdiff_t>(id));
|
regions_.erase(regions_.begin() + static_cast<std::ptrdiff_t>(id));
|
||||||
|
// The published hot handles name a region by index and are re-indexed with everything
|
||||||
|
// else: the removed region's own handle is cleared (nothing is hot there any more, and a
|
||||||
|
// frame drawn before the next event would otherwise highlight the region that slid into
|
||||||
|
// its slot), and a handle naming a region above the hole follows it down. The `zone` is
|
||||||
|
// the grab that went with that index, so it is cleared with it -- a stale zone describing
|
||||||
|
// a region that is no longer there would colour the next region's bound.
|
||||||
|
//
|
||||||
|
// A host must not remove a region *mid-drag*: the controller holds the id it grabbed, and
|
||||||
|
// this setter cannot re-index a controller's private `drag_region_`. The gesture's release
|
||||||
|
// would then edit whichever region now carries that id. Remove regions between gestures.
|
||||||
|
if (hot_.region != kInvalid) {
|
||||||
|
if (hot_.region == id) {
|
||||||
|
hot_.region = kInvalid;
|
||||||
|
hot_.zone = RegionGrab::None;
|
||||||
|
} else if (hot_.region > id) {
|
||||||
|
hot_.region -= 1u;
|
||||||
|
}
|
||||||
|
}
|
||||||
// The cache is keyed by region index, so the removed region's entries name nothing
|
// The cache is keyed by region index, so the removed region's entries name nothing
|
||||||
// and every entry above the hole names a region one slot further down. The
|
// and every entry above the hole names a region one slot further down. The
|
||||||
// surviving regions' own intervals are untouched -- erase keeps their order -- so
|
// surviving regions' own intervals are untouched -- erase keeps their order -- so
|
||||||
@@ -788,10 +950,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
|
||||||
@@ -850,10 +1024,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
|
||||||
@@ -865,13 +1039,22 @@ const PlotLayout& Plot::layout() {
|
|||||||
x_height = std::min(x_height, x_budget);
|
x_height = std::min(x_height, x_budget);
|
||||||
const f32 y_title_width = std::min(y_title_w, y_budget - y_width);
|
const f32 y_title_width = std::min(y_title_w, y_budget - y_width);
|
||||||
const f32 x_title_height = std::min(x_title_h, x_budget - x_height);
|
const f32 x_title_height = std::min(x_title_h, x_budget - x_height);
|
||||||
|
// The right-hand band, out of what is left of the same allowance the two left
|
||||||
|
// strips were served from -- so the three together cannot take more than
|
||||||
|
// `1 - kMinAxesShare` of the width however much each asks for. Nothing is drawn
|
||||||
|
// in it, so unlike the strips above it is a want and never a measurement, and it
|
||||||
|
// cannot feed the fixed point below: the y labels are chosen from the row's range
|
||||||
|
// and height, so a narrower `axes` does not change what they need.
|
||||||
|
const f32 right_gutter =
|
||||||
|
std::min(right_gutter_, y_budget - (y_width + y_title_width));
|
||||||
layout_.y_axis_width = y_width;
|
layout_.y_axis_width = y_width;
|
||||||
layout_.x_axis_height = x_height;
|
layout_.x_axis_height = x_height;
|
||||||
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_.right_gutter = right_gutter;
|
||||||
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 + right_gutter),
|
||||||
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};
|
||||||
@@ -884,11 +1067,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
|
||||||
@@ -910,7 +1093,7 @@ const PlotLayout& Plot::layout() {
|
|||||||
build_regions();
|
build_regions();
|
||||||
build_legend();
|
build_legend();
|
||||||
// The crosshair last: its panel is fitted into `axes` and its lines are held inside it, and
|
// The crosshair last: its panel is fitted into `axes` and its lines are held inside it, and
|
||||||
// its rows are the interpolated values at a data x -- so it wants the transforms the
|
// its rows are the sample values nearest a data x -- so it wants the transforms the
|
||||||
// settled layout pins, exactly as the legend and the regions do.
|
// settled layout pins, exactly as the legend and the regions do.
|
||||||
build_crosshair();
|
build_crosshair();
|
||||||
return layout_;
|
return layout_;
|
||||||
@@ -954,8 +1137,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 {
|
||||||
@@ -1178,57 +1363,71 @@ 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 bracket.
|
||||||
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 at all. Answering
|
||||||
// a number as if it were under the pointer when the series does not reach that far.
|
// 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 a span with a width it does not have. Decided on
|
||||||
// and casting that to `u64` is undefined. `!(index > 0.0)` is the NaN test as well.
|
// the samples' own coordinates, so it holds for any `neighbours()` impl.
|
||||||
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: there
|
||||||
// after it -- neither is an interpolation with no width.
|
// is no second sample to choose between, and this one 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 "which is nearer" is exact on
|
||||||
|
// any grid, uniform or not. A degenerate pair (two samples sharing an x)
|
||||||
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
f64 crosshair_interpolate(f64 y_begin, f64 y_end, f64 t) noexcept {
|
||||||
|
// This form, not `(1 - t) * a + t * b`: at `t == 0` it is `y_begin` and at `t == 1` it is
|
||||||
|
// `y_end`, both exactly, so a query that landed on a sample reads that sample back whatever
|
||||||
|
// mode is in force. The symmetric form rounds at both ends.
|
||||||
|
return y_begin + t * (y_end - y_begin);
|
||||||
|
}
|
||||||
|
|
||||||
std::string crosshair_unnamed_label(u32 trace) {
|
std::string crosshair_unnamed_label(u32 trace) {
|
||||||
char buf[16];
|
char buf[16];
|
||||||
std::snprintf(buf, sizeof buf, "#%u", static_cast<unsigned>(trace));
|
std::snprintf(buf, sizeof buf, "#%u", static_cast<unsigned>(trace));
|
||||||
@@ -1258,6 +1457,11 @@ void Plot::set_crosshair(f64 x, f64 y, bool active) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool Plot::crosshair_value(u32 trace, f64 x, f64& out_y) const {
|
bool Plot::crosshair_value(u32 trace, f64 x, f64& out_y) const {
|
||||||
|
f64 ignored = 0.0;
|
||||||
|
return crosshair_point(trace, x, ignored, out_y);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Plot::crosshair_point(u32 trace, f64 x, f64& out_x, f64& out_y) const {
|
||||||
if (trace >= traces_.size()) return false;
|
if (trace >= traces_.size()) return false;
|
||||||
const data::Series* series = traces_[trace].series;
|
const data::Series* series = traces_[trace].series;
|
||||||
if (series == nullptr) return false;
|
if (series == nullptr) return false;
|
||||||
@@ -1269,13 +1473,32 @@ bool Plot::crosshair_value(u32 trace, f64 x, f64& out_y) const {
|
|||||||
// when it fell between them. The scratch is cleared and refilled rather than reallocated,
|
// when it fell between them. The scratch is cleared and refilled rather than reallocated,
|
||||||
// and sized for two pairs from construction, so the pointer path allocates nothing.
|
// and sized for two pairs from construction, so the pointer path allocates nothing.
|
||||||
crosshair_scratch_.clear();
|
crosshair_scratch_.clear();
|
||||||
series->gather(0u, span.begin, span.end + 1u, crosshair_scratch_);
|
// The origin is the query itself: only y is read out of the pairs below -- the x comes back
|
||||||
|
// through `x_at()` -- so this names the one origin that cannot lose precision for a caller
|
||||||
|
// that might.
|
||||||
|
series->gather(0u, span.begin, span.end + 1u, x, crosshair_scratch_);
|
||||||
const usize pairs = static_cast<usize>(span.end - span.begin + 1u);
|
const usize pairs = static_cast<usize>(span.end - span.begin + 1u);
|
||||||
if (crosshair_scratch_.size() != 2u * pairs) return false; // a series that produced nothing
|
if (crosshair_scratch_.size() != 2u * pairs) return false; // a series that produced nothing
|
||||||
const f64 y_begin = static_cast<f64>(crosshair_scratch_[1u]);
|
const f64 y_begin = static_cast<f64>(crosshair_scratch_[1u]);
|
||||||
const f64 y_end = static_cast<f64>(crosshair_scratch_[2u * pairs - 1u]);
|
const f64 y_end = static_cast<f64>(crosshair_scratch_[2u * pairs - 1u]);
|
||||||
|
if (crosshair_.value_mode == CrosshairValue::Interpolated) {
|
||||||
|
// The signal the samples imply *at* this x, so the point is the query's own x: that is
|
||||||
|
// where an interpolated number lives, and the marker on the polyline there is honest
|
||||||
|
// about it.
|
||||||
|
out_x = x;
|
||||||
out_y = crosshair_interpolate(y_begin, y_end, span.t);
|
out_y = crosshair_interpolate(y_begin, y_end, span.t);
|
||||||
return true;
|
return true;
|
||||||
|
}
|
||||||
|
// `NearestSample`: the nearer of the two, and **its own x with it**. `span.t` is measured on
|
||||||
|
// the samples' own coordinates, so "nearer" is exact on any grid; a tie reads the earlier
|
||||||
|
// sample, which is the bias `crosshair_span()` already takes for a degenerate pair. The x
|
||||||
|
// comes back through `x_at()` rather than from the gathered pairs, which are relative to a
|
||||||
|
// caller-named origin by the `Series::gather()` contract -- adding it back here would be a
|
||||||
|
// second reconstruction of a coordinate the series will hand over directly.
|
||||||
|
const bool take_begin = span.t <= 0.5;
|
||||||
|
out_x = series->x_at(take_begin ? span.begin : span.end);
|
||||||
|
out_y = take_begin ? y_begin : y_end;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void Plot::build_crosshair() {
|
void Plot::build_crosshair() {
|
||||||
@@ -1343,17 +1566,23 @@ void Plot::build_crosshair() {
|
|||||||
row.trace = i;
|
row.trace = i;
|
||||||
row.valid = false;
|
row.valid = false;
|
||||||
row.listed = false;
|
row.listed = false;
|
||||||
|
row.x = 0.0;
|
||||||
row.y = 0.0;
|
row.y = 0.0;
|
||||||
row.marker = Vec2{};
|
row.marker = Vec2{};
|
||||||
row.swatch = Rect{};
|
row.swatch = Rect{};
|
||||||
row.label_box = Rect{};
|
row.label_box = Rect{};
|
||||||
row.value_box = Rect{};
|
row.value_box = Rect{};
|
||||||
row.label.assign(trace.name.empty() ? crosshair_unnamed_label(i) : trace.name);
|
row.label.assign(trace.name.empty() ? crosshair_unnamed_label(i) : trace.name);
|
||||||
// The value: the interpolated number where the trace has a sample at this x, and the em dash
|
// The value where the trace has one at this x, and the em dash where it does not. The row is
|
||||||
// where it does not. The row is listed either way, so the absence has to be *said* -- a blank
|
// listed either way, so the absence has to be *said* -- a blank value cell reads as a value
|
||||||
// value cell reads as a value that has not arrived -- and the dash is measured into the panel's
|
// that has not arrived -- and the dash is measured into the panel's width like any other
|
||||||
// width like any other value, so the row's boxes are placed by one rule.
|
// value, so the row's boxes are placed by one rule.
|
||||||
if (crosshair_value(i, crosshair_.x, row.y)) {
|
//
|
||||||
|
// The **point**, not just the number: `crosshair_point()` reports the x the value belongs to
|
||||||
|
// as well, which in the default `NearestSample` mode is the quoted sample's own and not the
|
||||||
|
// pointer's. Both coordinates come from that one call, so the mark and the text cannot
|
||||||
|
// disagree about which datum is being read (see `CrosshairRow::marker`).
|
||||||
|
if (crosshair_point(i, crosshair_.x, row.x, row.y)) {
|
||||||
row.valid = true;
|
row.valid = true;
|
||||||
char value[32];
|
char value[32];
|
||||||
// The trace's *own* y axis formats the number, so a readout reads exactly like the
|
// The trace's *own* y axis formats the number, so a readout reads exactly like the
|
||||||
@@ -1362,7 +1591,7 @@ void Plot::build_crosshair() {
|
|||||||
row.value.assign(value);
|
row.value.assign(value);
|
||||||
// Placed through the trace's own axis pair, so the marker sits where that trace is
|
// Placed through the trace's own axis pair, so the marker sits where that trace is
|
||||||
// actually drawn however its axes are scaled.
|
// actually drawn however its axes are scaled.
|
||||||
row.marker = Vec2{static_cast<f32>(x_transform(trace.axis_x).to_screen_x(crosshair_.x)),
|
row.marker = Vec2{static_cast<f32>(x_transform(trace.axis_x).to_screen_x(row.x)),
|
||||||
static_cast<f32>(y_transform(trace.axis_y).to_screen_y(row.y))};
|
static_cast<f32>(y_transform(trace.axis_y).to_screen_y(row.y))};
|
||||||
} else {
|
} else {
|
||||||
row.value.assign(kCrosshairNoValue);
|
row.value.assign(kCrosshairNoValue);
|
||||||
|
|||||||
+105
-16
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#include <plot/plot/ticks.hpp>
|
#include <plot/plot/ticks.hpp>
|
||||||
|
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
@@ -95,15 +98,42 @@ void format_time(f64 seconds, char* buf, usize n) {
|
|||||||
const char* const sign = seconds < 0.0 ? "-" : "";
|
const char* const sign = seconds < 0.0 ? "-" : "";
|
||||||
const f64 t = std::fabs(seconds);
|
const f64 t = std::fabs(seconds);
|
||||||
|
|
||||||
if (t < 1.0) {
|
// `0.9995` and not `1.0`: three significant figures of 0.9999 s are `1e+03ms`, so a value
|
||||||
// Sub-second: milliseconds. The 999.5 cut keeps %.3g out of scientific
|
// that *rounds* to a second belongs to the whole-second branch below, which writes it `1s`.
|
||||||
// notation (it would print "1e+03ms") right below the one-second boundary.
|
// The same spill one rung down is handled inside the ladder.
|
||||||
const f64 ms = t * 1000.0;
|
if (t < 0.9995) {
|
||||||
if (ms < 999.5) {
|
// Sub-second: the prefix that keeps the mantissa in [1, 1000) -- the same rule the SI
|
||||||
std::snprintf(buf, n, "%s%.3gms", sign, ms);
|
// formatter uses on a y axis, and the reading a zoomed-in window needs. A fixed
|
||||||
} else {
|
// millisecond unit printed `0.005ms` where `5us` is what the axis is showing, and
|
||||||
std::snprintf(buf, n, "%s%.3gs", sign, t);
|
// `1e-05ms` where `10ns` is. The micro sign is the UTF-8 sequence the embedded font has a
|
||||||
|
// glyph for (see `format_si`), not a Latin-1 byte.
|
||||||
|
// Exactly zero carries no unit, the same convention `format_si` states: every prefixed
|
||||||
|
// form of it is "0" with a unit the value does not have.
|
||||||
|
if (t == 0.0) {
|
||||||
|
std::snprintf(buf, n, "0");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
static const struct {
|
||||||
|
f64 scale;
|
||||||
|
const char* unit;
|
||||||
|
} kUnits[] = {
|
||||||
|
{1.0e-3, "ms"},
|
||||||
|
{1.0e-6, "\u00b5s"},
|
||||||
|
{1.0e-9, "ns"},
|
||||||
|
{1.0e-12, "ps"},
|
||||||
|
};
|
||||||
|
// The rung is picked by the *rounded* mantissa, which is what `format_si`'s own 999.5
|
||||||
|
// check buys there: 0.9999 ms is `999.9µs` before rounding and `1e+03µs` after, so it is
|
||||||
|
// the millisecond rung's value and reads `1ms`. Testing the raw `t < u.scale` let the
|
||||||
|
// mantissa reach 1000 at every rung boundary.
|
||||||
|
for (const auto& u : kUnits) {
|
||||||
|
if (t < u.scale * 0.9995) continue;
|
||||||
|
std::snprintf(buf, n, "%s%.3g%s", sign, t / u.scale, u.unit);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Below a picosecond nothing on a time axis reads in prefixed units; plain seconds keep
|
||||||
|
// the fallback's own precision rather than inventing one.
|
||||||
|
std::snprintf(buf, n, "%s%.3gs", sign, t);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +158,8 @@ void format_time(f64 seconds, char* buf, usize n) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out, bool include_minors) {
|
void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out, bool include_minors,
|
||||||
|
f64 origin) {
|
||||||
out.clear();
|
out.clear();
|
||||||
|
|
||||||
const f64 lo = range.min;
|
const f64 lo = range.min;
|
||||||
@@ -154,7 +185,10 @@ void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out, boo
|
|||||||
: 10.0);
|
: 10.0);
|
||||||
if (!std::isfinite(step) || !(step > 0.0)) return; // span so large the step overflowed
|
if (!std::isfinite(step) || !(step > 0.0)) return; // span so large the step overflowed
|
||||||
|
|
||||||
const f64 first_major = std::ceil(lo / step) * step;
|
// The major walk starts on the *origin's* grid: with an origin the ticks a host reads are
|
||||||
|
// round numbers relative to it (0, 0.5ms, 1ms of the window it zoomed into), which is the
|
||||||
|
// whole point of having one. `origin = 0` is the plain absolute grid.
|
||||||
|
const f64 first_major = origin + std::ceil((lo - origin) / step) * step;
|
||||||
if (!std::isfinite(first_major)) return;
|
if (!std::isfinite(first_major)) return;
|
||||||
|
|
||||||
// Minor ticks subdivide the major step: by 4 when the step's leading digit is
|
// Minor ticks subdivide the major step: by 4 when the step's leading digit is
|
||||||
@@ -164,22 +198,77 @@ 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;
|
||||||
|
|
||||||
|
// The walk starts at `ceil(lo / stride) * stride`, which is not representable
|
||||||
|
// whenever the stride is not a power of two -- 0.1 and 0.2 are the common ones --
|
||||||
|
// so a range that straddles the origin never lands *on* it: [-1, 1] stepped by 0.1
|
||||||
|
// reaches -1.39e-16 where it means zero, and that renders as `-139a`, a label from
|
||||||
|
// the far end of the SI table sitting between `-500m` and `500m`. Deriving `v` by
|
||||||
|
// multiplication instead of accumulation does not help, because the error is
|
||||||
|
// already in `first`.
|
||||||
|
//
|
||||||
|
// Every tick is an integer multiple of the stride, so the smallest magnitude a
|
||||||
|
// *non-zero* tick can have is the stride itself, while the residue is a few ULP of
|
||||||
|
// the range -- a relative gap of some ten orders of magnitude. Anything inside a
|
||||||
|
// millionth of the stride is therefore the zero tick and nothing else, and it is
|
||||||
|
// stored as exactly `0.0` rather than merely labelled as such: `Axis::refresh()`
|
||||||
|
// re-formats a time axis from `tick.value`, where the same residue reads
|
||||||
|
// `3.19e-13ms`.
|
||||||
|
const f64 zero_tol = (include_minors ? minor_step : step) * 1e-6;
|
||||||
|
|
||||||
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;
|
// The walk variable itself is left alone: `minor_index_is_major` counts indices
|
||||||
|
// from `first_major` and both operands have to stay on the walk's own grid.
|
||||||
|
tick.value = std::fabs(v) < zero_tol ? 0.0 : v;
|
||||||
tick.major = major;
|
tick.major = major;
|
||||||
if (major) {
|
if (major) {
|
||||||
// format_si owns both label guards: it never writes past the buffer, and it
|
// format_si owns both label guards: it never writes past the buffer, and it
|
||||||
// falls back to %.3g for a non-finite value or a label over 12 characters.
|
// falls back to %.3g for a non-finite value or a label over 12 characters.
|
||||||
format_si(v, label, sizeof label);
|
format_si(tick.value, label, sizeof label);
|
||||||
tick.label = label;
|
tick.label = label;
|
||||||
}
|
}
|
||||||
out.push_back(std::move(tick));
|
out.push_back(std::move(tick));
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#include <plot/render/buffer.hpp>
|
#include <plot/render/buffer.hpp>
|
||||||
|
|
||||||
#include <plot/gl/api.hpp>
|
#include <plot/gl/api.hpp>
|
||||||
|
|||||||
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user