Compare commits
57
Commits
d03f9ce51a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d5b286611 | ||
|
|
2cb31cbb91 | ||
|
|
69f0083fc0 | ||
|
|
2b4b316ad8 | ||
|
|
25c3381551 | ||
|
|
73f610b136 | ||
|
|
e5b7faadec | ||
|
|
7d8c453232 | ||
|
|
f79a97be9b | ||
|
|
1b8b55d3ee | ||
|
|
738205293e | ||
|
|
6a6915e64e | ||
|
|
3ad02e5120 | ||
|
|
9790becb33 | ||
|
|
2f7cdccdde | ||
|
|
be1f31a04f | ||
|
|
1fc97d818f | ||
|
|
ebc67bd318 | ||
|
|
1ae72d4903 | ||
|
|
2ebf2be571 | ||
|
|
37e414bcc5 | ||
|
|
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
|
||||||
+12
@@ -3,3 +3,15 @@ build/
|
|||||||
# Not this project's and not generated by it: a Solarus game-engine message that
|
# Not this project's and not generated by it: a Solarus game-engine message that
|
||||||
# happens to sit in the repo root.
|
# happens to sit in the repo root.
|
||||||
error.txt
|
error.txt
|
||||||
|
|
||||||
|
# An *in-tree* configure writes these beside the sources. `build/` above only covers the
|
||||||
|
# directory the presets use, and `cmake -S . -B .` leaves the whole of CMake's output in the
|
||||||
|
# repository root -- which then reads as an untracked submodule to whoever has it checked out.
|
||||||
|
CMakeFiles/
|
||||||
|
CMakeCache.txt
|
||||||
|
cmake_install.cmake
|
||||||
|
Makefile
|
||||||
|
CTestTestfile.cmake
|
||||||
|
CPackConfig.cmake
|
||||||
|
# Configured from `include/plot/version.hpp.in`, so it is output and not a source file.
|
||||||
|
include/plot/version.hpp
|
||||||
+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,265 @@
|
|||||||
|
# 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 GTK4 adapter exists for, and is exercised by, a real consumer: **udpscope**, an oscilloscope-style viewer
|
||||||
|
for a UDPStreamer source, at <https://git.ferrarinas.it/martino/udpscope>. It embeds the core through
|
||||||
|
`plot::gtk4`, and the library's interaction layer — cursors, measurement marks, the shading of a span a host
|
||||||
|
has declared invalid — has been shaped by what that host needed. The notes in `src/`, `docs/` and `tests/`
|
||||||
|
that say "in udpscope's case" are those places.
|
||||||
|
|
||||||
|
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. Below the density at
|
||||||
|
which columns are reduced the same switch grades the **polyline** by each segment's slope
|
||||||
|
(`FrameStats::slope_traces`), so the look does not change as a window narrows past the
|
||||||
|
threshold;
|
||||||
|
- a **phosphor density** a host can turn on for a plot (`Plot::set_density()`, an image rather
|
||||||
|
than a set of retained traces): **one grid per trace**, in that trace's own colour, the axes
|
||||||
|
rect in device pixels, drawn *in place of* the traces -- a host may not also draw them over it --
|
||||||
|
into which each acquisition is deposited, 1.0 per cell a trace crosses, so brightness is how
|
||||||
|
often it passed. It fades either by a **time constant** in stream seconds or by a
|
||||||
|
**per-acquisition factor**, and the newest acquisition is deposited like every other, so it
|
||||||
|
fades too. The resolution is bounded (`kDensityCellCap`, 262 144 cells -- a mebibyte of counts --
|
||||||
|
a grid, past which the rect is scaled down uniformly and the image magnified back over it with a
|
||||||
|
linear filter), because the per-frame pass is `O(cells)` and the window is not bounded at all:
|
||||||
|
memory is the cap times the traces on screen times 4 bytes, whatever the window (a 640x360
|
||||||
|
viewport's axes is 179 349 cells per trace, 717 kB, under it, while four rows of one trace in a
|
||||||
|
3040x1700 one used to be 4.5 M cells and 18 MB between them). The deposit walks the axes'
|
||||||
|
columns rather than the samples, and a frame whose grids did not move costs one draw call per
|
||||||
|
trace (`FrameStats::density_cells`/`density_upload_bytes`); see `docs/data-and-performance.md`;
|
||||||
|
- 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), the area being
|
||||||
|
chosen framed as the drag runs -- a low-alpha fill under a light border, widened along the axis
|
||||||
|
the release will *not* zoom, so Ctrl's frame is the band it is about to apply rather than the
|
||||||
|
sliver a straight horizontal drag sweeps;
|
||||||
|
- 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 # 396 cases, 0 failed
|
||||||
|
|
||||||
|
tools/coverage.sh # configure+build+run+capture+floor check
|
||||||
|
```
|
||||||
|
|
||||||
|
The `linux-debug` suite runs **396 cases**; the `linux-coverage` preset builds **379** 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).
|
||||||
+157
-36
@@ -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,38 @@ 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;
|
||||||
const u32 rows = figure_->row_count();
|
captured_ = false;
|
||||||
row_controllers_.reserve(rows);
|
captured_row_ = 0u;
|
||||||
for (u32 i = 0; i < rows; ++i) {
|
captured_button_ = input::Button::Left;
|
||||||
|
if (figure_ == nullptr) {
|
||||||
|
row_rects_.clear(); // no figure, no rows: the handlers fall back to `controller_`
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// One controller per **plot** (a row of several cells has one for each), indexed by the
|
||||||
|
// flattened plot index the figure uses.
|
||||||
|
const u32 plots = figure_->plot_count();
|
||||||
|
row_controllers_.reserve(plots);
|
||||||
|
for (u32 i = 0; i < plots; ++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). The rects
|
||||||
|
// are the figure's **cells** (one per plot, flattened), which for a figure with one
|
||||||
|
// cell per row are exactly its row rects.
|
||||||
|
row_rects_ = figure_ != nullptr ? figure_->layout().cells : std::vector<Rect>{};
|
||||||
}
|
}
|
||||||
|
|
||||||
input::Controller* Host::row_controller(u32 row) noexcept {
|
input::Controller* Host::row_controller(u32 row) noexcept {
|
||||||
@@ -431,13 +483,14 @@ 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()
|
const u32 row = row_in(rows, x, y);
|
||||||
const u32 row = row_in(rows, y);
|
|
||||||
last_row_ = row;
|
last_row_ = row;
|
||||||
input::Controller& controller = *row_controllers_[row];
|
input::Controller& controller = *row_controllers_[row];
|
||||||
// The row's own viewport, so the geometry this controller hit-tests is the geometry
|
// The row's own viewport, so the geometry this controller hit-tests is the geometry
|
||||||
@@ -446,24 +499,74 @@ input::Controller& Host::controller_at(f32 x, f32 y) noexcept {
|
|||||||
return controller;
|
return controller;
|
||||||
}
|
}
|
||||||
|
|
||||||
u32 Host::row_in(const std::vector<Rect>& rows, f32 y) noexcept {
|
void Host::feed_pointer_event(const input::PointerEvent& e) noexcept {
|
||||||
if (rows.empty()) return 0u;
|
if (figure_ == nullptr) {
|
||||||
// The row the pixel is in, or -- when it is in none of them, which is the row gap a
|
controller_.on_pointer(e);
|
||||||
// host has asked for with `Figure::set_row_gap()` or the figure's own margins -- the
|
return;
|
||||||
// row whose rect is nearest. Selection by distance rather than by "the first that
|
}
|
||||||
// matches" is what makes a pixel in a gap belong to the row it is *near*: the rows
|
pointer_target(e.x, e.y, e.button, e.pressed, e.released).on_pointer(e);
|
||||||
// tile the figure at the default gap of 0, so this is unreachable until a host asks
|
}
|
||||||
// for one, and a gesture in a visible gap must not be routed to row 0 by default.
|
|
||||||
|
input::Controller& Host::pointer_target(f32 x, f32 y, input::Button button, bool pressed,
|
||||||
|
bool released) noexcept {
|
||||||
|
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;
|
u32 row = 0u;
|
||||||
f32 best = std::numeric_limits<f32>::infinity();
|
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, x, 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_x_, last_pointer_y_);
|
||||||
|
}
|
||||||
|
|
||||||
|
u32 Host::row_in(const std::vector<Rect>& rows, f32 x, f32 y) noexcept {
|
||||||
|
if (rows.empty()) return 0u;
|
||||||
|
// The cell the pixel is in, or -- when it is in none of them, which is the row gap or
|
||||||
|
// column gap a host has asked for with `Figure::set_row_gap()`/`set_col_gap()` or the
|
||||||
|
// figure's own margins -- the cell whose rect is nearest (squared distance from the
|
||||||
|
// pixel to the rect). Selection by distance rather than by "the first that matches" is
|
||||||
|
// what makes a pixel in a gap belong to the cell it is *near*, and a gesture in a
|
||||||
|
// visible gap must not be routed to plot 0 by default. For a figure with one cell per
|
||||||
|
// row every cell spans the figure's width, so this reduces to the nearest row by y.
|
||||||
|
u32 row = 0u;
|
||||||
|
double best = std::numeric_limits<double>::infinity();
|
||||||
for (u32 i = 0; i < rows.size(); ++i) {
|
for (u32 i = 0; i < rows.size(); ++i) {
|
||||||
const f32 top = rows[i].y;
|
const Rect& r = rows[i];
|
||||||
const f32 bottom = rows[i].y + rows[i].h;
|
// In double: in f32 the dy^2 term can be swallowed by a large dx^2 (pointer far outside).
|
||||||
const f32 distance = y < top ? top - y : (y >= bottom ? y - bottom : 0.0f);
|
const double dx = x < r.x ? double(r.x) - x : (x >= r.right() ? double(x) - r.right() : 0.0);
|
||||||
|
const double dy = y < r.y ? double(r.y) - y : (y >= r.bottom() ? double(y) - r.bottom() : 0.0);
|
||||||
|
const double distance = dx * dx + dy * dy;
|
||||||
if (distance < best) {
|
if (distance < best) {
|
||||||
best = distance;
|
best = distance;
|
||||||
row = i;
|
row = i;
|
||||||
if (distance == 0.0f) break; // inside this row: nothing can be nearer
|
if (distance == 0.0) break; // inside this cell: nothing can be nearer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return row;
|
return row;
|
||||||
@@ -482,11 +585,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 +612,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
|
|
||||||
+97
-15
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <functional>
|
#include <functional>
|
||||||
@@ -118,25 +121,68 @@ class Host {
|
|||||||
/// `plot_` is then untouched by any of this.
|
/// `plot_` is then untouched by any of this.
|
||||||
void set_figure(Figure* figure) noexcept;
|
void set_figure(Figure* figure) noexcept;
|
||||||
Figure* figure() noexcept { return figure_; }
|
Figure* figure() noexcept { return figure_; }
|
||||||
/// One figure row's controller -- the one a gesture on that row reaches -- or
|
/// One figure plot's controller -- the one a gesture on that plot reaches -- or null
|
||||||
/// null while no figure is set or the row does not exist. A host reads a drag
|
/// while no figure is set or it does not exist. `row` is the **flattened plot index**
|
||||||
|
/// (`Figure::plot_count()` of them: the row count when no row holds several cells). A host reads a drag
|
||||||
/// mode or a hover out of it (`controller()` is the single-plot loop's, and is
|
/// mode or a hover out of it (`controller()` is the single-plot loop's, and is
|
||||||
/// not the one figure gestures go to).
|
/// not the one figure gestures go to).
|
||||||
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
|
/// plot (cell) that pixel lands in otherwise -- x and y both pick it, as a row may hold
|
||||||
/// same ones the renderer draws, so a gesture lands on the row it was aimed at; a
|
/// several cells side by side. The cell rects come from the cache
|
||||||
/// pixel inside no row -- the row gap a host asked for with `Figure::set_row_gap()`,
|
/// `refresh_row_rects()` fills -- the same ones the renderer draws -- so a gesture
|
||||||
/// or a margin -- is the *nearest* row rather than dropped or defaulted, so a drag
|
/// lands on the row it was aimed at and no event lays the figure out; a pixel
|
||||||
|
/// inside no cell -- the row or column gap a host asked for with
|
||||||
|
/// `Figure::set_row_gap()`/`set_col_gap()`, or a margin -- is the *nearest* cell 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_; }
|
||||||
|
|
||||||
@@ -174,11 +220,26 @@ class Host {
|
|||||||
void on_scroll(f64 dx, f64 dy) noexcept;
|
void on_scroll(f64 dx, f64 dy) noexcept;
|
||||||
void on_key(int key, int action, int mods) noexcept;
|
void on_key(int key, int action, int mods) noexcept;
|
||||||
|
|
||||||
/// The index of the row of `rows` a framebuffer y belongs to: the row it is in, or the nearest
|
/// The index of the cell of `rows` (the figure's flattened cell rects) a framebuffer pixel
|
||||||
/// row when it is in none (a row gap, a margin outside the figure). One rule, shared by the event
|
/// belongs to: the cell it is in, or the nearest cell when it is in none (a row or column gap,
|
||||||
/// routing and the crosshair sync -- and taking the rects rather than reading them, so a caller
|
/// a margin outside the figure). One rule, shared by the event routing and the crosshair sync
|
||||||
/// that has just laid the figure out does not make it lay out again for this.
|
/// -- and taking the rects rather than reading them, so a caller that has just laid the figure
|
||||||
static u32 row_in(const std::vector<Rect>& rows, f32 y) noexcept;
|
/// out does not make it lay out again for this.
|
||||||
|
static u32 row_in(const std::vector<Rect>& rows, f32 x, 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 plot (flattened index) the pointer's events currently go to: the captured one
|
||||||
|
/// while a drag owns the button, the one under the pointer otherwise. The run loop uses
|
||||||
|
/// it to clear every *other* plot's hot handles, so one 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).
|
||||||
///
|
///
|
||||||
@@ -217,10 +278,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 cell rects (one per plot, flattened; the row rects when no row is split) 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
|
||||||
+348
-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,208 @@ 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;
|
||||||
|
}
|
||||||
|
// One controller per **plot** (a row of several cells has one for each), indexed by the
|
||||||
|
// flattened plot index the figure uses.
|
||||||
|
const u32 plots = figure_->plot_count();
|
||||||
|
row_controllers_.reserve(plots);
|
||||||
|
for (u32 i = 0; i < plots; ++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. The rects are
|
||||||
|
// the figure's **cells** (one per plot, flattened), which for a figure with one cell
|
||||||
|
// per row are exactly its row rects.
|
||||||
|
row_rects_ = figure_ != nullptr ? figure_->layout().cells : 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_;
|
||||||
|
const u32 row = row_in(row_rects_, x, 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;
|
||||||
|
const Vec2 p = to_device(x, y);
|
||||||
|
return row_in(row_rects_, p.x, p.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
input::Controller& PlotArea::target_controller(f32 x, 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_, x, 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 x, f32 y) noexcept {
|
||||||
|
if (rows.empty()) return 0;
|
||||||
|
// The cell the pixel is in, or -- when it is in none of them, which is the row or
|
||||||
|
// column gap a host asked for with `Figure::set_row_gap()`/`set_col_gap()` or the
|
||||||
|
// figure's own margins -- the cell whose rect is nearest (squared distance from the
|
||||||
|
// pixel to the rect). By distance rather than by "the first that matches": a gesture
|
||||||
|
// in a visible gap must not default to plot 0. For a figure with one cell per row
|
||||||
|
// every cell spans the figure's width, so this reduces to the nearest row by y.
|
||||||
|
u32 row = 0;
|
||||||
|
double best = std::numeric_limits<double>::infinity();
|
||||||
|
for (u32 i = 0; i < rows.size(); ++i) {
|
||||||
|
const Rect& r = rows[i];
|
||||||
|
// In double: in f32 the dy^2 term can be swallowed by a large dx^2 (pointer far outside).
|
||||||
|
const double dx = x < r.x ? double(r.x) - x : (x >= r.right() ? double(x) - r.right() : 0.0);
|
||||||
|
const double dy = y < r.y ? double(r.y) - y : (y >= r.bottom() ? double(y) - r.bottom() : 0.0);
|
||||||
|
const double distance = dx * dx + dy * dy;
|
||||||
|
if (distance < best) {
|
||||||
|
best = distance;
|
||||||
|
row = i;
|
||||||
|
if (distance == 0.0) break; // inside this cell: 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_.x, pointer_.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PlotArea::sync_selection_frames() noexcept {
|
||||||
|
const auto frame_for = [](input::Controller& c) -> Rect {
|
||||||
|
return c.drag_mode() == input::DragMode::BoxZoom ? c.selection_preview() : Rect{};
|
||||||
|
};
|
||||||
|
if (figure_ != nullptr) {
|
||||||
|
for (u32 i = 0; i < row_controllers_.size(); ++i) {
|
||||||
|
// An empty rect on the rows that are not choosing: `Rect` has no equality to compare
|
||||||
|
// with, and a store of the same value is what keeps this path free of anything else.
|
||||||
|
figure_->plot(i).set_selection_rect(frame_for(*row_controllers_[i]));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
plot_->set_selection_rect(frame_for(controller_));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +497,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 +518,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
|
||||||
@@ -309,8 +555,15 @@ void PlotArea::render() noexcept {
|
|||||||
// `::resize` ratio, not `gtk_widget_get_scale_factor()` -- the latter is the
|
// `::resize` ratio, not `gtk_widget_get_scale_factor()` -- the latter is the
|
||||||
// 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;
|
||||||
|
// After the scale and before the draw: the preview reads the row's layout, and a layout measured
|
||||||
|
// with the previous frame's scale is not the one being drawn.
|
||||||
|
sync_selection_frames();
|
||||||
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 +614,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.x, 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 +639,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.x, 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.x, position.y);
|
||||||
press_open_ = false;
|
press_open_ = false;
|
||||||
|
|
||||||
input::PointerEvent e;
|
input::PointerEvent e;
|
||||||
@@ -397,7 +661,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 +674,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 +684,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 +697,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 +720,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 +750,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.x, 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 +767,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.x, 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 +840,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.x, 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 +862,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.x, p.y).on_pointer(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- keyboard -------------------------------------------------------------
|
// --- keyboard -------------------------------------------------------------
|
||||||
@@ -557,20 +874,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 +889,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,58 @@ 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 plot's controller -- the one a gesture on that plot reaches -- or null
|
||||||
|
/// while no figure is set or it does not exist. `row` is the **flattened plot index**
|
||||||
|
/// (`Figure::plot_count()` of them: the row count when no row holds several cells). 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 plot (cell)
|
||||||
|
/// that pixel lands in -- x and y both pick it, as a row may hold several cells side
|
||||||
|
/// by side. A pixel inside no cell (a `Figure::set_row_gap()`/`set_col_gap()` gap, a
|
||||||
|
/// margin) is the *nearest* cell 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 flattened plot index 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 plot a pointer is over without converting anything. The
|
||||||
|
/// same nearest-cell rule (x and y) 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 +364,58 @@ 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 (x, 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 (x and y pick the cell). 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 x, f32 y) noexcept;
|
||||||
|
/// The cell of `row_rects_` (the figure's flattened cell rects) a device pixel belongs
|
||||||
|
/// to: the cell it is in, or the nearest one when it is in none (a row or column 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 cell it is *near*
|
||||||
|
/// instead of to plot 0.
|
||||||
|
static u32 row_in(const std::vector<Rect>& rows, f32 x, 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;
|
||||||
|
/// Hands each row's plot the box-zoom frame its controller is showing, or takes it off
|
||||||
|
/// the ones whose gesture is over. Run once per frame from the render callback, like
|
||||||
|
/// the crosshair's spread and for a related reason: a frame has to go away when a drag
|
||||||
|
/// ends by *any* route -- the release, Escape, a row rebuilt mid-gesture -- and a
|
||||||
|
/// per-event hook would have to be repeated at every one of them, which is how a frame
|
||||||
|
/// ends up stuck on screen after a cancelled zoom.
|
||||||
|
///
|
||||||
|
/// Reads `selection_preview()`, which needs the row's layout, only for a controller
|
||||||
|
/// actually in `BoxZoom`: the cost is paid while a range is being chosen and never
|
||||||
|
/// otherwise.
|
||||||
|
void sync_selection_frames() 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 +427,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 +454,27 @@ 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 cell rects (one per plot, flattened; the row rects when no row is split)
|
||||||
|
/// 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 +486,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,134 @@
|
|||||||
|
# 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 **396 cases** in the `linux-debug`
|
||||||
|
preset; its harness prints one line per case and a final `396 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.
|
||||||
|
|
||||||
|
`PLOT_TEST_FILTER=<substring>` runs only the cases whose name contains that substring, and
|
||||||
|
is for working *on* a case rather than for checking the library: most of the render cases
|
||||||
|
create their own EGL context and read pixels back, so the whole binary is far slower than
|
||||||
|
the one case being edited and its one line of measured output scrolls past 376 others. The
|
||||||
|
summary line still counts the whole suite, so a filtered run says `396 cases, 0 failed`
|
||||||
|
having run one — it is never evidence that the suite passes. Unset, every case runs.
|
||||||
|
|
||||||
|
## 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 **379 of the 396 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,492 @@
|
|||||||
|
# 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. The floor only holds if the riser's rows
|
||||||
|
**reach** a tier: the "lit at all" cutoff that discards a column's empty rows is
|
||||||
|
`min(0.01 × peak, 0.5 × w_min)` over the smallest deposit any of that column's own segments
|
||||||
|
made, and not the first term alone. A column holding a flat level *and* a riser has its peak set
|
||||||
|
by the level — one unit in one row — while the riser spreads a unit over 300, which is three
|
||||||
|
orders of magnitude under `0.01 × peak`, so the riser was discarded before the floor could apply
|
||||||
|
to it. Measured on a 2098-sample square wave at 105 samples a column: **2 916 of the ungraded
|
||||||
|
path's 35 884 riser rows**, 106 of 116 riser columns gutted entirely, with a 311-row dark run in
|
||||||
|
a 312-row axis; the ten survivors were the phase that put a riser in a column of its own, which
|
||||||
|
is why it read as a riser *flickering* under a pan rather than as a missing edge. With the second
|
||||||
|
term it is 35 572 rows and 115 of 116 columns, for 48 extra segments
|
||||||
|
(`a_graded_column_keeps_a_riser_it_shares_with_a_flat_level`). 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.
|
||||||
|
|
||||||
|
## A sparse polyline is graded by slope
|
||||||
|
|
||||||
|
The column reduction stops at `dense_threshold_spp` (1.5 samples a device pixel), below which the
|
||||||
|
trace is a polyline. Grading used to stop there with it, because it was staged *inside* the
|
||||||
|
reduction — and that coupling was a defect, not a design. The reduction's gate is a density, and
|
||||||
|
its only purpose is removing the moiré beat between the bucket pitch and the pixel pitch. Dwell
|
||||||
|
has nothing to do with that beat, so zooming past the threshold turned the phosphor model off in
|
||||||
|
one step: the same square-wave edge measured **71 of 191** at 3.4 samples a pixel and **191 of
|
||||||
|
191** at 1.0, a 2.7× jump across one pixel of zoom, which reads as a discontinuity *lighting up*
|
||||||
|
the moment you look at it closely. The thing a reader most needs told apart from a real signal
|
||||||
|
excursion is a transition, and it was being drawn at full ink exactly when it filled the screen.
|
||||||
|
|
||||||
|
`Renderer::stage_trace_slopes()` grades the polyline on the same principle, differing only where
|
||||||
|
it must:
|
||||||
|
|
||||||
|
- **The measure is the segment's own slope**, `1 / (1 + |Δy_screen|)`, which is dwell per row for
|
||||||
|
a single segment. The `1 +` is not a guard against a division: a flat segment — span 0, the
|
||||||
|
commonest case in a sparse window — must be the *brightest* tier, and this makes the measure
|
||||||
|
dimensionless and **absolute**. There is no per-column peak to normalise against down here, and
|
||||||
|
that is an improvement: a sparse frame's tiers mean the same thing from frame to frame, so
|
||||||
|
panning does not reshade the trace.
|
||||||
|
- **No gather mode is excluded.** The reduction is `Envelope`-only, because turning a bucket's
|
||||||
|
min/max into a band answers a different question from the one a `Nearest` or `Mean` host asked.
|
||||||
|
A segment's slope is a property of the polyline itself, so `grade` is not reused as the gate —
|
||||||
|
only `intensity_grading` and `intensity_steps > 1` are.
|
||||||
|
- **The geometry is bit-for-bit the ungraded polyline's**, staged exactly as `stage_xy()` stages
|
||||||
|
it, with the same `snap = false`; only the batch a segment lands in differs. A segment with one
|
||||||
|
non-finite end is dropped rather than clamped, which is the conclusion `add_polyline_xy()`
|
||||||
|
reaches too.
|
||||||
|
- **`FrameStats::slope_traces` counts it, and it is disjoint from `column_traces`.** A window is
|
||||||
|
either dense enough to reduce onto the pixel grid or it is drawn as a polyline, never both — so
|
||||||
|
a host asserting `graded_traces == column_traces` keeps that reading unchanged.
|
||||||
|
|
||||||
|
`a_sparse_trace_is_graded_by_slope_like_a_dense_one` is the guard: one window either side of the
|
||||||
|
threshold over the same transition at the same place. The level reads **191 either way** (a flat
|
||||||
|
segment dwells in its row, so it is the top tier and full ink) and the riser reads **71 either
|
||||||
|
way**, against the 191 the sparse frame gave before. Across the whole sweep from 101 down to 0.1
|
||||||
|
samples a pixel the riser holds 71 and the level holds 191, so there is no step at the threshold
|
||||||
|
left to see.
|
||||||
|
|
||||||
|
## A phosphor density is the trace, one grid per trace
|
||||||
|
|
||||||
|
`Plot::set_density()` gives a plot a **phosphor density**: an image of how often its traces have
|
||||||
|
passed, drawn *in place of them* -- one grid per trace, in that trace's own colour -- fading by a
|
||||||
|
time constant or by an acquisition count. It is the library's because the library is the side that
|
||||||
|
knows a trace's device geometry; a host that built one itself would be re-rasterising traces the
|
||||||
|
renderer has already staged.
|
||||||
|
|
||||||
|
The split from the grading above is what it costs: grading measures *one* frame's dwell and
|
||||||
|
throws it away, while the density **keeps** what it measured — that is the whole point of it, and
|
||||||
|
also why it cannot be a per-frame accumulator.
|
||||||
|
|
||||||
|
- **The grid is the screen's, not the data's — bounded — and there is one per trace.** The axes
|
||||||
|
rect in device pixels (`DensityGrid`, `plot/plot/density.hpp`), one `f32` cell each, **up to
|
||||||
|
`kDensityCellCap` = 262 144 cells** (2^18, a mebibyte of counts): under the cap the grid *is* the
|
||||||
|
rect — the axes of a 640x360 viewport is a **573x313** grid of **179 349 cells** and **717 396
|
||||||
|
bytes**, and a thousand acquisitions through it cost the same — and past it the rect is scaled
|
||||||
|
down *uniformly*, so a cell stays square and the picture keeps the axes' aspect. A plot holds one
|
||||||
|
of these for **each trace it draws**, so the display's memory is that figure times the traces on
|
||||||
|
screen; the bound is per *grid*, so four traces hold at most 1 MiB each whatever the window is.
|
||||||
|
The colour is the reason for the per-trace split: a density that had one hue for the plot could
|
||||||
|
not say *which* trace put the ink there, and two signals agreeing in a cell would read as one
|
||||||
|
blended pixel. `Plot::density_grid(trace)` returns a trace's image; a trace with no series (an
|
||||||
|
unbound slot) holds no grid at all.
|
||||||
|
|
||||||
|
The cap is what keeps the resolution from following the window, and the window is not a fixed
|
||||||
|
thing: a 1520x850-logical window at 2x device scale has a 2877-pixel-wide axes rect, and four
|
||||||
|
rows of one trace are **4.5 M cells** — 18 MB of counts, every one of them walked per frame. The
|
||||||
|
pass is `O(cells)` in all three places it happens, so an unbounded image makes the *frame* cost a
|
||||||
|
function of the window: measured on llvmpipe (four traces, one a row, a 2 s window, a 3040x1700
|
||||||
|
framebuffer) the grid pass alone was **12.9 ms of tick and 12.8 ms of draw a frame** and 4.9 MB
|
||||||
|
uploaded, against **3.9 ms / 5.6 ms** and 1.0 MB at the cap. The renderer magnifies the capped
|
||||||
|
image back over the rect with a **linear** filter, because a cell is a *count* — a continuous
|
||||||
|
quantity — and a fragment between two of them is between their two ink levels, which is what a
|
||||||
|
linear tap returns and a point sample cannot (it would show the cell grid as blocks once a cell
|
||||||
|
is several pixels wide). A one-cell-tall feature is thereby spread over its neighbours, which is
|
||||||
|
what any magnification of an image does.
|
||||||
|
- **A cell's value is a number of sweeps**, and a trace covering it during one
|
||||||
|
`Plot::accumulate_density()` leaves 1.0, **once however many of its own samples crossed it**.
|
||||||
|
Counting the hits instead puts a single dense sweep at the ceiling of the brightness scale and
|
||||||
|
hides the sweeps behind it, and a cell would then mean "how much the beam moved here" rather
|
||||||
|
than "how many acquisitions were here". The dedupe is a stamp per grid *row* (one `u64`, a few
|
||||||
|
kB, shared by every trace's deposit), keyed by (trace, column), so two sweeps agreeing on a cell
|
||||||
|
still count twice and the two segments that share a column boundary do not.
|
||||||
|
- **The deposit is one pass per cell, not one per sample.** A trace's visible window is mapped to
|
||||||
|
sample indices and gathered exactly as `Renderer::draw_traces()` maps it (`neighbours()`, the
|
||||||
|
same 64-spp gate for the LOD level), then walked as a polyline: for each grid column the
|
||||||
|
segment covers, the rows it spans in that column get +1 each. A window of millions of samples
|
||||||
|
over hundreds of columns therefore costs *columns*, plus one row of ink per column the trace
|
||||||
|
crossed — the deposit is bounded by the screen, not by the data, which is what makes an
|
||||||
|
accumulation affordable at the trigger's own rate.
|
||||||
|
- **The newest acquisition is deposited like every other, and no trace is drawn over the image.**
|
||||||
|
`Renderer::draw_traces()` returns at once while a density is on: the trace ink *is* the image, so
|
||||||
|
the acquisition just drawn is a sweep *inside* it and fades with the rest. Drawing the polyline
|
||||||
|
on top as well would put a solid, unfading stroke over its own faint ghost — the "persistent
|
||||||
|
image + last acquisition" a user reported, where the newest acquisition never faded.
|
||||||
|
- **The brightness ceiling is the trace's own brightness, and the ramp is linear in sweeps.**
|
||||||
|
`DensityConfig::full_scale` defaults to **one sweep**, so a cell with one acquisition's ink is
|
||||||
|
drawn in its trace's colour at that colour's own alpha, and a signal crossing the same place
|
||||||
|
every acquisition settles *exactly* at the colour its trace would have drawn — the settlement is
|
||||||
|
a clamp, not a gain. `gamma` defaults to 1.0, because a cell's value is a count and half as many
|
||||||
|
sweeps must read as half the ink. A ceiling at the *fade's steady state* (`1/(1-0.5)=2`) was the
|
||||||
|
first default and is a legitimate alternative: it makes the newest sweep a fraction of the
|
||||||
|
settled brightness so the fade's ramp is visible, at the cost of a gain offset between the newest
|
||||||
|
sweep and the image behind it. The case that reads either one off the *screen* rather than the
|
||||||
|
grid is `a_phosphor_density_is_the_trace_in_its_own_colour`.
|
||||||
|
- **The scale is applied once, on the CPU.** `DensityConfig::full_scale` and `gamma` map a cell's
|
||||||
|
count to a luminance byte when the grid changed, and the shaders (`kDensityVS`/`kDensityFS`)
|
||||||
|
only tint that byte with the trace's colour. The one derivation is deliberate: a value a host
|
||||||
|
reads back through `DensityGrid::ink()`/`peak()` and the picture it sees come from the same
|
||||||
|
numbers.
|
||||||
|
- **Per frame, it is one texture upload and one draw call *per trace* — or one draw call each.**
|
||||||
|
The upload is one byte per cell (**179 kB** for the axes above, and at most 256 kB a trace at
|
||||||
|
`kDensityCellCap`, whatever the window); the one texture is re-filled per trace, and a frame whose
|
||||||
|
grids did not move skips the conversion and the uploads entirely and pays the quads alone
|
||||||
|
(`FrameStats::density_upload_bytes` reads 0 for it). The decay is what makes the grids move every
|
||||||
|
frame under `TimeConstant`, and what makes them stand still between acquisitions under
|
||||||
|
`PerAcquisition`.
|
||||||
|
A `TimeConstant` fade therefore does pay the conversion and the upload every frame, whatever the
|
||||||
|
bound — a *bounded* bill rather than a free one. Applying the fade on the GPU instead was
|
||||||
|
considered and is a **larger** change than this bound and was deliberately not bundled with it:
|
||||||
|
the image would have to live in a ping-pong target the decay multiplies in place, while the
|
||||||
|
*count* stays on the CPU, because that is what a host reads back through `ink()`/`peak()`, what
|
||||||
|
the deposit adds whole swaths to, and what the per-sweep dedupe keys on — so the two would have
|
||||||
|
to be reconciled per frame anyway. The bound is what makes that reconciliation a fixed cost.
|
||||||
|
|
||||||
|
`a_density_grid_is_the_axes_in_device_pixels` reads the resolution and the memory back
|
||||||
|
(**578x453 = 261 834 cells, 1 047 336 bytes** on its 640x480 viewport, which is past the cap, and
|
||||||
|
the 320x240 leg it ends on is the axes rect *exactly*, one cell a device pixel —
|
||||||
|
`ink=578 peak=1` for one flat sweep, the ink of one sweep *is* the columns it covers),
|
||||||
|
`a_density_grid_is_capped_whatever_the_window` reads the bound itself off a 3840x2160 viewport
|
||||||
|
(**681x384 = 261 504 cells = 1 046 016 bytes of a 3784x2138 axes**: the cells never exceed
|
||||||
|
`kDensityCellCap`, the grid is within 1 % of the whole budget and its aspect is the axes' — the
|
||||||
|
uniform scaling), and `a_capped_density_is_magnified_over_the_axes_rect` reads the *screen* side of
|
||||||
|
it (**569x460 = 261 740 cells of an 832x673 axes**: the image covers all **832** of the rect's
|
||||||
|
columns rather than the 569 cells it holds — the assertion a quad drawn at the grid's own size
|
||||||
|
fails — the sweep stays on the row its transform maps it to, and its ink is most of the trace's own
|
||||||
|
colour, `green=195` against a background of `65`, the rest being the magnification's blend),
|
||||||
|
`a_density_halves_per_acquisition_and_keeps_the_newest_brightest` reads the count schedule
|
||||||
|
(578 → 867 → 1012 on a 578-column grid, i.e. `1 + 1/2 + 1/4` of the width, `peak_steady=2.000`),
|
||||||
|
`a_density_fades_by_its_time_constant` reads `exp(-dt/tau)` off the ink,
|
||||||
|
`a_density_holds_one_grid_per_trace` reads two traces' grids apart (**261 834 cells each,
|
||||||
|
ink 578/578**, in two different rows), and
|
||||||
|
`a_phosphor_density_is_the_trace_in_its_own_colour` reads the frame: `cells=179349 uploaded=179349`
|
||||||
|
on the frame the grid moved and `uploaded=0` on the identical one after it; a saturated cell is the
|
||||||
|
trace's own colour exactly (`full=224`, the trace's `0xE0` green, against `bg=25`, where the old
|
||||||
|
near-white tint read ~190 red); one time constant dims it (`faded=115`); and a steady state
|
||||||
|
(`steady=224`) against a quarter of it (`quarter=138`) keeps the trace's hue, red near the
|
||||||
|
background's.
|
||||||
|
|
||||||
|
## 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,250 @@
|
|||||||
|
# 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).
|
||||||
|
|
||||||
|
The frame an open box zoom is drawing is published the same way and for the same reason
|
||||||
|
(`Plot::set_selection_rect()`), and the plot still learns nothing about pointers: the host reads the
|
||||||
|
rect off the controller -- `Controller::selection_preview()` -- on every frame of the drag and takes it
|
||||||
|
off the plot when the gesture ends. The preview is deliberately not what the pointer swept. Shift
|
||||||
|
frames both axes and its preview is the swept box; Ctrl frames x only, so its preview is widened to the
|
||||||
|
full height of the axes, and Alt's to the full width. The swept sliver is the *gesture*; the widened
|
||||||
|
rect is what the *release* will apply, which is the one worth showing while it is still only a
|
||||||
|
gesture. Nothing is previewed until the drag has swept every axis the release will act on: an empty
|
||||||
|
rect widened is a band the width of the plot, which would claim a range that does not exist yet.
|
||||||
|
|
||||||
|
## 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`.
|
||||||
+190
@@ -0,0 +1,190 @@
|
|||||||
|
# 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 |
|
||||||
|
| `invalid_fill` | `0x8A94A6` at α 0.18 | a span the host declared invalid (`Plot::set_invalid_spans()`) |
|
||||||
|
| `invalid_repaired_fill` | `0x8A94A6` at α 0.08 | the same span once the host has marked it repaired |
|
||||||
|
| `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,7 +70,25 @@ 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);
|
||||||
|
/// Folds a backfilled sample into every aggregate that covers absolute index
|
||||||
|
/// `absolute_sample`.
|
||||||
|
///
|
||||||
|
/// **Precondition: the sample at that index was appended non-finite.** That is what makes
|
||||||
|
/// this purely additive -- min/max widen, sum/sumsq accumulate and count rises by one, at
|
||||||
|
/// every covering level -- so no bucket is ever recomputed and the cost is O(levels)
|
||||||
|
/// regardless of how wide the repaired span is. Amending a *finite* sample would need the
|
||||||
|
/// old value removed from sum/sumsq and could not restore min/max at all, which is why
|
||||||
|
/// this is a one-way NaN-to-value operation and not a general write.
|
||||||
|
///
|
||||||
|
/// An index outside `[window_base(), window_end())` is a no-op: the window slides on the
|
||||||
|
/// consumer thread, so a caller cannot prove the slot is still held before asking.
|
||||||
|
void amend(u64 absolute_sample, 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.
|
||||||
/// Monotonic, and a no-op only when `absolute_sample <= window_base()`: an empty
|
/// Monotonic, and a no-op only when `absolute_sample <= window_base()`: an empty
|
||||||
@@ -97,6 +124,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 +152,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.
|
||||||
///
|
///
|
||||||
@@ -56,6 +64,24 @@ class RingBuffer {
|
|||||||
for (u64 i = 0; i < n; ++i) push(vs[i]);
|
for (u64 i = 0; i < n; ++i) push(vs[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Producer thread only: rewrites the slot at absolute index `absolute`, without pushing.
|
||||||
|
/// Returns false when that index is not retained -- already overwritten, or not yet
|
||||||
|
/// produced -- in which case nothing is written.
|
||||||
|
///
|
||||||
|
/// This is for *backfill*: a slot pushed as a placeholder and corrected when the data that
|
||||||
|
/// belongs there arrives late. It is the same race class as `push()` -- one relaxed store
|
||||||
|
/// into a slot a consumer may be reading, and T is lock-free by the class's static_assert,
|
||||||
|
/// so a reader sees the old value or the new one and never a torn one. It deliberately does
|
||||||
|
/// not touch `produced_`: the count fixes which indices a snapshot reads, and an amend
|
||||||
|
/// changes a value inside that window rather than extending it.
|
||||||
|
bool amend(u64 absolute, T v) {
|
||||||
|
const u64 p = produced_.load(std::memory_order_relaxed);
|
||||||
|
if (absolute >= p) return false;
|
||||||
|
if (p > cap_ && absolute < p - cap_) return false;
|
||||||
|
buf_[static_cast<usize>(absolute & mask_)].store(v, std::memory_order_relaxed);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// Consumer: retained element count, saturating at capacity().
|
/// Consumer: retained element count, saturating at capacity().
|
||||||
u64 size() const noexcept {
|
u64 size() const noexcept {
|
||||||
const u64 p = produced_.load(std::memory_order_acquire);
|
const u64 p = produced_.load(std::memory_order_acquire);
|
||||||
@@ -101,6 +127,18 @@ class RingBuffer {
|
|||||||
/// pushing).
|
/// pushing).
|
||||||
void clear() noexcept { produced_.store(0, std::memory_order_relaxed); }
|
void clear() noexcept { produced_.store(0, std::memory_order_relaxed); }
|
||||||
|
|
||||||
|
/// Consumer thread only: discards every element and resumes the absolute count
|
||||||
|
/// at `absolute`, so the next push() lands there (not safe while a producer is
|
||||||
|
/// pushing).
|
||||||
|
///
|
||||||
|
/// This is for a *recompute*: a ring whose contents are derived from another is
|
||||||
|
/// emptied and refilled from a given absolute origin, and it must come back
|
||||||
|
/// numbered as its source is. clear() would restart it at 0 and leave every
|
||||||
|
/// absolute index -- base(), produced(), copy_from() -- off by that origin.
|
||||||
|
void clear_to(u64 absolute) noexcept {
|
||||||
|
produced_.store(absolute, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
u64 cap_;
|
u64 cap_;
|
||||||
u64 mask_;
|
u64 mask_;
|
||||||
|
|||||||
@@ -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,13 +213,34 @@ 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.
|
||||||
Rect selection_rect() const noexcept { return selection_; }
|
Rect selection_rect() const noexcept { return selection_; }
|
||||||
|
|
||||||
|
/// The rectangle a host should *draw* for the open box zoom: the swept area, widened to the full
|
||||||
|
/// axes along whichever axis the modifier's mask will not zoom. Ctrl frames the x range only, so its
|
||||||
|
/// preview is the band that spans the plot -- which is what the release will zoom -- where the area
|
||||||
|
/// a purely horizontal drag sweeps is a sliver a few pixels tall. Shift (and any other combo) frames
|
||||||
|
/// both, and gets the swept box itself.
|
||||||
|
///
|
||||||
|
/// Meaningful while `BoxZoom`, like `selection_rect()`; the zero rect otherwise.
|
||||||
|
///
|
||||||
|
/// Defined in the implementation: widening the preview to the axes needs the plot's layout, and
|
||||||
|
/// `Plot` is only forward-declared here.
|
||||||
|
Rect selection_preview() const noexcept;
|
||||||
/// The cursor under the pointer -- a vertical marker or a horizontal ruler, whichever
|
/// The cursor under the pointer -- a vertical marker or a horizontal ruler, whichever
|
||||||
/// line the pointer is nearer -- or `kInvalid` when the pointer is outside the plot area
|
/// line the pointer is nearer -- or `kInvalid` when the pointer is outside the plot area
|
||||||
/// or no cursor is within a few pixels.
|
/// or no cursor is within a few pixels.
|
||||||
@@ -234,6 +268,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 +377,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>
|
||||||
@@ -50,6 +54,7 @@
|
|||||||
#include <plot/plot/axis.hpp>
|
#include <plot/plot/axis.hpp>
|
||||||
#include <plot/plot/crosshair.hpp>
|
#include <plot/plot/crosshair.hpp>
|
||||||
#include <plot/plot/cursor.hpp>
|
#include <plot/plot/cursor.hpp>
|
||||||
|
#include <plot/plot/density.hpp>
|
||||||
#include <plot/plot/figure.hpp>
|
#include <plot/plot/figure.hpp>
|
||||||
#include <plot/plot/grid.hpp>
|
#include <plot/plot/grid.hpp>
|
||||||
#include <plot/plot/legend.hpp>
|
#include <plot/plot/legend.hpp>
|
||||||
@@ -61,3 +66,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,17 @@ 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 axis is a member of a `Figure` link group. On an x axis that is
|
||||||
|
/// an 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()`).
|
||||||
|
/// On a y axis it is the row's shared-y group (`Figure::set_share_row_y()`): the
|
||||||
|
/// group, not the plot, owns the range *and* the auto scale state, so
|
||||||
|
/// `Plot::autoscale_axes()` skips the axis and the figure fits the union of the
|
||||||
|
/// members' traces instead.
|
||||||
|
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;
|
||||||
|
|
||||||
|
|||||||
+112
-28
@@ -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,15 +87,27 @@ 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;
|
||||||
|
/// Write the two values the crosshair's lines meet the axes at as small badges in the
|
||||||
|
/// label strips -- the y value beside the y ticks, the x value beside the x ticks, each
|
||||||
|
/// in its axis' own format. On by default: it is the axis reading an oscilloscope puts
|
||||||
|
/// where the eye already is, and a host that does not want it turns it off
|
||||||
|
/// (`Plot::set_crosshair_axis_labels()`). The badges are placed only when the strip they
|
||||||
|
/// belong to exists and the line they belong to is drawn (a vertical-only crosshair has
|
||||||
|
/// no y to badge, and a row whose x strip went to a neighbour badges nothing).
|
||||||
|
bool axis_labels = true;
|
||||||
/// The data x of the vertical line, on x axis 0.
|
/// The data x of the vertical line, on x axis 0.
|
||||||
f64 x = 0.0;
|
f64 x = 0.0;
|
||||||
/// 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 +125,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 +148,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 +163,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 +197,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 +214,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`.
|
||||||
@@ -212,6 +285,17 @@ struct CrosshairLayout {
|
|||||||
/// `Crosshair::show_readout` is off, or when the panel could not be placed inside the
|
/// `Crosshair::show_readout` is off, or when the panel could not be placed inside the
|
||||||
/// axes at all.
|
/// axes at all.
|
||||||
Rect panel{};
|
Rect panel{};
|
||||||
|
/// The two axis badges, when `Crosshair::axis_labels` is on and the crosshair is active:
|
||||||
|
/// the y value in the **y label strip**, right-aligned on the axis line at the horizontal
|
||||||
|
/// line's height, and the x value in the **x label strip**, centred on the vertical
|
||||||
|
/// line's column -- each in its axis' own format, so a badge reads exactly like the tick
|
||||||
|
/// labels beside it. Zero-sized (and the text empty) when the flag is off, the crosshair
|
||||||
|
/// is inactive, the line they belong to is not drawn, their strip does not exist (a row
|
||||||
|
/// whose x strip went to the row below), or there is no text backend to measure them.
|
||||||
|
Rect y_axis_badge{};
|
||||||
|
std::string y_axis_text;
|
||||||
|
Rect x_axis_badge{};
|
||||||
|
std::string x_axis_text;
|
||||||
/// One row per visible trace, in trace order -- see `CrosshairRow`. Present whether or
|
/// One row per visible trace, in trace order -- see `CrosshairRow`. Present whether or
|
||||||
/// not the panel could list them, so the markers are drawn even in a plot too small to
|
/// not the panel could list them, so the markers are drawn even in a plot too small to
|
||||||
/// hold a panel.
|
/// hold a panel.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
#include <span>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <plot/core/types.hpp>
|
||||||
|
|
||||||
|
namespace plot {
|
||||||
|
|
||||||
|
/// The most cells one density grid may hold: **262 144**, a mebibyte of `f32` counts.
|
||||||
|
///
|
||||||
|
/// A grid is the axes rect in device pixels -- the screen's resolution and not the data's, which is
|
||||||
|
/// the whole point of accumulating into a grid -- but the screen is not a fixed thing. A window can
|
||||||
|
/// be dragged to any width, and at 2x device scale the axes rects of a 1520x850-logical window are
|
||||||
|
/// 2877 device pixels wide each: four rows of one trace are **4.5 M cells**, 18 MB of counts
|
||||||
|
/// between them, and every one of them is walked per frame. That walk is `O(cells)`: the fade
|
||||||
|
/// multiplies every cell, a host's `ink()`/`peak()` read every cell, and a frame the fade moved
|
||||||
|
/// converts and uploads every cell as one byte. So a resolution tied to the window is a per-frame
|
||||||
|
/// cost tied to the window, with no ceiling -- measured headless on llvmpipe with four rows of one
|
||||||
|
/// trace, a 2 s window and a 3040x1700 framebuffer: **4.5 M cells, 12.9 ms of tick and 12.8 ms of
|
||||||
|
/// draw a frame, 4.9 MB uploaded**.
|
||||||
|
///
|
||||||
|
/// This is the ceiling, in **cells** rather than in pixels: the axes rect is scaled down, uniformly,
|
||||||
|
/// until it fits, so a cell stays square and the picture keeps the axes' aspect (see `DensityGrid`).
|
||||||
|
/// The number is a frame's budget read off that same measurement -- the same four rows at the cap
|
||||||
|
/// are 1.05 M cells and **3.9 ms of tick and 5.6 ms of draw**, a third and a half of the unbounded
|
||||||
|
/// grid's pass -- while the image is still 681 columns across on a 3784-pixel axes rect. It is a
|
||||||
|
/// **cap** and not a resolution: a grid whose axes rect is under it is still exactly that rect in
|
||||||
|
/// device pixels, and only an axes rect of more than a quarter of a megapixel -- a 4K window's, or
|
||||||
|
/// one row of a tall one -- is scaled.
|
||||||
|
inline constexpr usize kDensityCellCap = usize{1} << 18;
|
||||||
|
|
||||||
|
/// Which way a plot's **phosphor density** fades between the acquisitions drawn into it.
|
||||||
|
///
|
||||||
|
/// The two are answers to two different questions, which is why the choice belongs to the
|
||||||
|
/// caller rather than to a default: a bench scope's persistence is set by *time* -- "the trace
|
||||||
|
/// is still visible a second after it was drawn" -- while a sweep-to-sweep accumulation is set
|
||||||
|
/// by *count* -- "the tenth acquisition back is gone". The two agree only while the trigger
|
||||||
|
/// rate is constant, and a rate that varies between runs is exactly when a caller has to pick.
|
||||||
|
enum class DensityDecay {
|
||||||
|
/// `intensity *= exp(-dt / time_constant_s)` every time the grid is advanced
|
||||||
|
/// (`Plot::decay_density()`), so the image fades while you watch it whatever the trigger
|
||||||
|
/// rate -- a slow stream draws faint sweeps, a fast one bright ones, and both are still
|
||||||
|
/// legible a time constant later. A `time_constant_s <= 0` freezes the grid instead of
|
||||||
|
/// fading it.
|
||||||
|
TimeConstant,
|
||||||
|
/// `intensity *= per_acquisition` once per accumulated acquisition, applied by
|
||||||
|
/// `Plot::accumulate_density()` *before* the new acquisition is deposited, so the sweep just
|
||||||
|
/// drawn is the brightest and each older one is the factor down: the N-th back is gone
|
||||||
|
/// whatever the rate. It scales with the trigger rate by construction -- the record of the
|
||||||
|
/// last few sweeps is what the display holds -- which is what a caller wants when the
|
||||||
|
/// interesting comparison is between successive acquisitions and not between seconds.
|
||||||
|
PerAcquisition,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A plot's phosphor density: whether the grid is on, how it fades, and how a cell's count is
|
||||||
|
/// turned into brightness. Plain data, replaced wholesale by `Plot::set_density()`.
|
||||||
|
///
|
||||||
|
/// The image is **one grid per trace**, drawn in that trace's own colour (`Trace::color`), because
|
||||||
|
/// a density that had one hue for the whole plot could not say *which* trace put the ink there --
|
||||||
|
/// two signals agreeing in a cell would be one blended pixel, and a display of several traces
|
||||||
|
/// could not be read back to the trace it came from. A cell's brightness is a count of sweeps in
|
||||||
|
/// *that trace's* colour: `full_scale` (default one sweep, the trace's own brightness) is where it
|
||||||
|
/// saturates, and a signal crossing the same place every acquisition settles exactly at the colour
|
||||||
|
/// its trace would have drawn.
|
||||||
|
///
|
||||||
|
/// Its reason to exist as a *plot* feature rather than a host's overlay is the same one
|
||||||
|
/// `Plot::set_invalid_spans()` has: the plot is what knows a trace's device geometry -- the
|
||||||
|
/// visible window about to be drawn, on the axes rect the layout settled -- so the host would
|
||||||
|
/// otherwise have to rasterise traces the library has already staged, at a second derivation of
|
||||||
|
/// the same mapping that could disagree with the first.
|
||||||
|
struct DensityConfig {
|
||||||
|
/// Whether the grid is drawn *and* accumulated. Off is the default and costs nothing: no
|
||||||
|
/// grid is allocated, no frame uploads one, and the traces are drawn exactly as they were.
|
||||||
|
/// Turning it off drops what was accumulated --- a caller that has stopped paying is not
|
||||||
|
/// holding a stale image -- see `Plot::set_density()`.
|
||||||
|
bool enabled = false;
|
||||||
|
/// How the grid fades (see `DensityDecay`).
|
||||||
|
DensityDecay decay = DensityDecay::PerAcquisition;
|
||||||
|
/// `TimeConstant`'s constant, in **stream seconds** -- the same clock the x axis reads, so a
|
||||||
|
/// caller advancing the density by its frame's own `dt` gets a fade that does not depend on
|
||||||
|
/// the frame rate. `<= 0` freezes the grid.
|
||||||
|
f32 time_constant_s = 1.0f;
|
||||||
|
/// `PerAcquisition`'s factor, clamped to `[0, 1]` at use. 0.5 -- half per acquisition -- is
|
||||||
|
/// what a phosphor's afterglow looks like sweep to sweep: 1, 0.5, 0.25, 0.125 and by the
|
||||||
|
/// seventh the layer is under 1 %.
|
||||||
|
f32 per_acquisition = 0.5f;
|
||||||
|
/// The cell value that reads as **full brightness**, in accumulated sweeps -- and the one field
|
||||||
|
/// a caller reads *off its own trace* rather than off the fade. A cell at this value is drawn in
|
||||||
|
/// its trace's own colour at that colour's own alpha; the default of **one sweep** is therefore
|
||||||
|
/// the ink the newest acquisition leaves, so the acquisition just drawn reads exactly as the
|
||||||
|
/// trace it is a picture of (a signal crossing the same place every acquisition settles at the
|
||||||
|
/// trace's brightness, where a ceiling above one sweep would settle *brighter* than the trace and
|
||||||
|
/// leave a gain offset between the newest sweep and the image behind it). Everything above the
|
||||||
|
/// ceiling clamps to the same pixel: where several acquisitions agree, the cell is the trace's
|
||||||
|
/// colour at full strength and no brighter.
|
||||||
|
///
|
||||||
|
/// A caller that wants the fade's own ramp on screen instead -- the newest sweep a *fraction* of
|
||||||
|
/// the steady state, so the eye can rank the sweeps -- sets this to the sum the fade settles at
|
||||||
|
/// (for `PerAcquisition`'s factor `f` that is `1/(1-f)`) and accepts the gain offset; that was
|
||||||
|
/// this field's first default, and it is a legitimate choice, not the one a phosphor makes.
|
||||||
|
f32 full_scale = 1.0f;
|
||||||
|
/// The gamma applied to the normalised cell value before it becomes a luminance. **1.0 by
|
||||||
|
/// default: linear in sweeps**, because that is what a count means -- a cell covered by half the
|
||||||
|
/// acquisitions is half the ink. Under 1 lifts the faint end, and that is what
|
||||||
|
/// `Style::intensity_gamma` is for where the value is a *dwell fraction* rather than a count;
|
||||||
|
/// here it flattens the top instead (0.45 maps a quarter of the scale to 0.54 and the ceiling to
|
||||||
|
/// 1.0, which is most of the ramp spent on the last sweep). Over 1 darkens the faint end for a
|
||||||
|
/// caller that wants only the strong agreement visible.
|
||||||
|
f32 gamma = 1.0f;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The density's own image: **the axes rect in device pixels, up to `kDensityCellCap` cells**, so
|
||||||
|
/// its resolution is the screen's and not the data's -- the whole point of accumulating into a grid
|
||||||
|
/// rather than keeping the acquisitions that were drawn into it -- while still being bounded by
|
||||||
|
/// something other than the window a user has dragged. Under the cap the grid *is* the axes rect and
|
||||||
|
/// a cell is a device pixel; past it the rect is scaled down uniformly (see `kDensityCellCap` for
|
||||||
|
/// the number and the frame budget it comes from), and the renderer magnifies the image back over
|
||||||
|
/// the rect with a linear filter, because a cell is a count of sweeps and not a pixel of a picture.
|
||||||
|
///
|
||||||
|
/// The bound is what makes the memory and the per-frame cost *numbers* rather than functions of the
|
||||||
|
/// window: `columns() * rows() * 4` bytes whatever the trigger has done **and** whatever the
|
||||||
|
/// window is, at most **262 144 cells, 1 048 576 bytes** a grid -- against the 4.5 MB a trace a
|
||||||
|
/// 3040x1700 framebuffer's axes used to ask for, and the 26 ms of tick and draw a frame the pass
|
||||||
|
/// over four of them then cost. A plot holds **one grid per trace** (see `DensityConfig`), so a
|
||||||
|
/// display's memory is that figure times the traces actually drawn -- a plot's unbound slots hold no
|
||||||
|
/// grid -- and four traces hold at most 4 MiB between them. The renderer adds one byte per cell as
|
||||||
|
/// the luminance it uploads (`DensityGrid::cells()` is `f32` because a **count** is what decays and
|
||||||
|
/// what `ink()`/`peak()` report; the byte is derived per changed frame, not stored here).
|
||||||
|
///
|
||||||
|
/// A cell's value is a **number of sweeps**: every visible trace of the plot that covers that cell
|
||||||
|
/// during one `Plot::accumulate_density()` leaves 1.0 in it -- once, however many of that trace's
|
||||||
|
/// own samples crossed it -- so brightness means how many acquisitions were here, and two traces
|
||||||
|
/// agreeing in one cell is twice the ink. (What the *beam* did inside one column is a different
|
||||||
|
/// question, and the library answers it elsewhere: `Style::intensity_grading`.) Decay multiplies
|
||||||
|
/// the whole image, so a cell's value is a decaying count of sweeps and not a bounded score.
|
||||||
|
///
|
||||||
|
/// The grid is indexed row-major from the axes rect's **top-left** corner: cell `(c, r)` is at
|
||||||
|
/// `r * columns() + c` and covers the device span `[axes.x + c*w/columns(), axes.x + (c+1)*w/columns())`
|
||||||
|
/// by `[axes.y + r*h/rows(), axes.y + (r+1)*h/rows())` -- the device pixel `(axes.x + c, axes.y + r)`
|
||||||
|
/// while the rect is under the cap, and a block of them when the plot scaled the image down.
|
||||||
|
/// `revision()` moves on every change, which is what lets the renderer skip the upload -- and the
|
||||||
|
/// whole conversion -- on a frame the grid did not move.
|
||||||
|
class DensityGrid {
|
||||||
|
public:
|
||||||
|
/// Grid width in cells: the axes rect's width in device pixels, rounded up, scaled down when the
|
||||||
|
/// rect is past `kDensityCellCap` (see the class comment). 0 until the first accumulation on a
|
||||||
|
/// laid-out plot.
|
||||||
|
u32 columns() const noexcept { return columns_; }
|
||||||
|
/// Grid height in cells: the axes rect's height in device pixels, rounded up, scaled down with
|
||||||
|
/// `columns()`: `columns() * rows()` cells.
|
||||||
|
u32 rows() const noexcept { return rows_; }
|
||||||
|
usize cell_count() const noexcept {
|
||||||
|
return static_cast<usize>(columns_) * static_cast<usize>(rows_);
|
||||||
|
}
|
||||||
|
/// The cells, row-major from the top-left, one per cell of the axes rect -- see the class comment
|
||||||
|
/// for the resolution and what one cell's value means.
|
||||||
|
std::span<const f32> cells() const noexcept { return cells_; }
|
||||||
|
/// One cell, or 0.0 outside the grid.
|
||||||
|
f32 at(u32 column, u32 row) const noexcept {
|
||||||
|
if (column >= columns_ || row >= rows_) return 0.0f;
|
||||||
|
return cells_[static_cast<usize>(row) * columns_ + column];
|
||||||
|
}
|
||||||
|
/// The **total ink**: every cell's value summed. The reading that says how much has been
|
||||||
|
/// drawn into the grid at all -- it grows with each accumulation and falls with each decay,
|
||||||
|
/// and is 0 for a grid that is off or was just cleared.
|
||||||
|
f64 ink() const noexcept {
|
||||||
|
f64 total = 0.0;
|
||||||
|
for (const f32 v : cells_) total += static_cast<f64>(v);
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
/// The **peak**: the brightest cell's value, 0 for an empty grid. It is the reading that
|
||||||
|
/// says whether the accumulation is saturating (at or past `DensityConfig::full_scale`, which
|
||||||
|
/// reads as full brightness) and whether the decay has reached the sweep behind the newest.
|
||||||
|
f32 peak() const noexcept {
|
||||||
|
f32 top = 0.0f;
|
||||||
|
for (const f32 v : cells_) top = v > top ? v : top;
|
||||||
|
return top;
|
||||||
|
}
|
||||||
|
/// How many times the image changed: bumped by every accumulation, decay, clear and resize.
|
||||||
|
/// The renderer compares it with the revision it last uploaded, so a frame that changed
|
||||||
|
/// nothing uploads nothing.
|
||||||
|
u64 revision() const noexcept { return revision_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
friend class Plot;
|
||||||
|
std::vector<f32> cells_;
|
||||||
|
u32 columns_ = 0;
|
||||||
|
u32 rows_ = 0;
|
||||||
|
u64 revision_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace plot
|
||||||
@@ -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
|
||||||
|
|||||||
+158
-31
@@ -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>
|
||||||
@@ -24,13 +27,21 @@ struct FigureLayout {
|
|||||||
/// strips and axes come from its own `Plot::layout()` against this rect, so a row
|
/// strips and axes come from its own `Plot::layout()` against this rect, so a row
|
||||||
/// with a y title keeps its column and no two rows can disagree about chrome.
|
/// with a y title keeps its column and no two rows can disagree about chrome.
|
||||||
std::vector<Rect> rows;
|
std::vector<Rect> rows;
|
||||||
/// The strip the figure's one shared x axis is drawn in: the bottom row's own
|
/// One rect per plot, flattened row-major (row 0's cells left to right, then row 1's,
|
||||||
/// `PlotLayout::x_label_area`, at the bottom of the bottom row's rect and so as
|
/// ...): the **viewport** each `Plot` was given. A row of one cell has exactly its row
|
||||||
/// wide as that row's axes rather than the whole figure. Read back from the row
|
/// rect here; a row of several tiles its rect left to right with `col_gap` between
|
||||||
|
/// cells, the cells' widths summing to the row's less the gaps.
|
||||||
|
std::vector<Rect> cells;
|
||||||
|
/// The strip the figure's bottom x axis is drawn in: the bottom row's own
|
||||||
|
/// `PlotLayout::x_label_area` (for a bottom row split into several cells, only the **last
|
||||||
|
/// cell's** strip -- the others' strips are their own `plot(i).layout().x_label_area`, which
|
||||||
|
/// is where the controller's axis-zoom hit test reads each cell's gestures from), at the
|
||||||
|
/// bottom of the bottom row's rect and so as wide as that row's axes rather than the whole
|
||||||
|
/// figure. Read back from the row
|
||||||
/// rather than derived here, so a host and the frame cannot disagree about where
|
/// rather than derived here, so a host and the frame cannot disagree about where
|
||||||
/// the axis is. It is nonzero whenever the figure has rows and the bottom row
|
/// the axis is. It is nonzero whenever the figure has rows and the bottom row
|
||||||
/// reserves a label strip -- including the case where every row labels itself, in
|
/// reserves a label strip. A row above whose columns differ from the row below keeps
|
||||||
/// which case it is simply the bottom row's own strip, one of several.
|
/// a strip of its own (see `Figure`), which is its plots' `x_label_area`, not this.
|
||||||
///
|
///
|
||||||
/// It is the rect the controller's axis-zoom hit test reads (`x_label_area` is what
|
/// It is the rect the controller's axis-zoom hit test reads (`x_label_area` is what
|
||||||
/// a middle drag in a label strip enters `DragMode::AxisZoomX` from), which is why
|
/// a middle drag in a label strip enters `DragMode::AxisZoomX` from), which is why
|
||||||
@@ -41,6 +52,10 @@ struct FigureLayout {
|
|||||||
/// figure edge to edge, which is the default; it is dropped for a figure too
|
/// figure edge to edge, which is the default; it is dropped for a figure too
|
||||||
/// short to hold it, so the rows never lose their height to a gap.
|
/// short to hold it, so the rows never lose their height to a gap.
|
||||||
f32 row_gap = 0.0f;
|
f32 row_gap = 0.0f;
|
||||||
|
/// The horizontal gap between two cells of a row, in device pixels. `set_col_gap()`'s
|
||||||
|
/// value, or 0 when any row holding several cells was too narrow to hold it and
|
||||||
|
/// dropped it, so the cells never lose their width to a gap.
|
||||||
|
f32 col_gap = 0.0f;
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Several plots stacked in one column, sharing one x axis.
|
/// Several plots stacked in one column, sharing one x axis.
|
||||||
@@ -56,12 +71,15 @@ struct FigureLayout {
|
|||||||
/// **The shared x axis.** Row heights are an equal division of the figure by
|
/// **The shared x axis.** Row heights are an equal division of the figure by
|
||||||
/// default, `set_row_weights()` gives a row more of it, and every row keeps a floor
|
/// default, `set_row_weights()` gives a row more of it, and every row keeps a floor
|
||||||
/// height so chrome can never erase a plot. When `set_x_labels_only_on_bottom()` is
|
/// height so chrome can never erase a plot. When `set_x_labels_only_on_bottom()` is
|
||||||
/// on -- the default -- the figure's one x axis is the **bottom row's own**: the rows
|
/// on -- the default -- the x axis is the **bottom row's own** for every run of rows
|
||||||
/// above reserve no x chrome at all, and the bottom row keeps its own label and title
|
/// with the *same columns* (the same number of cells and the same normalised cell
|
||||||
/// strips, so those labels and that title are drawn once, from the bottom row's own
|
/// weights): those rows above reserve no x chrome at all, and the bottom row of the run
|
||||||
/// ticks and transform. That places the shared axis *inside* a row, where the
|
/// keeps its own label and title strips, so those labels and that title are drawn once,
|
||||||
/// controller's axis-zoom hit test can reach it, instead of in a band outside every
|
/// from its own ticks and transform. A row whose columns differ from the row below keeps
|
||||||
/// row that no gesture could land in.
|
/// its own strip, drawn under each of its cells, because the axis below is at another
|
||||||
|
/// scale and width. The rule is structural, never a function of what a plot holds. That
|
||||||
|
/// places a shared axis *inside* a row, where the controller's axis-zoom hit test can
|
||||||
|
/// reach it, instead of in a band outside every row that no gesture could land in.
|
||||||
///
|
///
|
||||||
/// **The x link.** `plot(i).set_x_range()` and a controller gesture both reach a
|
/// **The x link.** `plot(i).set_x_range()` and a controller gesture both reach a
|
||||||
/// member's x range the same way, so a figure cannot see either of them happen.
|
/// member's x range the same way, so a figure cannot see either of them happen.
|
||||||
@@ -91,13 +109,30 @@ class Figure {
|
|||||||
/// which is the *unlinked* group: rows that share nothing, each with the range
|
/// which is the *unlinked* group: rows that share nothing, each with the range
|
||||||
/// its own plot was given.
|
/// its own plot was given.
|
||||||
explicit Figure(u32 rows);
|
explicit Figure(u32 rows);
|
||||||
|
/// A figure of `cells_per_row.size()` rows, row `r` holding `cells_per_row[r]` plots side
|
||||||
|
/// by side (a count of 0 is clamped to 1). Every row of one cell makes this the same
|
||||||
|
/// figure as `Figure(rows)`. There is deliberately no `initializer_list` constructor, so
|
||||||
|
/// `Figure{3u}` keeps meaning three rows.
|
||||||
|
explicit Figure(std::span<const u32> cells_per_row);
|
||||||
|
|
||||||
u32 row_count() const noexcept { return static_cast<u32>(plots_.size()); }
|
/// The number of figure rows -- the bands stacked top to bottom -- which equals
|
||||||
/// One row's plot. A row that does not exist yields a default-constructed `Plot`
|
/// `plot_count()` for a figure no row of which is split.
|
||||||
/// rather than reading out of bounds -- the rule every accessor on `Plot` follows
|
u32 row_count() const noexcept { return static_cast<u32>(cells_per_row_.size()); }
|
||||||
/// -- so a stale row can only ever affect itself.
|
/// The number of plots, i.e. cells over all rows. `plot()`, `link_x()`, `x_link()` and
|
||||||
Plot& plot(u32 row) noexcept;
|
/// the hovered plot of `set_crosshair()` index this flattened (row-major) range.
|
||||||
const Plot& plot(u32 row) const noexcept;
|
u32 plot_count() const noexcept { return static_cast<u32>(plots_.size()); }
|
||||||
|
/// How many cells row `row` holds; 0 for a row that does not exist.
|
||||||
|
u32 cells_in_row(u32 row) const noexcept;
|
||||||
|
/// The flattened index of row `row`'s first cell; `plot_count()` for `row ==
|
||||||
|
/// row_count()` and for anything beyond.
|
||||||
|
u32 first_plot_of_row(u32 row) const noexcept;
|
||||||
|
/// The row that holds flattened plot `plot` (the last row for an index past the end).
|
||||||
|
u32 row_of_plot(u32 plot) const noexcept;
|
||||||
|
/// One plot, by flattened cell index. A plot that does not exist yields a
|
||||||
|
/// default-constructed `Plot` rather than reading out of bounds -- the rule every
|
||||||
|
/// accessor on `Plot` follows -- so a stale index can only ever affect itself.
|
||||||
|
Plot& plot(u32 index) noexcept;
|
||||||
|
const Plot& plot(u32 index) const noexcept;
|
||||||
|
|
||||||
// --- geometry ---------------------------------------------------------
|
// --- geometry ---------------------------------------------------------
|
||||||
|
|
||||||
@@ -116,6 +151,21 @@ class Figure {
|
|||||||
void clear_row_weights() noexcept { row_weights_.clear(); }
|
void clear_row_weights() noexcept { row_weights_.clear(); }
|
||||||
bool has_row_weights() const noexcept { return !row_weights_.empty(); }
|
bool has_row_weights() const noexcept { return !row_weights_.empty(); }
|
||||||
|
|
||||||
|
/// How much of row `row`'s width each of its cells gets: `weights[i]` is cell i's
|
||||||
|
/// share of what is left after every cell has its floor width (64 logical pixels,
|
||||||
|
/// capped at an equal share of a narrow row). Normalised. Refused, changing nothing,
|
||||||
|
/// when the row does not exist, the count is not the row's cell count, or a weight is
|
||||||
|
/// not a positive finite number. A row with no weights divides equally.
|
||||||
|
Status set_cell_weights(u32 row, std::span<const f32> weights);
|
||||||
|
/// Back to an equal division of row `row`; a row that does not exist is ignored.
|
||||||
|
void clear_cell_weights(u32 row) noexcept;
|
||||||
|
bool has_cell_weights(u32 row) const noexcept;
|
||||||
|
/// The horizontal gap left between two cells of a row, in device pixels (0.0 by
|
||||||
|
/// default). Negative values are clamped to 0, and a gap too wide for a row to hold is
|
||||||
|
/// dropped for that layout rather than squeezing the cells to nothing.
|
||||||
|
void set_col_gap(f32 device_px) noexcept { col_gap_ = device_px > 0.0f ? device_px : 0.0f; }
|
||||||
|
f32 col_gap() const noexcept { return col_gap_; }
|
||||||
|
|
||||||
/// The gap left between two rows, in device pixels (0.0 by default, so the rows
|
/// The gap left between two rows, in device pixels (0.0 by default, so the rows
|
||||||
/// tile the figure). Negative values are clamped to 0, and a gap too large for the
|
/// tile the figure). Negative values are clamped to 0, and a gap too large for the
|
||||||
/// figure to hold is dropped for that layout rather than squeezing the rows to
|
/// figure to hold is dropped for that layout rather than squeezing the rows to
|
||||||
@@ -123,9 +173,34 @@ 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_; }
|
||||||
|
|
||||||
/// Whether the shared x axis is the figure's: every row reserves no x label strip
|
/// A floor under the figure's common left gutter, in device pixels. The gutter
|
||||||
/// and one shared axis is drawn under the last row (the default), or every row
|
/// `layout()` imposes is the maximum over the rows' *measured* needs, so it follows
|
||||||
/// draws its own labels and the figure draws nothing extra.
|
/// 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.
|
||||||
|
/// In a split row whose cells share one y (`set_share_row_y`) only the **last** cell
|
||||||
|
/// keeps it, taken off the row like the y gutter, so the gap between two plots of the
|
||||||
|
/// row is the column gap.
|
||||||
|
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 rows with the same columns as the row below hand it their x axis (the
|
||||||
|
/// default; a row with different columns keeps its own), or every row draws its own
|
||||||
|
/// labels and the figure draws nothing extra.
|
||||||
void set_x_labels_only_on_bottom(bool on) noexcept { labels_bottom_only_ = on; }
|
void set_x_labels_only_on_bottom(bool on) noexcept { labels_bottom_only_ = on; }
|
||||||
bool x_labels_only_on_bottom() const noexcept { return labels_bottom_only_; }
|
bool x_labels_only_on_bottom() const noexcept { return labels_bottom_only_; }
|
||||||
|
|
||||||
@@ -140,7 +215,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.
|
||||||
@@ -169,17 +245,21 @@ class Figure {
|
|||||||
/// is not a number; such a call therefore leaves every row's crosshair as it was.
|
/// is not a number; such a call therefore leaves every row's crosshair as it was.
|
||||||
void set_crosshair(f64 x, bool active);
|
void set_crosshair(f64 x, bool active);
|
||||||
|
|
||||||
/// Places the crosshair with the pointer's **row** and that row's local y (spec §17): the
|
/// Places the crosshair with the pointer's **plot** and that plot's local y (spec §17): the
|
||||||
/// shared x goes to every row -- vertical lines everywhere, as the plain overload does -- and
|
/// shared x goes to every plot -- vertical lines everywhere, as the plain overload does -- and
|
||||||
/// the horizontal line is drawn **only in `hovered_row`**, where its y is a value of *that*
|
/// the horizontal line is drawn in `hovered_row` -- the **flattened plot index**, not the
|
||||||
/// row's own scale. It is the fix for the reported defect -- a figure showing no horizontal
|
/// figure-row index; they differ when a row holds several cells -- where its y is a value of
|
||||||
|
/// *that* plot's own scale. **When the row is a split whose cells share one y
|
||||||
|
/// (`set_share_row_y`), the line is drawn in every cell of that row**: it is one value on one
|
||||||
|
/// scale, so stopping it at the divider would hide the number the neighbouring cell is read
|
||||||
|
/// against. Rows never share y with each other, so no other row gets a line. It is the fix for the reported defect -- a figure showing no horizontal
|
||||||
/// line at all -- without inventing a figure-wide y, which would mean a different value in
|
/// line at all -- without inventing a figure-wide y, which would mean a different value in
|
||||||
/// every row: the line appears where the pointer is, and no row ever shows another row's y.
|
/// every row: the line appears where the pointer is, and no row ever shows another row's y.
|
||||||
///
|
///
|
||||||
/// `y_in_row` is a data coordinate on `hovered_row`'s y axis 0, exactly as `Crosshair::y`'s
|
/// `y_in_row` is a data coordinate on `hovered_row`'s y axis 0, exactly as `Crosshair::y`'s
|
||||||
/// two-coordinate placement is; a host reads it from the row's own y transform at the
|
/// two-coordinate placement is; a host reads it from the row's own y transform at the
|
||||||
/// pointer's pixel row. `hovered_row` outside `[0, row_count())` is treated as "no row under
|
/// pointer's pixel row. `hovered_row` outside `[0, plot_count())` is treated as "no plot under
|
||||||
/// the pointer": every row gets the vertical line and none gets a horizontal one, the same
|
/// the pointer": every plot gets the vertical line and none gets a horizontal one, the same
|
||||||
/// state the single-coordinate overload produces.
|
/// state the single-coordinate overload produces.
|
||||||
///
|
///
|
||||||
/// Refused, changing nothing, when `x` is not a number -- matching `set_crosshair(x, active)`
|
/// Refused, changing nothing, when `x` is not a number -- matching `set_crosshair(x, active)`
|
||||||
@@ -200,9 +280,10 @@ class Figure {
|
|||||||
/// How many groups there are, excluding the unlinked group 0.
|
/// How many groups there are, excluding the unlinked group 0.
|
||||||
u32 x_link_count() const noexcept { return static_cast<u32>(links_.size()); }
|
u32 x_link_count() const noexcept { return static_cast<u32>(links_.size()); }
|
||||||
|
|
||||||
/// Moves one row into a link group and applies that group's range to it at once,
|
/// Moves one plot into a link group and applies that group's range to it at once; `row`
|
||||||
/// so the row is on the group's window from this call rather than from the next
|
/// is the **flattened plot index** (see `plot_count()`), which is the row index only for a
|
||||||
/// frame. `group` 0 takes the row back out of its group, leaving it with the range
|
/// figure with one cell per row. The plot is on the group's window from this call rather
|
||||||
|
/// than from the next frame. `group` 0 takes the plot back out of its group, leaving it with the range
|
||||||
/// it holds now.
|
/// it holds now.
|
||||||
///
|
///
|
||||||
/// A group with no range yet -- one that no row and no `set_x_link_range()` has
|
/// A group with no range yet -- one that no row and no `set_x_link_range()` has
|
||||||
@@ -216,7 +297,7 @@ class Figure {
|
|||||||
/// per-row window. `XFollow::Off` is the group's to set
|
/// per-row window. `XFollow::Off` is the group's to set
|
||||||
/// (`set_x_link_follow()`), not the axis'.
|
/// (`set_x_link_follow()`), not the axis'.
|
||||||
Status link_x(u32 row, u32 group);
|
Status link_x(u32 row, u32 group);
|
||||||
/// The group a row is in, or 0 when it is in none.
|
/// The group the plot with flattened index `row` is in, or 0 when it is in none.
|
||||||
u32 x_link(u32 row) const noexcept;
|
u32 x_link(u32 row) const noexcept;
|
||||||
|
|
||||||
/// Sets a group's shared range and pushes it to every member.
|
/// Sets a group's shared range and pushes it to every member.
|
||||||
@@ -243,6 +324,22 @@ class Figure {
|
|||||||
/// A group's follow state. `Off` for group 0 and for a group that does not exist.
|
/// A group's follow state. `Off` for group 0 and for a group that does not exist.
|
||||||
XFollow x_link_follow(u32 group) const noexcept;
|
XFollow x_link_follow(u32 group) const noexcept;
|
||||||
|
|
||||||
|
/// Makes the cells of every split row (two or more) share their y axis 0: one range
|
||||||
|
/// and one auto scale state per row, default off. A range set on any member -- a
|
||||||
|
/// wheel, a drag, `Plot::set_y_range()` -- becomes the row's and is pushed to all of
|
||||||
|
/// its cells at the next `layout()`; the row is auto or manual as one, and while it is
|
||||||
|
/// auto it fits the union of every cell's visible traces (a cell with none is ignored;
|
||||||
|
/// padding and log handling are the plot's own, via `Plot::fit_y_bounds()`). The group,
|
||||||
|
/// not the plot, owns auto scale: members' own autoscale is suppressed, because each
|
||||||
|
/// would fit only its own traces and the cells would fight over the range. Turning it
|
||||||
|
/// on or off forgets the groups' state; a row of one cell is never a group.
|
||||||
|
void set_share_row_y(bool on);
|
||||||
|
bool share_row_y() const noexcept { return share_row_y_; }
|
||||||
|
/// Whether figure row `row` is auto scaled as a group. True for a row that is not a
|
||||||
|
/// group -- fewer than two cells, sharing off, or a row that does not exist -- so
|
||||||
|
/// "auto" is also the answer when there is no shared state to be manual.
|
||||||
|
bool row_y_auto(u32 row) const noexcept;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
/// One link group: the range its members share, the scroll state behind that
|
/// One link group: the range its members share, the scroll state behind that
|
||||||
/// range while it follows, the rows in it (ascending) and, in step with them, the
|
/// range while it follows, the rows in it (ascending) and, in step with them, the
|
||||||
@@ -299,7 +396,35 @@ class Figure {
|
|||||||
/// a following group's window, push both to all members.
|
/// a following group's window, push both to all members.
|
||||||
void sync_x_links();
|
void sync_x_links();
|
||||||
|
|
||||||
|
/// A split row's shared y axis: the range last pushed to its cells (the value the
|
||||||
|
/// next frame compares each cell against, so a gesture is told from the group's own
|
||||||
|
/// write -- the same device as `XLink::pushed`), whether the group has been
|
||||||
|
/// initialised from its first cell, and whether it is auto scaled.
|
||||||
|
struct RowY {
|
||||||
|
Range pushed{};
|
||||||
|
bool have = false;
|
||||||
|
bool auto_scale = true;
|
||||||
|
};
|
||||||
|
/// Advances every split row's y group for this frame: adopt what a cell was changed
|
||||||
|
/// to (a range or an auto flag), fit the union of the cells' traces while the group
|
||||||
|
/// is auto, push the result to all cells. Runs before any `Plot::layout()`, which is
|
||||||
|
/// where a plot would otherwise autoscale on its own.
|
||||||
|
void sync_row_y();
|
||||||
|
|
||||||
|
/// Fills the row bookkeeping (`cells_per_row_`, `row_first_`, `cell_weights_`) from a
|
||||||
|
/// per-row cell count, clamping each to at least one. Does not touch `plots_`, which the
|
||||||
|
/// constructors size from `total_cells()`.
|
||||||
|
void build_cells(std::span<const u32> cells);
|
||||||
|
|
||||||
std::vector<Plot> plots_;
|
std::vector<Plot> plots_;
|
||||||
|
std::vector<u32> cells_per_row_;
|
||||||
|
/// Prefix sums of `cells_per_row_`: `rows + 1` entries, `row_first_[r]` the flattened
|
||||||
|
/// index of row r's first cell and the last entry the plot count.
|
||||||
|
std::vector<u32> row_first_;
|
||||||
|
std::vector<std::vector<f32>> cell_weights_;
|
||||||
|
bool share_row_y_ = false;
|
||||||
|
std::vector<RowY> row_y_;
|
||||||
|
f32 col_gap_ = 0.0f;
|
||||||
/// What `plot()` yields for a row that does not exist, so a stale row can be used
|
/// What `plot()` yields for a row that does not exist, so a stale row can be used
|
||||||
/// without reading out of bounds. Owned rather than a static, so one figure's
|
/// without reading out of bounds. Owned rather than a static, so one figure's
|
||||||
/// stray row can never write into another's.
|
/// stray row can never write into another's.
|
||||||
@@ -309,6 +434,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>
|
||||||
|
|||||||
+382
-14
@@ -1,4 +1,8 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
#include <span>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include <plot/core/rect.hpp>
|
#include <plot/core/rect.hpp>
|
||||||
@@ -8,6 +12,7 @@
|
|||||||
#include <plot/plot/axis.hpp>
|
#include <plot/plot/axis.hpp>
|
||||||
#include <plot/plot/crosshair.hpp>
|
#include <plot/plot/crosshair.hpp>
|
||||||
#include <plot/plot/cursor.hpp>
|
#include <plot/plot/cursor.hpp>
|
||||||
|
#include <plot/plot/density.hpp>
|
||||||
#include <plot/plot/grid.hpp>
|
#include <plot/plot/grid.hpp>
|
||||||
#include <plot/plot/legend.hpp>
|
#include <plot/plot/legend.hpp>
|
||||||
#include <plot/plot/measure.hpp>
|
#include <plot/plot/measure.hpp>
|
||||||
@@ -35,6 +40,13 @@ namespace plot {
|
|||||||
/// are for a host that wants one without the other.
|
/// are for a host that wants one without the other.
|
||||||
enum class XStrip { Both, LabelsOnly, TitleOnly, None };
|
enum class XStrip { Both, LabelsOnly, TitleOnly, None };
|
||||||
|
|
||||||
|
/// Which parts of the left y chrome `Plot::layout()` reserves. `Both` is a plot drawn on
|
||||||
|
/// its own. `None` reserves no label width, no title column and ignores the figure's common
|
||||||
|
/// gutter: it is the mode of every cell of a split row but the first when the figure shares
|
||||||
|
/// the row's y axis (`Figure::set_share_row_y`), where the first cell draws the one axis.
|
||||||
|
/// The cell's ticks still refresh and its grid still builds.
|
||||||
|
enum class YStrip { Both, None };
|
||||||
|
|
||||||
/// Whether a strip mode reserves the label strip. One rule, read by `Plot::layout()`
|
/// Whether a strip mode reserves the label strip. One rule, read by `Plot::layout()`
|
||||||
/// when it sizes the strip and by the renderer when it decides whether to stage the
|
/// when it sizes the strip and by the renderer when it decides whether to stage the
|
||||||
/// labels -- a row that reserves none would otherwise stage them over its own data.
|
/// labels -- a row that reserves none would otherwise stage them over its own data.
|
||||||
@@ -46,6 +58,28 @@ constexpr bool x_strip_has_title(XStrip mode) noexcept {
|
|||||||
return mode == XStrip::Both || mode == XStrip::TitleOnly;
|
return mode == XStrip::Both || mode == XStrip::TitleOnly;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One span of the primary x axis the host has declared invalid, in **data** coordinates --
|
||||||
|
/// `Region`'s convention. In udpscope's case a reserved hole: samples the stream's counter says
|
||||||
|
/// exist and that have not been delivered.
|
||||||
|
///
|
||||||
|
/// `repaired` says the host has since filled the span's samples in. Such a span is **still
|
||||||
|
/// reported**, because the shading is a record of what the window was missing, but it is drawn in
|
||||||
|
/// the fainter of the two tints -- so a reader can distinguish a gap that is still empty from one
|
||||||
|
/// that has since been repaired, and from a span that has left the list entirely.
|
||||||
|
struct InvalidSpan {
|
||||||
|
f64 x0 = 0.0;
|
||||||
|
f64 x1 = 0.0;
|
||||||
|
bool repaired = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// One invalid span's measured shading: the rect, clipped to the axes' columns, and the state that
|
||||||
|
/// chooses its tint. A struct and not a bare `Rect` for `RegionLayout`'s reason -- the renderer
|
||||||
|
/// draws exactly what the layout measured and derives nothing of its own.
|
||||||
|
struct InvalidSpanLayout {
|
||||||
|
Rect fill;
|
||||||
|
bool repaired = false;
|
||||||
|
};
|
||||||
|
|
||||||
/// Pixel geometry of a plot's chrome, all of it in the same pixel space as
|
/// Pixel geometry of a plot's chrome, all of it in the same pixel space as
|
||||||
/// `Rect` (origin top-left) and therefore in **device** pixels: everything here is
|
/// `Rect` (origin top-left) and therefore in **device** pixels: everything here is
|
||||||
/// what the renderer draws with, already converted from the logical metrics in
|
/// what the renderer draws with, already converted from the logical metrics in
|
||||||
@@ -85,6 +119,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
|
||||||
@@ -97,13 +136,29 @@ struct PlotLayout {
|
|||||||
/// controller's hit test reads the very same rects, so where a region is drawn and
|
/// controller's hit test reads the very same rects, so where a region is drawn and
|
||||||
/// 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;
|
||||||
|
/// Every invalid span's shading, in `Plot::invalid_spans()` order and clipped to the axes'
|
||||||
|
/// columns. Measured here for the regions' first reason -- the frame draws exactly these
|
||||||
|
/// numbers -- and reserving nothing, so a hole cannot move the strips the labels were
|
||||||
|
/// measured into. A span entirely outside the window contributes no rect at all, so this is
|
||||||
|
/// not index-aligned with `invalid_spans()` and nothing needs it to be: there is no hit test
|
||||||
|
/// on a band and no label beside it. Each entry carries the span's `repaired` state alongside
|
||||||
|
/// its rect, so the tint is chosen where the geometry is measured rather than looked up again
|
||||||
|
/// at draw time.
|
||||||
|
std::vector<InvalidSpanLayout> invalid_spans;
|
||||||
/// 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.
|
||||||
CrosshairLayout crosshair;
|
CrosshairLayout crosshair;
|
||||||
|
/// The box-zoom frame, when a modified drag has one open: the area the gesture is framing, in
|
||||||
|
/// viewport pixels, already widened along whichever axis the modifier will *not* zoom (see
|
||||||
|
/// `input::Controller::selection_preview()`, which is where that is decided). Here rather than
|
||||||
|
/// derived by the renderer, for the regions' reason -- the frame draws exactly these numbers -- and
|
||||||
|
/// left as the zero rect whenever no gesture is open, which is what both the fill and the four
|
||||||
|
/// border segments read as "draw nothing".
|
||||||
|
Rect selection;
|
||||||
};
|
};
|
||||||
|
|
||||||
/// What a pointer is on, as of the last pointer event: the cursor marker under it, the
|
/// What a pointer is on, as of the last pointer event: the cursor marker under it, the
|
||||||
@@ -149,8 +204,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
|
||||||
@@ -182,6 +245,12 @@ class Plot {
|
|||||||
void set_x_strip_mode(XStrip mode) noexcept { x_strip_ = mode; }
|
void set_x_strip_mode(XStrip mode) noexcept { x_strip_ = mode; }
|
||||||
XStrip x_strip_mode() const noexcept { return x_strip_; }
|
XStrip x_strip_mode() const noexcept { return x_strip_; }
|
||||||
|
|
||||||
|
/// Which parts of the left y chrome `layout()` reserves; `YStrip::Both` by default. With
|
||||||
|
/// `YStrip::None` the y label width, the y title column and the figure's common gutter are
|
||||||
|
/// all zero, and the renderer stages no y labels or title (`y_label_area.w == 0`).
|
||||||
|
void set_y_strip_mode(YStrip mode) noexcept { y_strip_ = mode; }
|
||||||
|
YStrip y_strip_mode() const noexcept { return y_strip_; }
|
||||||
|
|
||||||
/// The font layout() measures tick labels with. Borrowed, not owned, and may be
|
/// The font layout() measures tick labels with. Borrowed, not owned, and may be
|
||||||
/// null -- then the label areas fall back to PlotLayout's defaults and no string
|
/// null -- then the label areas fall back to PlotLayout's defaults and no string
|
||||||
/// is measured: a plot with no renderer lays out with whatever the host sets
|
/// is measured: a plot with no renderer lays out with whatever the host sets
|
||||||
@@ -218,6 +287,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 +402,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 +441,28 @@ 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;
|
||||||
|
/// The y-side twin of `set_x_link_member()`: publishes that this y axis is a member
|
||||||
|
/// of its row's shared-y group (`Figure::set_share_row_y()`). A linked y axis is
|
||||||
|
/// never fitted by `layout()` -- the group owns its range and auto scale, and fits
|
||||||
|
/// the union of every member's traces -- but a gesture or `set_y_range()` still
|
||||||
|
/// writes it, which is how the group learns of a change. A no-op for an axis that
|
||||||
|
/// does not exist. Not for callers outside the figure.
|
||||||
|
void set_y_link_member(u32 axis, bool linked) noexcept;
|
||||||
|
/// Union of the bounds of the visible traces bound to y axis `axis`: what a shared-y
|
||||||
|
/// group collects from each member. False -- and `out` untouched -- when none.
|
||||||
|
bool y_data_bounds(u32 axis, Range& out) const;
|
||||||
|
/// Fits y axis `axis` to `bounds` the way `auto_fit_y()` fits it to its own traces
|
||||||
|
/// (same padding, same log handling), leaving `auto_scale` alone. False -- range
|
||||||
|
/// untouched -- when the axis does not exist or cannot be fitted to `bounds`.
|
||||||
|
bool fit_y_bounds(u32 axis, Range bounds);
|
||||||
|
|
||||||
/// 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 +551,152 @@ 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 caption (see `Cursor::label`): a vertical marker's badge text -- a horizontal
|
||||||
|
/// ruler writes its own value and needs no caption to be readable. Rendered on a
|
||||||
|
/// backplate, and labels whose backplates meet are merged into one panel with one line per
|
||||||
|
/// label (see the renderer), which is what keeps a marker pair half a division apart
|
||||||
|
/// readable instead of a smear of two texts over one column. An empty string removes the
|
||||||
|
/// caption; an id that names no cursor is ignored, like every other setter here.
|
||||||
|
void set_cursor_label(u32 id, std::string label);
|
||||||
|
|
||||||
|
/// 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_; }
|
||||||
|
|
||||||
|
/// Spans of the primary x axis the host has declared invalid -- in udpscope's case a reserved
|
||||||
|
/// hole: samples the stream's counter says exist and that have not been delivered. Each span's
|
||||||
|
/// `x0`/`x1` are in **data** coordinates, `Region`'s convention, and is shaded behind
|
||||||
|
/// the traces and over the grid.
|
||||||
|
///
|
||||||
|
/// Deliberately *not* a `Region`: a region is interactive -- draggable bounds, hot zones, a
|
||||||
|
/// label, a cached statistic, a controller hit test -- and a plot can carry dozens of holes at
|
||||||
|
/// once, which would put a hundred-odd grab targets in the way of every pointer event for
|
||||||
|
/// marks nobody can usefully drag. This is passive shading and nothing else.
|
||||||
|
///
|
||||||
|
/// The span is copied, so the caller's storage need not outlive the call. Spans need not be
|
||||||
|
/// sorted and may overlap; an empty call clears the shading.
|
||||||
|
///
|
||||||
|
/// A span whose `repaired` flag is set is shaded more faintly (`Style::invalid_repaired_fill`
|
||||||
|
/// against `Style::invalid_fill`). Clearing the shading is the host's decision and nothing
|
||||||
|
/// here retires a span on its own: a repaired span is drawn until the host stops sending it.
|
||||||
|
void set_invalid_spans(std::span<const InvalidSpan> spans);
|
||||||
|
std::span<const InvalidSpan> invalid_spans() const noexcept { return invalid_spans_; }
|
||||||
|
|
||||||
|
// --- phosphor density --------------------------------------------------
|
||||||
|
|
||||||
|
/// Turns this plot's **phosphor density** on or off, and states how it fades. Off -- the
|
||||||
|
/// default -- is the plot exactly as it was: no grid is allocated, no geometry is deposited
|
||||||
|
/// and no frame uploads anything.
|
||||||
|
///
|
||||||
|
/// On, the plot keeps an intensity grid behind each of its traces, in that trace's own colour
|
||||||
|
/// (see `DensityGrid` for the resolution, the bound on it and the per-cell meaning, and
|
||||||
|
/// `DensityConfig` for the per-trace colour and the memory the grids cost), and deposits the
|
||||||
|
/// acquisition on screen into it with `accumulate_density()`. While a density is on the
|
||||||
|
/// renderer draws **no solid trace** over it: the trace ink *is* the image, so the newest
|
||||||
|
/// acquisition is deposited like every other and fades with the rest -- a trace left drawn on
|
||||||
|
/// top of its own density could never fade, which is what a report of "the last acquisition
|
||||||
|
/// never fades" was. It is deliberately *not* a set of retained traces -- the library has the
|
||||||
|
/// trace geometry in hand, so a density costs one grid per trace whatever the trigger has done,
|
||||||
|
/// where retaining the acquisitions would cost a window copy each.
|
||||||
|
///
|
||||||
|
/// Like `set_invalid_spans()` it reserves **no layout space** and nothing on it can be picked:
|
||||||
|
/// it is drawn inside the axes it was sized to, and a pointer neither finds it nor is found by
|
||||||
|
/// it. A cell is a device pixel of that axes rect while the rect is under `kDensityCellCap` and
|
||||||
|
/// a block of them past it, which the renderer scales back up with a linear filter -- the image
|
||||||
|
/// reserves no space either way, so the bound changes its resolution and nothing about the
|
||||||
|
/// layout.
|
||||||
|
///
|
||||||
|
/// **Disabling drops what was accumulated at once**, not at the next acquisition: a caller who
|
||||||
|
/// has stopped paying for the display is not left holding a stale image of it, which is the
|
||||||
|
/// rule the app's own `set_persist_depth()` follows for the very same reason. Re-enabling
|
||||||
|
/// starts from an empty grid -- the image is sized to the screen, and a screen it has not been
|
||||||
|
/// sized against yet is a screen it has nothing to say about.
|
||||||
|
void set_density(const DensityConfig& config);
|
||||||
|
/// The configuration, as set.
|
||||||
|
const DensityConfig& density() const noexcept { return density_; }
|
||||||
|
/// The accumulated image **of a trace**, for a host that wants to draw it or read it back. There
|
||||||
|
/// is one per trace, in trace order, because each is drawn in that trace's own colour (see
|
||||||
|
/// `DensityConfig`); an out-of-range trace reads an empty (0x0) image. Empty until the first
|
||||||
|
/// `accumulate_density()` on a laid-out plot, and dropped by `set_density()` off.
|
||||||
|
const DensityGrid& density_grid(u32 trace) const noexcept {
|
||||||
|
return trace < density_grids_.size() ? density_grids_[trace] : empty_density_grid();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deposits **the acquisition on screen** into each trace's grid: every visible trace of this
|
||||||
|
/// plot, the newest acquisition included, over the x window its own axis is showing, 1.0 into
|
||||||
|
/// every cell it covers -- **once per cell per sweep**, however many of that trace's own samples
|
||||||
|
/// crossed it, so a cell's value is how many traces have covered it since the last decay and not
|
||||||
|
/// how much the beam moved (see `DensityGrid`; the dwell question is
|
||||||
|
/// `Style::intensity_grading`'s). Called once per *acquisition*, which is the cadence the grids
|
||||||
|
/// count in: a caller that deposited every frame would add one sweep sixty times a second, and
|
||||||
|
/// the per-acquisition fade would have nothing to measure itself against. The renderer draws no
|
||||||
|
/// polyline for a trace whose density is on (see `set_density()`), so this deposit is the whole
|
||||||
|
/// of what the newest acquisition looks like -- and it fades with the rest.
|
||||||
|
///
|
||||||
|
/// The geometry is the frame's own. The visible x range is mapped onto sample indices through
|
||||||
|
/// the series' own x (`Series::neighbours()`), never the `dx()` grid, and never the whole
|
||||||
|
/// series -- exactly as `Renderer::draw_traces()` maps it -- and the gathered points are taken
|
||||||
|
/// to pixels through the transforms the plot's last `layout()` pinned. So this belongs *after*
|
||||||
|
/// a layout: a plot that has never been laid out has no axes rect to size the grid to and
|
||||||
|
/// deposits nothing.
|
||||||
|
///
|
||||||
|
/// The grid is sized to that axes rect, **the rect in device pixels up to `kDensityCellCap`
|
||||||
|
/// cells** (`DensityGrid`), and a grid of the wrong size is reallocated -- which drops what it
|
||||||
|
/// held, because the image is of the axes rect and a new rect is a new image. Under
|
||||||
|
/// `DensityDecay::PerAcquisition` the fade happens here, before the deposit, so the acquisition
|
||||||
|
/// just drawn is the brightest. A NaN sample is a gap: it deposits nothing and joins nothing,
|
||||||
|
/// the library-wide policy.
|
||||||
|
void accumulate_density();
|
||||||
|
|
||||||
|
/// Fades the grid by `dt_s` of **stream seconds** -- the clock the x axis reads, so a caller
|
||||||
|
/// advancing the display by its frame's own `dt` gets a fade that does not depend on the frame
|
||||||
|
/// rate. Only `DensityDecay::TimeConstant` fades here: `PerAcquisition`'s factor is applied by
|
||||||
|
/// `accumulate_density()`, because there is no acquisition in a frame that merely passed.
|
||||||
|
/// A non-positive `dt_s` and a non-positive `time_constant_s` are both no-ops, so a caller that
|
||||||
|
/// has not measured a frame yet cannot wipe the grid by accident.
|
||||||
|
void decay_density(f64 dt_s);
|
||||||
|
|
||||||
|
/// Empties the grid (and leaves the configuration alone). What a caller does when the data
|
||||||
|
/// under the image is replaced: the screen-relative image of another stream's sweeps says
|
||||||
|
/// nothing about this one.
|
||||||
|
void clear_density() noexcept;
|
||||||
|
|
||||||
/// 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 +800,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 +818,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_; }
|
||||||
|
|
||||||
@@ -610,12 +875,35 @@ class Plot {
|
|||||||
/// calls this when the pointer leaves the axes.
|
/// calls this when the pointer leaves the axes.
|
||||||
void clear_crosshair() noexcept { crosshair_.active = false; }
|
void clear_crosshair() noexcept { crosshair_.active = false; }
|
||||||
|
|
||||||
|
/// The box-zoom frame the host is showing, in **viewport pixels**, or the zero rect for none.
|
||||||
|
///
|
||||||
|
/// Set by the host from `input::Controller::selection_preview()` while a modified left drag is
|
||||||
|
/// choosing a range, and taken off when it ends. It is the *host's* job and not the plot's because
|
||||||
|
/// the plot has no controller and learns nothing about pointer, button or modifier (see
|
||||||
|
/// `plot::input`): this is the receiving end of that boundary, exactly as `set_hot_handles()` is.
|
||||||
|
/// Drawn as a frame -- a slightly opaque fill under a light border -- over everything else inside
|
||||||
|
/// the axes, so the area being framed stays legible against the traces it covers.
|
||||||
|
void set_selection_rect(Rect rect) noexcept { selection_ = rect; }
|
||||||
|
/// Takes the frame off, leaving the same state as before any drag: the next one starts from nothing.
|
||||||
|
void clear_selection_rect() noexcept { selection_ = Rect{}; }
|
||||||
|
/// The frame as set -- what `layout()` measures into `PlotLayout::selection`, and what a host or a
|
||||||
|
/// test reads back without paying a layout.
|
||||||
|
Rect selection_rect() const noexcept { return selection_; }
|
||||||
|
|
||||||
/// Switches the readout panel off -- or back on -- without moving the crosshair: the lines and
|
/// Switches the readout panel off -- or back on -- without moving the crosshair: the lines and
|
||||||
/// the per-trace markers stay, the panel is not measured and no row is listed. A host that wants
|
/// the per-trace markers stay, the panel is not measured and no row is listed. A host that wants
|
||||||
/// the pointer's column marked but not a panel over its data sets this once; it is deliberately
|
/// the pointer's column marked but not a panel over its data sets this once; it is deliberately
|
||||||
/// **not** part of `set_crosshair()`, so placing a hover cannot silently change it.
|
/// **not** part of `set_crosshair()`, so placing a hover cannot silently change it.
|
||||||
void set_crosshair_readout(bool on) noexcept { crosshair_.show_readout = on; }
|
void set_crosshair_readout(bool on) noexcept { crosshair_.show_readout = on; }
|
||||||
|
|
||||||
|
/// Writes or clears the two **axis badges** (see `Crosshair::axis_labels`): the y value in
|
||||||
|
/// the y label strip and the x value in the x label strip, at the heights and columns the
|
||||||
|
/// crosshair's lines meet the axes at. Takes effect on the next `layout()`. **Not** part of
|
||||||
|
/// `set_crosshair()`, for the same reason the readout flag is not.
|
||||||
|
void set_crosshair_axis_labels(bool on) noexcept { crosshair_.axis_labels = on; }
|
||||||
|
/// The axis-badge flag, as set.
|
||||||
|
bool crosshair_axis_labels() const noexcept { return crosshair_.axis_labels; }
|
||||||
|
|
||||||
/// Overrides this plot's crosshair stroke and dash, in logical pixels, without touching the
|
/// Overrides this plot's crosshair stroke and dash, in logical pixels, without touching the
|
||||||
/// global style every other plot draws with (spec §17): each field left negative in `s`
|
/// global style every other plot draws with (spec §17): each field left negative in `s`
|
||||||
/// defers to the matching `Style::crosshair_*` metric, so `CrosshairStyle{}` (the default)
|
/// defers to the matching `Style::crosshair_*` metric, so `CrosshairStyle{}` (the default)
|
||||||
@@ -627,20 +915,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 +947,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
|
||||||
@@ -738,6 +1048,30 @@ class Plot {
|
|||||||
/// region's pixel geometry is derived -- the controller hit-tests what this
|
/// region's pixel geometry is derived -- the controller hit-tests what this
|
||||||
/// measured instead of mapping the bounds through the transform again.
|
/// measured instead of mapping the bounds through the transform again.
|
||||||
void build_regions();
|
void build_regions();
|
||||||
|
/// Measures every invalid span into `layout_.invalid_spans`: its interval clipped to the axes'
|
||||||
|
/// columns, with a one-device-pixel floor, and the state that picks its tint. Here and not in
|
||||||
|
/// the renderer for `build_regions()`' reason -- one x mapping, the frame's own.
|
||||||
|
void build_invalid_spans();
|
||||||
|
/// Deposits one trace's visible window into its own density grid: the same index window
|
||||||
|
/// `Renderer::draw_traces()` maps -- `neighbours()` for the ends, the 64-spp gate for the LOD
|
||||||
|
/// level -- gathered into `density_points_`, mapped to pixels through the transforms of the
|
||||||
|
/// plot's last layout, and left with `deposit_density_segment()`. A trace with no series, no
|
||||||
|
/// visible window or non-positive `dx()` deposits nothing.
|
||||||
|
void deposit_density_trace(const Trace& trace, DensityGrid& grid);
|
||||||
|
/// Deposits one already-mapped segment of a trace polyline into `grid`: for every grid column
|
||||||
|
/// the segment crosses, the rows it covers in that column, +1 each. The columns it also *clips*:
|
||||||
|
/// a column whose slice of the segment lies entirely outside the axes is skipped, so a trace off
|
||||||
|
/// the top of the plot deposits nothing rather than a band along the edge, and a segment with
|
||||||
|
/// both ends past an edge keeps the part that is inside. A degenerate segment (`x0 == x1`, or a
|
||||||
|
/// non-finite coordinate) fills the single column it is in.
|
||||||
|
void deposit_density_segment(f32 x0, f32 y0, f32 x1, f32 y1, DensityGrid& grid) noexcept;
|
||||||
|
/// Grows `density_grids_` -- one grid per trace -- to the last layout's axes rect in device
|
||||||
|
/// pixels, clearing a grid when the size moved (a *different* screen is a different image), and
|
||||||
|
/// returns whether any grid is usable. The one place the grids are allocated.
|
||||||
|
bool size_density_grids();
|
||||||
|
/// The image an out-of-range trace reads (see `density_grid()`): a shared empty grid, so the
|
||||||
|
/// accessor need not hand back a reference into nothing.
|
||||||
|
static const DensityGrid& empty_density_grid() noexcept;
|
||||||
/// Exact statistics of `region` over `series`, or an invalid result when the
|
/// Exact statistics of `region` over `series`, or an invalid result when the
|
||||||
/// interval covers no sample. The one place the region arithmetic lives.
|
/// interval covers no sample. The one place the region arithmetic lives.
|
||||||
static RegionStats evaluate_region_stats(const Region& region, const data::Series& series);
|
static RegionStats evaluate_region_stats(const Region& region, const data::Series& series);
|
||||||
@@ -755,7 +1089,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 +1099,18 @@ 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;
|
||||||
|
/// Which parts of the left y chrome are reserved at all; see set_y_strip_mode().
|
||||||
|
YStrip y_strip_ = YStrip::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_{};
|
||||||
|
|
||||||
@@ -780,6 +1121,28 @@ class Plot {
|
|||||||
std::vector<MeasureSlot> measures_;
|
std::vector<MeasureSlot> measures_;
|
||||||
std::vector<Annotation> annotations_;
|
std::vector<Annotation> annotations_;
|
||||||
std::vector<Region> regions_;
|
std::vector<Region> regions_;
|
||||||
|
std::vector<InvalidSpan> invalid_spans_;
|
||||||
|
/// The phosphor density: its configuration, the grids it accumulates into, and the scratch the
|
||||||
|
/// one visible window being deposited is gathered into. **One grid per trace** -- the image is of
|
||||||
|
/// the axes rect but each trace's is tinted with that trace's own colour (see `DensityConfig`), so
|
||||||
|
/// the memory is `traces * columns * rows * 4` bytes and not one plot's worth -- and each is
|
||||||
|
/// bounded by `kDensityCellCap`. The vector tracks `traces_` and outlives the frames its images
|
||||||
|
/// were drawn in, which is the whole point of accumulating into an image rather than retaining
|
||||||
|
/// traces.
|
||||||
|
DensityConfig density_{};
|
||||||
|
std::vector<DensityGrid> density_grids_;
|
||||||
|
/// Scratch for `deposit_density_trace()`: interleaved (x, y) of one gathered window, re-used
|
||||||
|
/// across traces and frames so a steady-state accumulation allocates nothing. Bounded by the
|
||||||
|
/// window, not the series, as `Renderer::gather_` is.
|
||||||
|
std::vector<f32> density_points_;
|
||||||
|
/// The per-sweep dedupe's stamps, one per grid **row** and shared by every trace's deposit: the
|
||||||
|
/// key of the last (trace, column) to deposit at that row, so one acquisition's own samples
|
||||||
|
/// cannot count a cell twice (see `deposit_density_segment()`). One `u64` a row -- a few kB at
|
||||||
|
/// any window -- and not one per cell, which would double a grid's memory for a comparison.
|
||||||
|
std::vector<u64> density_stamp_;
|
||||||
|
/// The sweep's own number, bumped once per trace deposited: the high half of every stamp key, so
|
||||||
|
/// every trace of a plot, and every acquisition, deposits exactly once per cell it covers.
|
||||||
|
u32 density_trace_ = 0;
|
||||||
/// The (region, trace) statistics cache, filled on demand by `region_stats()`
|
/// The (region, trace) statistics cache, filled on demand by `region_stats()`
|
||||||
/// and so mutable: a lookup on a const Plot may cache, exactly as `measures_`
|
/// and so mutable: a lookup on a const Plot may cache, exactly as `measures_`
|
||||||
/// does. Entries are created for the pairs a caller actually asks about, never
|
/// does. Entries are created for the pairs a caller actually asks about, never
|
||||||
@@ -794,6 +1157,11 @@ class Plot {
|
|||||||
/// The crosshair's state (see Crosshair): at most one per plot, placed by
|
/// The crosshair's state (see Crosshair): at most one per plot, placed by
|
||||||
/// `set_crosshair()` and read back by `crosshair()` and `layout()`.
|
/// `set_crosshair()` and read back by `crosshair()` and `layout()`.
|
||||||
Crosshair crosshair_{};
|
Crosshair crosshair_{};
|
||||||
|
|
||||||
|
/// The host's box-zoom frame (`set_selection_rect()`): viewport pixels, the zero rect when no
|
||||||
|
/// modified drag is choosing a range. State rather than a `PlotLayout` field alone because a host
|
||||||
|
/// sets it on every drag motion and reads it back; `layout()` only carries it through.
|
||||||
|
Rect selection_{};
|
||||||
/// The samples `crosshair_value()` reads a span through: `Series::gather()` appends
|
/// The samples `crosshair_value()` reads a span through: `Series::gather()` appends
|
||||||
/// `(x, y)` pairs and never clears, so the lookup clears this and refills it with the at
|
/// `(x, y)` pairs and never clears, so the lookup clears this and refills it with the at
|
||||||
/// most two samples a span covers. Sized to hold them from construction -- four floats is
|
/// most two samples a span covers. Sized to hold them from construction -- four floats is
|
||||||
|
|||||||
@@ -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,7 +77,40 @@ 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
|
||||||
|
/// runs of several colours -- there is deliberately no colour setter here.
|
||||||
|
|
||||||
|
private:
|
||||||
|
gl::Program program_;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Linked phosphor-density program and its uniform surface: one textured quad over a plot's
|
||||||
|
/// axes rect. It fails exactly the way the two above do, propagating `Unsupported` and
|
||||||
|
/// `GLFailure`.
|
||||||
|
///
|
||||||
|
/// It is the one pipeline with **no vertex data**: the quad's four corners come from
|
||||||
|
/// `gl_VertexID`, so a draw is one four-vertex strip whatever the grid's size and there is no
|
||||||
|
/// buffer to stage or upload. The density itself reaches the GPU as a texture -- one texel per
|
||||||
|
/// cell -- which is the one thing this pipeline has that the others do not.
|
||||||
|
///
|
||||||
|
/// `create()` also points the `u_density` sampler at texture unit 0, which is the unit
|
||||||
|
/// `plot::gl::Texture::bind()` defaults to, and leaves this program bound. The texture stays
|
||||||
|
/// with the caller, who owns its lifetime.
|
||||||
|
class DensityProgram {
|
||||||
|
public:
|
||||||
|
bool create(Status* st = nullptr) noexcept;
|
||||||
|
bool valid() const noexcept { return program_.id() != 0; }
|
||||||
|
void bind() const noexcept { program_.use(); }
|
||||||
|
|
||||||
|
/// `u_viewport`: render target size in pixels.
|
||||||
|
void set_viewport(Vec2 px) const noexcept;
|
||||||
|
/// `u_origin` and `u_size`: the quad in `Rect` pixels -- the axes rect's top-left corner and its
|
||||||
|
/// size, which is the area the image is drawn over (its own grid may hold fewer cells than the
|
||||||
|
/// rect has pixels: see `kDensityCellCap`). They are two uniforms rather than a rect because a
|
||||||
|
/// `vec4` uniform would need a fourth setter shape for one caller; a corner and a size are what
|
||||||
|
/// the vertex shader builds the quad from anyway.
|
||||||
|
void set_rect(Vec2 origin, Vec2 size) const noexcept;
|
||||||
|
/// `u_color`: the tint the cells' luminances are multiplied into.
|
||||||
void set_color(Color color) const noexcept;
|
void set_color(Color color) const noexcept;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|||||||
@@ -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,9 +29,37 @@ 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;
|
||||||
|
/// Traces drawn as a polyline whose *segments* were graded by slope -- the sparse
|
||||||
|
/// half of the same phosphor model (`Renderer::stage_trace_slopes`). **Disjoint from
|
||||||
|
/// `column_traces`**, and therefore from `graded_traces`: a window is either dense
|
||||||
|
/// enough to reduce onto the pixel grid or it is drawn as a polyline, never both. A
|
||||||
|
/// host asserting `graded_traces == column_traces` keeps that reading unchanged.
|
||||||
|
u32 slope_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
|
||||||
|
/// The phosphor density's own cells, when one was drawn: the **sum** of every trace's
|
||||||
|
/// `Plot::density_grid()` resolution, zero for a plot with the density off. It is what the
|
||||||
|
/// *frame* drew, so a host reading the density's cost off a frame reads it here rather than off
|
||||||
|
/// the grids, which say what is accumulated and not what was spent -- and it is the number that
|
||||||
|
/// scales with the trace count, since the image is one grid per trace (see `DensityConfig`).
|
||||||
|
u32 density_cells = 0;
|
||||||
|
/// The bytes of that image the frame **uploaded**: `columns * rows` when the grid had changed
|
||||||
|
/// since the last frame and 0 when it had not. It is the one field that separates the two
|
||||||
|
/// costs of a density -- the conversion and upload on the frame that moved, the single draw
|
||||||
|
/// call on the frames that did not -- which is exactly the pair a host comparing frame times
|
||||||
|
/// with the display on and off needs to tell apart.
|
||||||
|
u32 density_upload_bytes = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Draws a `Plot` into whatever framebuffer is bound.
|
/// Draws a `Plot` into whatever framebuffer is bound.
|
||||||
@@ -64,13 +95,47 @@ struct FrameStats {
|
|||||||
/// a guard -- and the frame reports it (see `last_frame_had_gl_error()`).
|
/// a guard -- and the frame reports it (see `last_frame_had_gl_error()`).
|
||||||
///
|
///
|
||||||
/// Not thread-safe, and confined to the thread that owns the current context.
|
/// Not thread-safe, and confined to the thread that owns the current context.
|
||||||
|
|
||||||
|
/// One cursor label's measured text and placed badge: what `Renderer::collect_cursor_label()`
|
||||||
|
/// builds and `Renderer::draw_clustered_cursor_labels()` places and draws. A rendering
|
||||||
|
/// detail of the cursor labels' backplates, declared at namespace scope only because a
|
||||||
|
/// private nested type cannot be named in an out-of-class member definition's signature.
|
||||||
|
struct CursorLabel {
|
||||||
|
std::string text; ///< the one line the backplate carries
|
||||||
|
Color border{}; ///< the panel's border: the cursor's own colour
|
||||||
|
f32 anchor = 0.0f; ///< the coordinate the label belongs to (x for a marker, y for a ruler)
|
||||||
|
f32 w = 0.0f; ///< the measured text width
|
||||||
|
bool horizontal = false;
|
||||||
|
/// The placed pen and backplate: centred on the anchor across the mark, nudged onto a
|
||||||
|
/// free row when a close neighbour's badge is in the way, and clustered into one panel
|
||||||
|
/// by `draw_clustered_cursor_labels()`.
|
||||||
|
Vec2 pen{};
|
||||||
|
Rect box{};
|
||||||
|
};
|
||||||
|
|
||||||
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: all three programs, the vertex arrays and
|
||||||
|
/// their instance buffers, the glyph atlas texture and the density's own 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
|
||||||
@@ -191,12 +256,13 @@ class Renderer {
|
|||||||
/// exactly as a stand-alone plot is, and `draw(Plot&)` itself is unchanged for
|
/// exactly as a stand-alone plot is, and `draw(Plot&)` itself is unchanged for
|
||||||
/// single-plot hosts.
|
/// single-plot hosts.
|
||||||
|
|
||||||
/// The figure's one shared x axis is the **bottom row's own**, so it is drawn by
|
/// A shared x axis is the **bottom row's own** (of a run of rows with the same
|
||||||
/// that row's normal path: when the figure says labels belong on the bottom only
|
/// columns), so it is drawn by that row's normal path: when the figure says labels
|
||||||
/// (the default), the rows above reserve no x chrome and the bottom row stages its
|
/// belong on the bottom only (the default), the rows above that match the row below
|
||||||
/// own labels and title once, inside its own strip -- the rect
|
/// reserve no x chrome and the bottom row stages its own labels and title once,
|
||||||
/// `FigureLayout::x_axis_strip` reads back. With that switched off, every row keeps
|
/// inside its own strip -- the rect `FigureLayout::x_axis_strip` reads back. A row
|
||||||
/// its own chrome and each draws its own labels; there is then no shared axis.
|
/// whose columns differ keeps and draws its own; with that switched off, every row
|
||||||
|
/// does and there is no shared axis.
|
||||||
|
|
||||||
/// Styling, the text backend and `ui_scale` are installed on **every** row before
|
/// Styling, the text backend and `ui_scale` are installed on **every** row before
|
||||||
/// the figure computes its layout, so the strips the rows measure and the strokes
|
/// the figure computes its layout, so the strips the rows measure and the strokes
|
||||||
@@ -212,7 +278,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 +347,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 +409,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 +440,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 +493,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 +523,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
|
||||||
@@ -448,6 +544,34 @@ class Renderer {
|
|||||||
void draw_plot(Plot& plot, const PlotLayout& layout);
|
void draw_plot(Plot& plot, const PlotLayout& layout);
|
||||||
|
|
||||||
void draw_grid(const Plot& plot);
|
void draw_grid(const Plot& plot);
|
||||||
|
/// The plot's **phosphor density** (`Plot::set_density()`), when one is on: one textured quad per
|
||||||
|
/// trace over the axes rect, each in **that trace's own colour** -- a saturated cell is the trace
|
||||||
|
/// at its own brightness -- drawn over the grid and the invalid-span bands. The traces themselves
|
||||||
|
/// are not drawn while it is on (`draw_traces()` returns), so this is the whole of the data ink
|
||||||
|
/// and the newest acquisition is inside it.
|
||||||
|
///
|
||||||
|
/// Each trace's image reaches the GPU as an `GL_R8` texture, one texel per cell, converted here
|
||||||
|
/// from the grid's counts -- the scale and the gamma are the plot's and this stage derives
|
||||||
|
/// neither, so the numbers a host reads back and the picture it sees are one derivation. The
|
||||||
|
/// texture is magnified over the axes rect with a linear filter, because past `kDensityCellCap` a
|
||||||
|
/// cell is several device pixels across. A frame whose grids did not change skips the conversion
|
||||||
|
/// *and* the uploads and costs the draw calls, which `FrameStats::density_upload_bytes` says per
|
||||||
|
/// frame.
|
||||||
|
void draw_density(const Plot& plot, const PlotLayout& layout);
|
||||||
|
/// One grid's quad: fills the shared `GL_R8` texture from `grid` (skipping when its revision and
|
||||||
|
/// address are the ones already uploaded) and draws it over the axes rect in `tint` -- the rect,
|
||||||
|
/// and not the grid's own cell count, so a capped grid is scaled back over the area it is an
|
||||||
|
/// image of. The one place the density conversion lives, shared by every trace of
|
||||||
|
/// `draw_density()`.
|
||||||
|
void draw_density_grid(const DensityConfig& config, const DensityGrid& grid, Color tint,
|
||||||
|
const PlotLayout& layout);
|
||||||
|
/// `Plot::invalid_spans()`, as `PlotLayout::invalid_spans` measured them: one fill per span,
|
||||||
|
/// in `style_.invalid_fill` or — for a span the host has marked repaired —
|
||||||
|
/// `style_.invalid_repaired_fill`. Over the grid and under everything else inside the axes, so
|
||||||
|
/// a hole reads as a property of the plot's background -- the grid shows through it and a trace
|
||||||
|
/// that does have data in those columns is drawn on top, which is what distinguishes a reserved
|
||||||
|
/// hole from a trace that has gone quiet.
|
||||||
|
void draw_invalid_spans(const PlotLayout& layout);
|
||||||
void draw_axis_lines(const Plot& plot);
|
void draw_axis_lines(const Plot& plot);
|
||||||
/// The chrome helpers take the frame's layout: `Plot::layout()` recomputes the
|
/// The chrome helpers take the frame's layout: `Plot::layout()` recomputes the
|
||||||
/// ticks, the strips and the grid, so it is computed once, in `draw()`, and handed
|
/// ticks, the strips and the grid, so it is computed once, in `draw()`, and handed
|
||||||
@@ -457,6 +581,53 @@ 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 **sparse** window's polyline, graded by the same measure: each segment is
|
||||||
|
/// staged into the step its own *slope* earns, so a near-vertical segment between two
|
||||||
|
/// adjacent samples -- a discontinuity, which the signal crossed without dwelling
|
||||||
|
/// anywhere in it -- is faint and a near-flat one is bright.
|
||||||
|
///
|
||||||
|
/// It exists because grading used to be gated on the column reduction, and that
|
||||||
|
/// reduction is gated on a *density* (two gathered points per pixel column) whose only
|
||||||
|
/// purpose is removing the moire beat. Dwell has nothing to do with that density, so
|
||||||
|
/// zooming in past it switched the whole phosphor model off and drew an edge as
|
||||||
|
/// brightly as a flat top -- the edge got brighter the further in you zoomed.
|
||||||
|
///
|
||||||
|
/// The measure agrees with `stage_trace_density()`'s at the boundary but is **absolute
|
||||||
|
/// rather than per column**: below two points a column there is no column peak to
|
||||||
|
/// normalise against, so a segment's dwell is `1 / (1 + rows it spans)` outright. A
|
||||||
|
/// flat segment earns the top step and one crossing the whole axis earns
|
||||||
|
/// `Style::intensity_floor`, which is what a riser's column already gets on the dense
|
||||||
|
/// side.
|
||||||
|
///
|
||||||
|
/// Geometry is staged exactly as `stage_xy()` stages it -- in the pre-affine space,
|
||||||
|
/// relative to `x_origin` -- so only *which batch* a segment lands in differs from the
|
||||||
|
/// ungraded polyline, and the caller flushes with the trace's own `Mapping` and
|
||||||
|
/// `snap = false`. Returns the segments staged across all steps (0 stages nothing).
|
||||||
|
u32 stage_trace_slopes(const Plot& plot, const Trace& trace, const f32* xy, u32 points,
|
||||||
|
f64 x_origin, bool log_x, bool log_y);
|
||||||
/// 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
|
||||||
@@ -468,13 +639,22 @@ class Renderer {
|
|||||||
/// the clip, and a range maximum that undershoots the edge by a ulp is not mistaken for
|
/// the clip, and a range maximum that undershoots the edge by a ulp is not mistaken for
|
||||||
/// off the plot.
|
/// off the plot.
|
||||||
void draw_cursors(const Plot& plot, const PlotLayout& layout);
|
void draw_cursors(const Plot& plot, const PlotLayout& layout);
|
||||||
/// One cursor's label: the caller's caption for either orientation, and for a horizontal
|
/// One cursor's label, measured rather than drawn: a horizontal ruler's line is the value
|
||||||
/// ruler the value itself, written in its y axis' own tick format (`Axis::format_value()`)
|
/// itself, written in its y axis' own tick format (`Axis::format_value()`) -- the number a
|
||||||
/// -- the number a ruler exists to be read off. A vertical cursor's caption starts at the
|
/// ruler exists to be read off -- with the caption first on the line when there is one; a
|
||||||
/// top of the axes beside its marker; a ruler's text starts at the axes' left edge on its
|
/// vertical cursor's line is its caption, beside the marker at the top of the axes. The
|
||||||
/// own row, with the caption first on that line when there is one.
|
/// label is then placed (centred on its mark, stacked clear of close neighbours) and drawn
|
||||||
void draw_cursor_label(const Plot& plot, const PlotLayout& layout, const Cursor& cursor,
|
/// clustered (`draw_clustered_cursor_labels()`), which is what keeps a pair of readout
|
||||||
f32 at);
|
/// marks half a division apart readable: two texts over one column is a smear, one panel
|
||||||
|
/// with two lines is a readout.
|
||||||
|
void collect_cursor_label(const Plot& plot, const PlotLayout& layout, const Cursor& cursor,
|
||||||
|
f32 at, std::vector<CursorLabel>& labels);
|
||||||
|
/// Draws the placed labels' backplates -- one panel per cluster of meeting boxes, every
|
||||||
|
/// line at its own placed pen -- and stages the lines. Clustering is what keeps a pair of
|
||||||
|
/// readout marks half a division apart readable: two texts over one column is a smear, one
|
||||||
|
/// panel with two lines is a readout.
|
||||||
|
void draw_clustered_cursor_labels(const PlotLayout& layout,
|
||||||
|
const std::vector<CursorLabel>& labels);
|
||||||
/// The regions: the fill, one bound per edge, the optional label. Every rect comes
|
/// The regions: the fill, one bound per edge, the optional label. Every rect comes
|
||||||
/// from `PlotLayout::regions` -- the same geometry the controller hit-tests -- so
|
/// from `PlotLayout::regions` -- the same geometry the controller hit-tests -- so
|
||||||
/// a region is drawn where it can be grabbed, and neither side derives the other's
|
/// a region is drawn where it can be grabbed, and neither side derives the other's
|
||||||
@@ -492,13 +672,23 @@ 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
|
||||||
/// rect's fractional edges is lit half-heartedly, and a frame bright on three sides and dim on the
|
/// rect's fractional edges is lit half-heartedly, and a frame bright on three sides and dim on the
|
||||||
/// fourth reads as a mistake). One helper, two callers: the border's colour is the only thing a
|
/// fourth reads as a mistake). One helper, two callers: the border's colour is the only thing a
|
||||||
/// legend panel and a readout panel do not share.
|
/// legend panel and a readout panel do not share. `background_alpha` scales the frame's own
|
||||||
void draw_readout_panel(const Rect& panel, Color border);
|
/// background under the border: the legend and the crosshair's readout keep the shared
|
||||||
|
/// translucent backplate (what they cover stays faintly visible), while a *badge* -- a
|
||||||
|
/// cursor's value box, the crosshair's axis badges -- is read against whatever it sits on
|
||||||
|
/// (ticks, a noisy trace) and is drawn opaque.
|
||||||
|
void draw_readout_panel(const Rect& panel, Color border, float background_alpha);
|
||||||
/// One trace's colour swatch in `box`, opaque whatever the trace's own alpha is: a swatch drawn at
|
/// One trace's colour swatch in `box`, opaque whatever the trace's own alpha is: a swatch drawn at
|
||||||
/// a trace's 15% would be the least legible thing in the frame, and the swatch is the one element
|
/// a trace's 15% would be the least legible thing in the frame, and the swatch is the one element
|
||||||
/// that says which colour an entry or a readout row *is*. The legend's entries and the crosshair's
|
/// that says which colour an entry or a readout row *is*. The legend's entries and the crosshair's
|
||||||
@@ -528,6 +718,23 @@ class Renderer {
|
|||||||
/// Drawn after every other interior pass -- the legend included -- because it is what the
|
/// Drawn after every other interior pass -- the legend included -- because it is what the
|
||||||
/// 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);
|
||||||
|
/// The crosshair's **axis badges** (`CrosshairLayout::y_axis_badge`/`x_axis_badge`): the
|
||||||
|
/// values the two lines meet the axes at, each on the shared readout backplate with its
|
||||||
|
/// border in the crosshair's colour, drawn over the tick labels they sit among. Called
|
||||||
|
/// from `draw_plot()` after the label strips are drawn, which is the only order in which a
|
||||||
|
/// badge in a strip can sit on the ticks and still be read.
|
||||||
|
void draw_crosshair_axis_badges(const PlotLayout& layout);
|
||||||
|
|
||||||
|
/// The host's box-zoom frame (`PlotLayout::selection`): a fill at `selection_fill` under a border at
|
||||||
|
/// `selection_edge`, or nothing at all when nothing was swept. Drawn last of the interior passes, so
|
||||||
|
/// the area being framed is legible over the traces, the regions and the readout alike.
|
||||||
|
void draw_selection_frame(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
|
||||||
@@ -543,12 +750,59 @@ class Renderer {
|
|||||||
render::TextAtlas atlas_;
|
render::TextAtlas atlas_;
|
||||||
render::LineProgram line_program_;
|
render::LineProgram line_program_;
|
||||||
render::TextProgram text_program_;
|
render::TextProgram text_program_;
|
||||||
|
/// The phosphor density's pipeline, its one attribute-less vertex array and the texture a grid is
|
||||||
|
/// uploaded into. The texture is `GL_R8`, one texel per cell of the plot's grid, and is re-filled
|
||||||
|
/// for each trace in turn -- a trace's grid is drawn before the next trace's replaces it -- so a
|
||||||
|
/// row of several traces still costs the one texture. It is re-created when the grid's size moves
|
||||||
|
/// (a resized plot is a differently sized image, and the plot has already dropped what the old one
|
||||||
|
/// held).
|
||||||
|
render::DensityProgram density_program_;
|
||||||
|
gl::VertexArray density_vao_;
|
||||||
|
gl::Texture density_texture_;
|
||||||
|
/// The luminance image as uploaded, re-used across frames so a steady-state frame reallocates
|
||||||
|
/// nothing: one byte per cell.
|
||||||
|
std::vector<u8> density_pixels_;
|
||||||
|
u32 density_w_ = 0;
|
||||||
|
u32 density_h_ = 0;
|
||||||
|
/// The grid the texture currently holds, by address, and its revision. Compared per frame and per
|
||||||
|
/// trace, so a frame whose grids did not move skips the conversion *and* the upload -- the pause
|
||||||
|
/// in a persistence display. The address is part of the key because the texture is shared: two
|
||||||
|
/// traces of one plot have two grids with two revisions, and without the address a skip would
|
||||||
|
/// draw one trace's image under another's colour.
|
||||||
|
const DensityGrid* density_uploaded_grid_ = nullptr;
|
||||||
|
u64 density_uploaded_revision_ = 0;
|
||||||
render::LineVertexArray line_vao_;
|
render::LineVertexArray line_vao_;
|
||||||
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 +811,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,16 +44,50 @@ 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;
|
||||||
|
|
||||||
|
/// GLSL source of the phosphor-density pipeline: one textured quad over the axes rect.
|
||||||
|
///
|
||||||
|
/// The vertex shader builds the quad from `gl_VertexID` -- a four-vertex `GL_TRIANGLE_STRIP`
|
||||||
|
/// like the other two pipelines, and the only one with **no vertex attributes at all**: the
|
||||||
|
/// image is one rectangle whose corners are uniform state, so there is no geometry to stage and
|
||||||
|
/// nothing to upload (the density's own bytes go to a texture, not to a buffer).
|
||||||
|
///
|
||||||
|
/// Uniforms: `u_viewport` (vec2, pixels), `u_origin` (vec2, the quad's top-left corner in `Rect`
|
||||||
|
/// pixels), `u_size` (vec2, its size in pixels -- the axes rect the image is drawn over, which the
|
||||||
|
/// texture's cells are spread across: one texel a cell while the rect is under `kDensityCellCap`,
|
||||||
|
/// and a filtered magnification past it), `u_color` (vec4), `u_density` (sampler2D, unit 0).
|
||||||
|
///
|
||||||
|
/// The fragment shader multiplies the sampled single-channel image into `u_color`'s alpha, which
|
||||||
|
/// is the whole of the brightness mapping: a cell's count was turned into a luminance byte on the
|
||||||
|
/// CPU (`Plot`), where the count, the decay and the gamma live, so this stage has no scale of its
|
||||||
|
/// own to get out of step with the numbers a host reads back.
|
||||||
|
///
|
||||||
|
/// `Rect` is top-left origin while GL is bottom-left, so the y axis is flipped exactly once,
|
||||||
|
/// here, as in the line pipeline.
|
||||||
|
extern const char* const kDensityVS;
|
||||||
|
extern const char* const kDensityFS;
|
||||||
|
|
||||||
} // namespace plot::render
|
} // namespace plot::render
|
||||||
@@ -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,12 +38,45 @@ 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);
|
||||||
Color measure_fill = Color::hex(0xFFB020, 0.15f);
|
Color measure_fill = Color::hex(0xFFB020, 0.15f);
|
||||||
|
/// The box-zoom selection's frame, drawn while a modified left drag is choosing a range: a light
|
||||||
|
/// neutral near full alpha for the border and the same neutral at a low one for the fill. Neutral
|
||||||
|
/// and not a trace colour, on `invalid_fill`'s argument -- the frame says "this is the area", and a
|
||||||
|
/// tint a trace could have would read as data. The fill's alpha is chosen against the same floor:
|
||||||
|
/// low enough that what is under the frame stays readable, because the point is to show what is
|
||||||
|
/// being selected, not to hide it.
|
||||||
|
Color selection_fill = Color::hex(0xC8D0DC, 0.12f);
|
||||||
|
Color selection_edge = Color::hex(0xC8D0DC, 0.85f);
|
||||||
|
/// The shading over a span the host has declared invalid (`Plot::set_invalid_spans()`): the
|
||||||
|
/// axis furniture's own neutral grey at a low alpha, on the same model as `measure_fill`
|
||||||
|
/// above. Neutral and not a trace colour on purpose -- a band is the *absence* of data, and
|
||||||
|
/// one in a hue a trace could have would read as a signal rather than as a gap. The alpha is
|
||||||
|
/// low enough that the grid under it stays legible, which is what makes the band read as a
|
||||||
|
/// region of the plot rather than as a panel laid over it.
|
||||||
|
Color invalid_fill = Color::hex(0x8A94A6, 0.18f);
|
||||||
|
/// The same shading for a span whose samples have since arrived and been written in, at well
|
||||||
|
/// under half the alpha. The gap is still reported -- the window was drawn without that data --
|
||||||
|
/// but a reader can tell a hole that is still empty from one that has been filled. Fainter by
|
||||||
|
/// alpha alone and not by hue, so the two read as two depths of one mark rather than as two
|
||||||
|
/// different kinds of thing; and still above the threshold where the difference from *no* band
|
||||||
|
/// is visible, which is the floor this alpha is chosen against.
|
||||||
|
Color invalid_repaired_fill = Color::hex(0x8A94A6, 0.08f);
|
||||||
/// The crosshair's colour: its two lines and the readout panel's border (spec §15). Markers are
|
/// The crosshair's colour: its two lines and the readout panel's border (spec §15). Markers are
|
||||||
/// deliberately *not* this colour -- they carry each trace's own, because a marker says *which*
|
/// deliberately *not* this colour -- they carry each trace's own, because a marker says *which*
|
||||||
/// trace and the line says *where*.
|
/// trace and the line says *where*.
|
||||||
@@ -50,8 +86,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 +108,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 +144,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 +199,90 @@ 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.
|
||||||
|
///
|
||||||
|
/// Below `dense_threshold_spp`, where there are no columns to reduce, this grades
|
||||||
|
/// the **polyline** by each segment's own slope instead (`FrameStats::slope_traces`
|
||||||
|
/// against `graded_traces`) -- the same measure for a sparse window, where a
|
||||||
|
/// segment *is* the sub-column. One switch covers both because the alternative was
|
||||||
|
/// a defect: gated on the reduction, the model switched off as a window narrowed
|
||||||
|
/// past the threshold, and an edge drawn at a third of a flat top's ink jumped to
|
||||||
|
/// all of it in one pixel of zoom.
|
||||||
|
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 {
|
||||||
|
|||||||
+79
-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,14 +167,55 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MinMaxPyramid::amend(u64 absolute_sample, f32 v) {
|
||||||
|
if (!std::isfinite(v)) return;
|
||||||
|
if (absolute_sample < base_ || absolute_sample >= count_) return;
|
||||||
|
|
||||||
|
const f64 vd = f64(v);
|
||||||
|
const auto fold = [&](Entry& e) {
|
||||||
|
// An empty bucket's `mm` is meaningless (see the Entry note), so the first finite
|
||||||
|
// sample *sets* the extremes instead of widening from a zero that was never a sample.
|
||||||
|
if (e.count == 0) {
|
||||||
|
e.mm.min = v;
|
||||||
|
e.mm.max = v;
|
||||||
|
} else {
|
||||||
|
e.mm.min = std::min(e.mm.min, v);
|
||||||
|
e.mm.max = std::max(e.mm.max, v);
|
||||||
|
}
|
||||||
|
e.sum += vd;
|
||||||
|
e.sumsq += vd * vd;
|
||||||
|
++e.count;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The in-progress level-0 bucket, when the index lies inside it. It is not an entry yet,
|
||||||
|
// so the loop below cannot reach it; leaving it out would lose the amendment for a hole
|
||||||
|
// whose tail is still filling, which is exactly the hole a prompt backfill arrives into.
|
||||||
|
if (count_ % kBucket != 0 && absolute_sample >= (count_ / kBucket) * kBucket) {
|
||||||
|
fold(pending_);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every level independently: min/max/sum/sumsq/count are each associative, so folding the
|
||||||
|
// value into a parent gives the same aggregate as re-merging its children would.
|
||||||
|
for (u32 l = 0; l < static_cast<u32>(levels_.size()); ++l) {
|
||||||
|
const u64 i = absolute_sample / level_span(l);
|
||||||
|
if (i < levels_[l].base) continue;
|
||||||
|
if (i - levels_[l].base >= static_cast<u64>(levels_[l].entries.size())) continue;
|
||||||
|
fold(levels_[l].entries[static_cast<usize>(i - levels_[l].base)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void MinMaxPyramid::drop_before(u64 absolute_sample) {
|
void MinMaxPyramid::drop_before(u64 absolute_sample) {
|
||||||
if (absolute_sample <= base_) return;
|
if (absolute_sample <= base_) return;
|
||||||
if (absolute_sample > count_) {
|
if (absolute_sample > count_) {
|
||||||
@@ -222,6 +281,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 +327,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>
|
||||||
|
|||||||
+325
-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
|
||||||
@@ -499,6 +751,31 @@ void Controller::pan(const PointerEvent& e) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Rect Controller::selection_preview() const noexcept {
|
||||||
|
// Nothing to preview off a box zoom: the mask bits outlive the gesture -- `end_drag()` clears the
|
||||||
|
// selection, not them -- so widening without this guard would hand out a full-band rect for a drag
|
||||||
|
// that finished minutes ago.
|
||||||
|
if (mode_ != DragMode::BoxZoom) return Rect{};
|
||||||
|
// And nothing has been framed until the drag has swept every axis the release will apply: at the
|
||||||
|
// press instant the selection is an empty rect at the pointer, and the *widened* form of that is a
|
||||||
|
// band the width of the plot, which is a claim about a range that does not exist yet. Ctrl's other
|
||||||
|
// axis is deliberately not required: a drag with no vertical movement at all is a band across the
|
||||||
|
// plot, which is exactly what Ctrl is for.
|
||||||
|
if ((box_zoom_x_ && !(selection_.w > 0.0f)) || (box_zoom_y_ && !(selection_.h > 0.0f))) return Rect{};
|
||||||
|
Rect preview = selection_;
|
||||||
|
if (box_zoom_x_ && box_zoom_y_) return preview;
|
||||||
|
const Rect& axes = plot_->layout().axes;
|
||||||
|
if (!box_zoom_x_) {
|
||||||
|
preview.x = axes.x;
|
||||||
|
preview.w = axes.w;
|
||||||
|
}
|
||||||
|
if (!box_zoom_y_) {
|
||||||
|
preview.y = axes.y;
|
||||||
|
preview.h = axes.h;
|
||||||
|
}
|
||||||
|
return preview;
|
||||||
|
}
|
||||||
|
|
||||||
void Controller::box_zoom_update(const PointerEvent& e) {
|
void Controller::box_zoom_update(const PointerEvent& e) {
|
||||||
const f32 x = std::min(origin_x_, e.x);
|
const f32 x = std::min(origin_x_, e.x);
|
||||||
const f32 y = std::min(origin_y_, e.y);
|
const f32 y = std::min(origin_y_, e.y);
|
||||||
@@ -507,12 +784,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,19 +807,26 @@ void Controller::apply_box_zoom() {
|
|||||||
const f64 r = std::clamp(static_cast<f64>(sel.right()), ax, ar);
|
const f64 r = std::clamp(static_cast<f64>(sel.right()), ax, ar);
|
||||||
const f64 t = std::clamp(static_cast<f64>(sel.y), ay, ab);
|
const f64 t = std::clamp(static_cast<f64>(sel.y), ay, ab);
|
||||||
const f64 b = std::clamp(static_cast<f64>(sel.bottom()), ay, ab);
|
const f64 b = std::clamp(static_cast<f64>(sel.bottom()), ay, ab);
|
||||||
if (!(r > l) || !(b > t)) return;
|
if (box_zoom_x_ && !(r > l)) return;
|
||||||
|
if (box_zoom_y_ && !(b > t)) return;
|
||||||
|
|
||||||
// The ranges come straight from the transforms, so the box frames exactly the
|
// The ranges come straight from the transforms, so the box frames exactly the
|
||||||
// data under it; the selection's bottom edge is the range minimum on y.
|
// data under it; the selection's bottom edge is the range minimum on y. Masked
|
||||||
|
// axes are left exactly as they were -- the release applies only what the press
|
||||||
|
// promised.
|
||||||
|
if (box_zoom_x_) {
|
||||||
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
|
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
|
||||||
const Transform tx = plot_->x_transform(i);
|
const Transform tx = plot_->x_transform(i);
|
||||||
plot_->set_x_range(i, Range{tx.to_data_x(l), tx.to_data_x(r)});
|
plot_->set_x_range(i, Range{tx.to_data_x(l), tx.to_data_x(r)});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (box_zoom_y_) {
|
||||||
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
|
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
|
||||||
const Transform ty = plot_->y_transform(i);
|
const Transform ty = plot_->y_transform(i);
|
||||||
plot_->set_y_range(i, Range{ty.to_data_y(b), ty.to_data_y(t)});
|
plot_->set_y_range(i, Range{ty.to_data_y(b), ty.to_data_y(t)});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void Controller::move_cursor(const PointerEvent& e) {
|
void Controller::move_cursor(const PointerEvent& e) {
|
||||||
if (drag_cursor_ == kInvalid) return;
|
if (drag_cursor_ == kInvalid) return;
|
||||||
@@ -605,12 +894,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 +960,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
|
||||||
|
|||||||
+433
-62
@@ -1,37 +1,194 @@
|
|||||||
|
// 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;
|
||||||
|
|
||||||
|
/// The width a cell never drops below, in logical pixels, converted exactly as the row
|
||||||
|
/// height floor is. It yields to the row -- capped at an equal share of the width there
|
||||||
|
/// is -- so a row too narrow for its cells still tiles instead of overflowing.
|
||||||
|
constexpr f32 kMinCellWidthLogical = 64.0f;
|
||||||
|
|
||||||
|
/// The number of plots a per-row cell count asks for, each row holding at least one.
|
||||||
|
u32 total_cells(std::span<const u32> cells) noexcept {
|
||||||
|
u32 total = 0;
|
||||||
|
for (const u32 c : cells) total += std::max<u32>(c, 1u);
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splits the row rect `r` into `n` cells in `out[0..n)` and returns the gap actually used.
|
||||||
|
/// The cells tile `r` exactly: the gap is dropped when `r` cannot hold it, every cell first
|
||||||
|
/// gets the floor width and then its weight's share of what is left, and the last cell
|
||||||
|
/// closes the row on its right edge, absorbing the rounding of the ones before it.
|
||||||
|
f32 place_cells(const Rect& r, u32 n, const std::vector<f32>& weights, f32 col_gap,
|
||||||
|
f32 ui_scale, Rect* out) {
|
||||||
|
if (n == 1u) {
|
||||||
|
out[0] = r;
|
||||||
|
return col_gap;
|
||||||
|
}
|
||||||
|
f32 gap = col_gap;
|
||||||
|
f32 usable = r.w - gap * static_cast<f32>(n - 1u);
|
||||||
|
if (usable <= 0.0f) {
|
||||||
|
gap = 0.0f;
|
||||||
|
usable = r.w;
|
||||||
|
}
|
||||||
|
usable = std::max(0.0f, usable);
|
||||||
|
const f32 floor_device = ui_scale > 0.0f ? kMinCellWidthLogical * ui_scale
|
||||||
|
: kMinCellWidthLogical;
|
||||||
|
const f32 floor_px = std::min(floor_device, usable / static_cast<f32>(n));
|
||||||
|
const f32 extra = usable - floor_px * static_cast<f32>(n);
|
||||||
|
f32 total = 0.0f;
|
||||||
|
const bool weighted = weights.size() == n;
|
||||||
|
if (weighted) {
|
||||||
|
for (const f32 w : weights) total += w;
|
||||||
|
}
|
||||||
|
f32 x = r.x;
|
||||||
|
f32 consumed = 0.0f;
|
||||||
|
for (u32 i = 0; i < n; ++i) {
|
||||||
|
const f32 share = weighted && total > 0.0f ? weights[i] / total : 1.0f / static_cast<f32>(n);
|
||||||
|
const f32 end = i + 1u == n ? usable : consumed + floor_px + extra * share;
|
||||||
|
out[i] = Rect{x, r.y, end - consumed, r.h};
|
||||||
|
consumed = end;
|
||||||
|
x = out[i].right() + gap;
|
||||||
|
}
|
||||||
|
return gap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The share of the row each of its `n` cells is given by `weights`, exactly as `place_cells`
|
||||||
|
/// reads them: an empty (or wrongly sized, or non-positive-total) vector means equal shares.
|
||||||
|
std::vector<f32> normalised_shares(const std::vector<f32>& weights, u32 n) {
|
||||||
|
std::vector<f32> shares(n, 1.0f / static_cast<f32>(n));
|
||||||
|
f32 total = 0.0f;
|
||||||
|
if (weights.size() == n) {
|
||||||
|
for (const f32 w : weights) total += w;
|
||||||
|
}
|
||||||
|
if (total > 0.0f) {
|
||||||
|
for (u32 i = 0; i < n; ++i) shares[i] = weights[i] / total;
|
||||||
|
}
|
||||||
|
return shares;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether two rows have the **same columns**: the same number of cells and the same
|
||||||
|
/// normalised cell weights (within 1e-4). The rows are all the figure's width, so such rows
|
||||||
|
/// get cells with identical x extents -- the same y gutter, gap, floors and right gutter
|
||||||
|
/// apply to both -- and one row's x axis serves the other. Structural, never content: it
|
||||||
|
/// reads the cell counts and the weights the host set and nothing a plot holds.
|
||||||
|
bool same_columns(const std::vector<u32>& cells_per_row,
|
||||||
|
const std::vector<std::vector<f32>>& cell_weights, u32 row_a, u32 row_b) {
|
||||||
|
const u32 n = cells_per_row[row_a];
|
||||||
|
if (n != cells_per_row[row_b]) return false;
|
||||||
|
if (n == 1u) return true;
|
||||||
|
const std::vector<f32> a = normalised_shares(cell_weights[row_a], n);
|
||||||
|
const std::vector<f32> b = normalised_shares(cell_weights[row_b], n);
|
||||||
|
for (u32 i = 0; i < n; ++i) {
|
||||||
|
if (std::fabs(a[i] - b[i]) > 1e-4f) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A coordinate in the shared axis' **axis space**: `log10` when the figure's one
|
||||||
|
/// x axis -- the bottom row's, the row the shared strip belongs to -- is
|
||||||
|
/// logarithmic, the value itself otherwise. A following group's span and offset
|
||||||
|
/// live in this space exactly as a single `Live` axis' do (see `Axis::offset`), so
|
||||||
|
/// a log group scrolls decades and holds decades; a *linear* span there would
|
||||||
|
/// rescale every row's view as the live edge moved. The bottom row rules the
|
||||||
|
/// space because it owns the shared axis; a group whose members disagree about
|
||||||
|
/// `log` still gets one data-space range pushed into every member, computed in
|
||||||
|
/// that row's space.
|
||||||
|
f64 to_link_space(f64 v, bool log) noexcept {
|
||||||
|
return log ? Transform::log10_positive(v) : v;
|
||||||
|
}
|
||||||
|
f64 from_link_space(f64 v, bool log) noexcept {
|
||||||
|
return log ? std::pow(10.0, v) : v;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
Figure::Figure(u32 rows) : plots_(rows) {}
|
Figure::Figure(u32 rows) : plots_(rows) {
|
||||||
|
const std::vector<u32> ones(rows, 1u);
|
||||||
Plot& Figure::plot(u32 row) noexcept {
|
build_cells(std::span<const u32>(ones));
|
||||||
return row < plots_.size() ? plots_[row] : fallback_;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Plot& Figure::plot(u32 row) const noexcept {
|
Figure::Figure(std::span<const u32> cells_per_row) : plots_(total_cells(cells_per_row)) {
|
||||||
return row < plots_.size() ? plots_[row] : fallback_;
|
build_cells(cells_per_row);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Figure::build_cells(std::span<const u32> cells) {
|
||||||
|
cells_per_row_.assign(cells.begin(), cells.end());
|
||||||
|
for (u32& c : cells_per_row_) c = std::max<u32>(c, 1u);
|
||||||
|
row_first_.assign(cells_per_row_.size() + 1u, 0u);
|
||||||
|
for (std::size_t r = 0; r < cells_per_row_.size(); ++r) {
|
||||||
|
row_first_[r + 1u] = row_first_[r] + cells_per_row_[r];
|
||||||
|
}
|
||||||
|
cell_weights_.assign(cells_per_row_.size(), {});
|
||||||
|
}
|
||||||
|
|
||||||
|
u32 Figure::cells_in_row(u32 row) const noexcept {
|
||||||
|
return row < cells_per_row_.size() ? cells_per_row_[row] : 0u;
|
||||||
|
}
|
||||||
|
|
||||||
|
u32 Figure::first_plot_of_row(u32 row) const noexcept {
|
||||||
|
return row < cells_per_row_.size() ? row_first_[row] : plot_count();
|
||||||
|
}
|
||||||
|
|
||||||
|
u32 Figure::row_of_plot(u32 plot) const noexcept {
|
||||||
|
u32 r = 0;
|
||||||
|
while (r + 1u < cells_per_row_.size() && row_first_[r + 1u] <= plot) ++r;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
Plot& Figure::plot(u32 index) noexcept {
|
||||||
|
return index < plots_.size() ? plots_[index] : fallback_;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Plot& Figure::plot(u32 index) const noexcept {
|
||||||
|
return index < plots_.size() ? plots_[index] : fallback_;
|
||||||
|
}
|
||||||
|
|
||||||
|
Status Figure::set_cell_weights(u32 row, std::span<const f32> weights) {
|
||||||
|
if (row >= cells_per_row_.size() || weights.size() != cells_per_row_[row]) {
|
||||||
|
return Status{ErrorCode::InvalidArgument, "cell weights must name every cell of an existing row"};
|
||||||
|
}
|
||||||
|
for (const f32 w : weights) {
|
||||||
|
if (!std::isfinite(w) || !(w > 0.0f)) {
|
||||||
|
return Status{ErrorCode::InvalidArgument,
|
||||||
|
"cell weights must be positive finite numbers: a weight is a share of "
|
||||||
|
"the row, and a cell with none of it has no rect"};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cell_weights_[row].assign(weights.begin(), weights.end());
|
||||||
|
return Status{};
|
||||||
|
}
|
||||||
|
|
||||||
|
void Figure::clear_cell_weights(u32 row) noexcept {
|
||||||
|
if (row < cell_weights_.size()) cell_weights_[row].clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Figure::has_cell_weights(u32 row) const noexcept {
|
||||||
|
return row < cell_weights_.size() && !cell_weights_[row].empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
Status Figure::set_row_weights(std::span<const f32> weights) {
|
Status Figure::set_row_weights(std::span<const f32> weights) {
|
||||||
if (weights.size() != plots_.size()) {
|
if (weights.size() != cells_per_row_.size()) {
|
||||||
return Status{ErrorCode::InvalidArgument, "row weights must name every row"};
|
return Status{ErrorCode::InvalidArgument, "row weights must name every row"};
|
||||||
}
|
}
|
||||||
f32 total = 0.0f;
|
f32 total = 0.0f;
|
||||||
@@ -69,8 +226,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 +272,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 +304,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 +316,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 +405,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,6 +466,81 @@ bool Figure::adopt_member_changes(XLink& group) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Figure::set_share_row_y(bool on) {
|
||||||
|
share_row_y_ = on;
|
||||||
|
row_y_.clear();
|
||||||
|
for (u32 row = 0; row < cells_per_row_.size(); ++row) {
|
||||||
|
if (cells_per_row_[row] < 2u) continue;
|
||||||
|
for (u32 i = row_first_[row]; i < row_first_[row] + cells_per_row_[row]; ++i) {
|
||||||
|
plots_[i].set_y_link_member(0u, on);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Figure::row_y_auto(u32 row) const noexcept {
|
||||||
|
return row < row_y_.size() && row_y_[row].have ? row_y_[row].auto_scale : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Figure::sync_row_y() {
|
||||||
|
if (!share_row_y_) return;
|
||||||
|
row_y_.resize(cells_per_row_.size());
|
||||||
|
for (u32 row = 0; row < cells_per_row_.size(); ++row) {
|
||||||
|
const u32 n = cells_per_row_[row];
|
||||||
|
if (n < 2u) continue;
|
||||||
|
RowY& g = row_y_[row];
|
||||||
|
const u32 first = row_first_[row];
|
||||||
|
// Axes added after set_share_row_y() are covered here.
|
||||||
|
for (u32 i = first; i < first + n; ++i) plots_[i].set_y_link_member(0u, true);
|
||||||
|
if (!g.have) {
|
||||||
|
g.pushed = plots_[first].y_axis(0u).range;
|
||||||
|
g.auto_scale = plots_[first].y_axis(0u).auto_scale;
|
||||||
|
}
|
||||||
|
// What happened since the last push. A member's auto flag that differs from the group's
|
||||||
|
// is a request (set_y_range clears it, auto_fit_y / set_y_auto_scale(true) sets it);
|
||||||
|
// otherwise a member whose range differs from the pushed one was moved by a gesture.
|
||||||
|
bool want_auto = g.auto_scale;
|
||||||
|
Range target = g.pushed;
|
||||||
|
bool flag_request = false;
|
||||||
|
for (u32 i = first; i < first + n && !flag_request; ++i) {
|
||||||
|
const Axis& a = plots_[i].y_axis(0u);
|
||||||
|
if (a.auto_scale != g.auto_scale) {
|
||||||
|
want_auto = a.auto_scale;
|
||||||
|
if (!want_auto) target = a.range; // auto -> manual: the gesture's range
|
||||||
|
flag_request = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!flag_request && !want_auto) {
|
||||||
|
for (u32 i = first; i < first + n; ++i) {
|
||||||
|
const Range r = plots_[i].y_axis(0u).range;
|
||||||
|
if (r.min != g.pushed.min || r.max != g.pushed.max) {
|
||||||
|
target = r;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (want_auto) {
|
||||||
|
Range bounds{};
|
||||||
|
bool any = false;
|
||||||
|
for (u32 i = first; i < first + n; ++i) {
|
||||||
|
Range b{};
|
||||||
|
if (!plots_[i].y_data_bounds(0u, b)) continue;
|
||||||
|
bounds = any ? Range{std::min(bounds.min, b.min), std::max(bounds.max, b.max)} : b;
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
// The first cell stands in for the row: all cells are assumed to share one y axis kind/padding (the host builds them identically).
|
||||||
|
if (any && plots_[first].fit_y_bounds(0u, bounds)) target = plots_[first].y_axis(0u).range;
|
||||||
|
// else: nothing to fit -- keep the range the group had
|
||||||
|
}
|
||||||
|
for (u32 i = first; i < first + n; ++i) {
|
||||||
|
plots_[i].set_y_range(0u, target); // clears the flag ...
|
||||||
|
plots_[i].set_y_auto_scale(0u, want_auto); // ... so restate it: every member mirrors the group
|
||||||
|
}
|
||||||
|
g.pushed = target;
|
||||||
|
g.auto_scale = want_auto;
|
||||||
|
g.have = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void Figure::sync_x_links() {
|
void Figure::sync_x_links() {
|
||||||
for (XLink& group : links_) {
|
for (XLink& group : links_) {
|
||||||
if (group.rows.empty()) continue;
|
if (group.rows.empty()) continue;
|
||||||
@@ -288,15 +549,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -314,6 +580,8 @@ void Figure::sync_x_links() {
|
|||||||
const FigureLayout& Figure::layout() {
|
const FigureLayout& Figure::layout() {
|
||||||
const u32 rows = row_count();
|
const u32 rows = row_count();
|
||||||
layout_.rows.assign(rows, Rect{});
|
layout_.rows.assign(rows, Rect{});
|
||||||
|
layout_.cells.clear();
|
||||||
|
layout_.col_gap = 0.0f;
|
||||||
layout_.row_gap = row_gap_;
|
layout_.row_gap = row_gap_;
|
||||||
layout_.x_axis_strip = Rect{};
|
layout_.x_axis_strip = Rect{};
|
||||||
if (rows == 0u) {
|
if (rows == 0u) {
|
||||||
@@ -322,12 +590,13 @@ const FigureLayout& Figure::layout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const f32 figure_h = std::max(0.0f, viewport_.h);
|
const f32 figure_h = std::max(0.0f, viewport_.h);
|
||||||
// The rows tile the figure itself: the shared x axis is the *bottom row's own*, so
|
// The rows tile the figure itself: an x axis is a row's *own* strip (the bottom row's, and
|
||||||
// nothing is taken off the figure for it. That is what keeps the axis reachable --
|
// the strip of any row whose columns differ from the row below), so nothing is taken off
|
||||||
// its strip is a real `PlotLayout::x_label_area` inside the bottom row's rect, which
|
// the figure for it. That is what keeps the axis reachable -- its strip is a real
|
||||||
// is the rect the controller's axis-zoom hit test reads and the strip an x title is
|
// `PlotLayout::x_label_area` inside a row's rect, which is the rect the controller's
|
||||||
// drawn in -- and it is why an interior row reserves no x chrome at all instead of
|
// axis-zoom hit test reads and the strip an x title is drawn in -- and it is why a row
|
||||||
// leaving a band no one owns.
|
// that hands its axis to the one below reserves no x chrome at all instead of leaving a
|
||||||
|
// band no one owns.
|
||||||
const f32 content_h = figure_h;
|
const f32 content_h = figure_h;
|
||||||
// The gap is dropped, rather than squeezing the rows, when the figure cannot hold
|
// The gap is dropped, rather than squeezing the rows, when the figure cannot hold
|
||||||
// it: a figure one pixel tall with four rows still gives every row a positive
|
// it: a figure one pixel tall with four rows still gives every row a positive
|
||||||
@@ -353,7 +622,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;
|
||||||
@@ -378,21 +655,59 @@ const FigureLayout& Figure::layout() {
|
|||||||
// short to hold the one asked for.
|
// short to hold the one asked for.
|
||||||
layout_.row_gap = gap;
|
layout_.row_gap = gap;
|
||||||
|
|
||||||
const u32 bottom_row = rows - 1u;
|
// Each row rect is split into its cells; a one-cell row's cell is the rect itself, so an
|
||||||
|
// unsplit figure lays out exactly as it always did.
|
||||||
|
layout_.cells.assign(plots_.size(), Rect{});
|
||||||
|
layout_.col_gap = col_gap_;
|
||||||
for (u32 row = 0; row < rows; ++row) {
|
for (u32 row = 0; row < rows; ++row) {
|
||||||
Plot& p = plots_[row];
|
const u32 n = cells_per_row_[row];
|
||||||
p.set_viewport(layout_.rows[row]);
|
const f32 used = place_cells(layout_.rows[row], n, cell_weights_[row], col_gap_,
|
||||||
// One x axis for the whole figure, and it is the bottom row's: every row keeps its
|
floor_scale, &layout_.cells[row_first_[row]]);
|
||||||
// x chrome -- labels, title and the strip the controller's axis zoom is found in --
|
if (n > 1u && used < col_gap_) layout_.col_gap = 0.0f;
|
||||||
// and every other row gives all of it to that one, so the labels and the title are
|
}
|
||||||
// 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.
|
// Which rows carry their own x strip, decided structurally and before the first
|
||||||
p.set_x_strip_mode(labels_bottom_only_ && row != bottom_row ? XStrip::None : XStrip::Both);
|
// measurement pass so that pass and the final one see the same strips. The bottom row
|
||||||
|
// always has one. A row above hides its strip only when the row directly below has the
|
||||||
|
// same columns (see `same_columns()`), because then the cells of the two rows have the
|
||||||
|
// same x extents and the axis below serves this row too; the rule is transitive, since
|
||||||
|
// that row is itself either labelled or hidden in favour of one that matches it. A row
|
||||||
|
// whose columns differ from the one below -- `[1][2]` over `[3]` -- keeps its own strip,
|
||||||
|
// drawn under each of its cells, because the axis below is at another scale and width.
|
||||||
|
// A figure told to label every row keeps the strips everywhere.
|
||||||
|
std::vector<XStrip> strips(rows, XStrip::Both);
|
||||||
|
if (labels_bottom_only_) {
|
||||||
|
for (u32 row = rows - 1u; row-- > 0u;) {
|
||||||
|
// By induction the row below is labelled or itself hidden in favour of a matching
|
||||||
|
// row, so hiding this one on a match with it is the transitive rule.
|
||||||
|
if (same_columns(cells_per_row_, cell_weights_, row, row + 1u)) strips[row] = XStrip::None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (u32 row = 0; row < rows; ++row) {
|
||||||
|
const XStrip strip = strips[row];
|
||||||
|
// A split row whose cells share one y range also shares one y axis, drawn by its first
|
||||||
|
// cell: every other cell reserves no y chrome at all (see `YStrip::None`).
|
||||||
|
const bool shared_y = share_row_y_ && cells_per_row_[row] > 1u;
|
||||||
|
// ... and its right gutter is the last cell's alone, so the gap between two plots of the
|
||||||
|
// row is the column gap and not a gutter more (see the placement block below).
|
||||||
|
const u32 last = row_first_[row + 1u] - 1u;
|
||||||
|
for (u32 i = row_first_[row]; i < row_first_[row + 1u]; ++i) {
|
||||||
|
Plot& p = plots_[i];
|
||||||
|
p.set_viewport(layout_.cells[i]);
|
||||||
|
p.set_x_strip_mode(strip);
|
||||||
|
p.set_y_strip_mode(shared_y && i != row_first_[row] ? YStrip::None : YStrip::Both);
|
||||||
|
// Before either pass below, and the same value for every plot: nothing is measured on
|
||||||
|
// the right, so unlike the left gutter this needs no measurement pass -- the plots
|
||||||
|
// end on a common column because they are all told the one width.
|
||||||
|
p.set_right_gutter(shared_y && i != last ? 0.0f : 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
|
||||||
// out below, so a following group's window is the range they lay out with.
|
// out below, so a following group's window is the range they lay out with.
|
||||||
sync_x_links();
|
sync_x_links();
|
||||||
|
// Before any `Plot::layout()`, where a plot would autoscale its own y axis.
|
||||||
|
sync_row_y();
|
||||||
// Every row is laid out against its own rect, so after this call each row's
|
// Every row is laid out against its own rect, so after this call each row's
|
||||||
// chrome is current: `plot(i).layout()` reads back what the figure computed rather
|
// chrome is current: `plot(i).layout()` reads back what the figure computed rather
|
||||||
// than what the row last held. That is the contract `Renderer::draw(Figure&)`
|
// than what the row last held. That is the contract `Renderer::draw(Figure&)`
|
||||||
@@ -415,26 +730,67 @@ 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;
|
//
|
||||||
for (u32 row = 0; row < rows; ++row) {
|
// `set_min_y_gutter()` seeds the maximum rather than being imposed on a row before the
|
||||||
plots_[row].set_min_y_gutter(0.0f);
|
// measurement, so a reserved floor cannot widen what a row reports and then be widened
|
||||||
const PlotLayout& row_layout = plots_[row].layout();
|
// by its own reservation on the next frame.
|
||||||
|
//
|
||||||
|
// Over the flattened plots: every cell of every row takes part, so the y axes of a split
|
||||||
|
// row line up with each other and with the rows above and below.
|
||||||
|
f32 gutter = min_y_gutter_;
|
||||||
|
//
|
||||||
|
// A `YStrip::None` cell draws no y axis, so it has no width to contribute and takes no part
|
||||||
|
// in the maximum: the gutter is the widest of the cells that do draw one.
|
||||||
|
for (Plot& p : plots_) {
|
||||||
|
p.set_min_y_gutter(0.0f);
|
||||||
|
const PlotLayout& row_layout = p.layout();
|
||||||
|
if (p.y_strip_mode() == YStrip::None) continue;
|
||||||
gutter = std::max(gutter, row_layout.y_axis_width + row_layout.y_title_width);
|
gutter = std::max(gutter, row_layout.y_axis_width + row_layout.y_title_width);
|
||||||
}
|
}
|
||||||
|
// Rows that share one y axis divide their *plot* widths, not their cell widths: the first
|
||||||
|
// cell's y strip would otherwise make its plot narrower than its neighbours'. The gutter is
|
||||||
|
// taken off the row first, the remainder is split by `place_cells` (so the weights, the gap
|
||||||
|
// and the floor all apply to plot widths), and the first cell then grows left by the gutter
|
||||||
|
// to hold the axis. The gutter is capped at half the row, so a row too narrow for it
|
||||||
|
// degrades to ordinary cells instead of producing a negative rect. The cells still tile the
|
||||||
|
// row: the first starts on its left edge and the last still ends on its right edge.
|
||||||
|
//
|
||||||
|
// The right gutter is taken off the same way and belongs to the **last** cell alone (the
|
||||||
|
// other cells were told none above): left to every cell, the visible gap between two
|
||||||
|
// plots would be the column gap plus a whole right gutter. Together with the y gutter it is
|
||||||
|
// capped at half the row, and never exceeds the figure's own right gutter.
|
||||||
|
for (u32 row = 0; row < rows; ++row) {
|
||||||
|
const u32 n = cells_per_row_[row];
|
||||||
|
if (!share_row_y_ || n <= 1u) continue;
|
||||||
|
const Rect& r = layout_.rows[row];
|
||||||
|
const f32 g_eff = std::max(0.0f, std::min(gutter, r.w / 2.0f));
|
||||||
|
const f32 rg_eff = std::max(0.0f, std::min(right_gutter_, r.w / 2.0f - g_eff));
|
||||||
|
Rect* cells = &layout_.cells[row_first_[row]];
|
||||||
|
const f32 used = place_cells(Rect{r.x + g_eff, r.y, r.w - g_eff - rg_eff, r.h}, n,
|
||||||
|
cell_weights_[row], col_gap_, floor_scale, cells);
|
||||||
|
cells[0].x -= g_eff;
|
||||||
|
cells[0].w += g_eff;
|
||||||
|
cells[n - 1u].w += rg_eff;
|
||||||
|
layout_.col_gap = used < col_gap_ ? 0.0f : layout_.col_gap;
|
||||||
|
for (u32 i = row_first_[row]; i < row_first_[row + 1u]; ++i) plots_[i].set_viewport(layout_.cells[i]);
|
||||||
|
// What the cell actually reserved, so a row too narrow for the whole gutter is not asked
|
||||||
|
// for more than it was given (the same value whenever the row has room).
|
||||||
|
plots_[row_first_[row + 1u] - 1u].set_right_gutter(rg_eff);
|
||||||
|
}
|
||||||
// A one-row figure imposes exactly what that row measured, so it lays out as the plot
|
// A one-row figure imposes exactly what that row measured, so it lays out as the plot
|
||||||
// would on its own; a figure whose rows all need the same strip imposes that, which is
|
// would on its own; a figure whose rows all need the same strip imposes that, which is
|
||||||
// also what each would have done alone.
|
// also what each would have done alone.
|
||||||
for (u32 row = 0; row < rows; ++row) {
|
for (Plot& p : plots_) {
|
||||||
plots_[row].set_min_y_gutter(gutter);
|
p.set_min_y_gutter(p.y_strip_mode() == YStrip::None ? 0.0f : gutter);
|
||||||
plots_[row].layout();
|
p.layout();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The figure's one x axis is the bottom row's, so the strip it is drawn in is that
|
// `x_axis_strip` is the bottom row's own label area (the last plot's) -- the rect the
|
||||||
// row's own label area -- the rect the controller hit-tests and the renderer stages
|
// controller hit-tests and the renderer stages its labels in, read back here rather than
|
||||||
// its labels in, read back here rather than derived again, so a host and the frame
|
// derived again, so a host and the frame cannot disagree about where it is. Rows above
|
||||||
// cannot disagree about where the shared axis is. It is zero-sized only when there
|
// that carry their own strip (their columns differ from the row below, or every row is
|
||||||
// are no rows at all: with `set_x_labels_only_on_bottom(false)` it is simply the last
|
// labelled) have it in their plots' own `layout().x_label_area`. It is zero-sized only
|
||||||
// row's own strip, one of several.
|
// when there are no rows at all.
|
||||||
layout_.x_axis_strip = plots_.back().layout().x_label_area;
|
layout_.x_axis_strip = plots_.back().layout().x_label_area;
|
||||||
return layout_;
|
return layout_;
|
||||||
}
|
}
|
||||||
@@ -463,6 +819,21 @@ void Figure::set_crosshair(f64 x, u32 hovered_row, f64 y_in_row, bool active) {
|
|||||||
// than clearing a hover the pointer still has.
|
// than clearing a hover the pointer still has.
|
||||||
if (!active || hovered_row >= plots_.size()) return;
|
if (!active || hovered_row >= plots_.size()) return;
|
||||||
plots_[hovered_row].set_crosshair(x, y_in_row, true);
|
plots_[hovered_row].set_crosshair(x, y_in_row, true);
|
||||||
|
// **The cells of a split row share one y**, so the horizontal line means the same value in
|
||||||
|
// every one of them: it spans the row rather than stopping at the divider between two cells
|
||||||
|
// that read the same scale. Only that row -- rows do not share y with each other, so a
|
||||||
|
// sibling row's line would be a value on a scale it does not have -- and only when the row
|
||||||
|
// really is split (`set_share_row_y`, which is what makes the y common in the first place).
|
||||||
|
if (!share_row_y_) return;
|
||||||
|
const u32 row = row_of_plot(hovered_row);
|
||||||
|
if (row >= cells_per_row_.size() || cells_per_row_[row] <= 1u) return;
|
||||||
|
const u32 first = row_first_[row];
|
||||||
|
for (u32 k = 0; k < cells_per_row_[row]; ++k) {
|
||||||
|
const u32 cell = first + k;
|
||||||
|
if (cell != hovered_row && cell < plots_.size()) {
|
||||||
|
plots_[cell].set_crosshair(x, y_in_row, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void Figure::clear_crosshair() {
|
void Figure::clear_crosshair() {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+755
-96
File diff suppressed because it is too large.
Load diff
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user