`Style::marker_below_samples` (50 by default, `0` for never) draws a `marker_px` dot on every *raw* sample when the visible window holds at most that many, in the trace's own colour. Zoomed in far enough to see individual samples, the polyline between them stops being the shape a reader is looking at -- a handful of points across a whole panel ARE the picture -- and a dot makes each one a place rather than a bend in a line. Level 0 only: above it the geometry is pyramid buckets, which are not points anyone can find. Test: a_sparse_window_marks_its_samples -- the same flat series at ten samples and at a hundred. Ten gives exactly ten dots, evenly spaced (their centres are checked against the panel's own step, so "they are the samples" is asserted rather than "something was painted"); a hundred is past the threshold and gives none. The dots *group* into runs of columns (a marker is `marker_px` wide), which is how the test reads one marker per sample -- counting columns read three.
plot
An embeddable OpenGL time-series plotting library for real-time, high-sample-rate data.
The core (plot::core, C++20, OpenGL 3.3 core) has no third-party dependency at all;
the host integrations that own a window and an event loop are separate, opt-in targets
built on top of it.
Hosts, and what a frame costs
| host | CMake target | source |
|---|---|---|
| GLFW | plot::glfw |
adapters/glfw/ |
| Dear ImGui | plot::imgui (build-tree only) |
adapters/imgui/ |
| Qt6 | plot::qt6 |
adapters/qt6/ |
| GTK4 | plot::gtk4 |
adapters/gtk4/ |
The visible x range is mapped onto sample indices through the series' own x
(Series::neighbours(), a binary search over the real stamps), never through the
(x - x0()) / dx() grid: dx() is a nominal spacing, and a series whose x is a streamed
timestamp would otherwise have its draw window land inside the visible range and lose the two
segments that enter and leave the frame — the empty left and right margins of a zoomed-in view.
Per-frame rendering cost is O(visible pixels + samples produced since the last frame),
not O(samples): a trace denser than one sample per pixel is drawn from a min/max LOD
pyramid rather than from raw samples. The plot_multiplot_demo run stages 20,901 GPU
segments to draw ~4.5 million visible samples across six traces — the frame is
bounded by the pixels it lights, not by the sample count behind them. That figure is from
a linux-release build under xvfb-run on llvmpipe (Mesa's software rasteriser); no
run touched a GPU, so read it as an upper bound and a record of the software path.
Features
- sample markers when a window is sparse enough that its samples are the shape
(
Style::marker_below_samples, 50 by default,marker_pxacross, 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, interpolated readout;
- a multi-plot
Figureof 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 manualmarker_everystride or an automatic one;width == 0draws the markers alone (scatter); - three downsample policies per trace (
Trace::mode,GatherMode):Envelope(min/max pairs — the default),Nearest(one raw sample per bucket) andMean(one averaged point per bucket); - readout text colour: crosshair value rows render in their row's trace colour, labels in the axis text colour;
- input parity across hosts: pinch-zoom and pan gestures on Qt6 and GTK4 drive the same controller paths as mouse and wheel (touch parity), with keyboard panning (arrow keys), Escape cancelling a drag or box-zoom, double-click re-fitting the axes, and modifier-masked box zoom (Shift = both axes, Ctrl = x only, Alt = y only);
- DPI-consistent sizing: metrics are logical units converted once through
Style::ui_scale; - an anti-aliased embedded monospaced font, plus an optional runtime TrueType backend
(
plot::font_ttf).
Build and install
Needs a C++20 compiler and CMake 3.24+. Presets live in CMakePresets.json
(linux-debug, linux-release, linux-asan, linux-coverage).
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:
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.
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:
#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
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:
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 — prerequisites, build/install/consume, and a
minimal GLFW program against
plot::core. - Architecture — the layers (
core/math→gl→data→render→plot→input), what each owns, and the dependency rules. - Embedding into a host — the four adapters, the
ui_scale/DPI rule per host, and the headless demo invocations. - Data and performance — the sample stores, the single-producer/single-consumer contract, the LOD pyramid, and the measured frame times.
- Interaction — cursors, regions, the measurement band, the legend, the crosshair, the gesture table, and live follow.
- Styling — every
Stylefield with its default and unit, the grid classes, and installing a text backend. - Building and testing — presets,
PLOT_BUILD_*options, the suite, the headless demo modes, and the coverage command. - Coverage — the coverage method and the measured per-file table.
Testing and coverage
cmake --preset linux-debug
cmake --build build/debug
ctest --test-dir build/debug --output-on-failure # 350 cases, 0 failed
tools/coverage.sh # configure+build+run+capture+floor check
The linux-debug suite runs 350 cases; the linux-coverage preset builds 333 of
them, because its adapters are off and 17 cases need an adapter target (11 in
test_imgui_widget.cpp, 6 in test_glfw_host.cpp). Coverage is enforced by
tools/coverage.sh against tools/coverage_floor.py: 80% per file under src/ and
90% aggregate. The measured aggregate and per-file table live in
docs/coverage.md, 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, 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 atlicences/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 bytools/fonts/gen_atlas.pyand committed as data, soplot_corerasterises nothing at build or run time. - stb_truetype v1.26 (public domain, Sean Barrett / RAD Game Tools) is vendored at
third_party/stb_truetype.hand compiled only by the optionalplot_font_ttftarget; its include directory isPRIVATE, so no consumer and no other target can reach it.PLOT_BUILD_TTF_FONTdefaults on exactly when the vendored header is present. plot_coreis third-party-free. It links only${CMAKE_DL_LIBS}, and no public header underinclude/plot/includes a third-party header. The header self-containment and umbrella checks intests/CMakeLists.txtfail 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_scalesources 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). - Untrusted font files are out of scope for the runtime
plot::font_ttfbackend: stb_truetype does no range checking, sofrom_file()must only be handed files the host trusts. render↔plotis not a strict include chain.Rendererincludesplot/plot.hppbecause drawing is being handed aPlot; theplotlayer names onlyrender's value types (Style,TextBackend), neverRenderer. The cycle and its direction are documented in docs/architecture.md.