# plot An embeddable OpenGL time-series plotting library for real-time, high-sample-rate data. The core (`plot::core`, C++20, OpenGL 3.3 core) has no third-party dependency at all; the host integrations that own a window and an event loop are separate, opt-in targets built on top of it. ## Hosts, and what a frame costs | host | CMake target | source | |---|---|---| | GLFW | `plot::glfw` | `adapters/glfw/` | | Dear ImGui | `plot::imgui` (build-tree only) | `adapters/imgui/` | | Qt6 | `plot::qt6` | `adapters/qt6/` | | GTK4 | `plot::gtk4` | `adapters/gtk4/` | The visible x range is mapped onto sample indices through the series' own x (`Series::neighbours()`, a binary search over the real stamps), never through the `(x - x0()) / dx()` grid: `dx()` is a *nominal* spacing, and a series whose x is a streamed timestamp would otherwise have its draw window land inside the visible range and lose the two segments that enter and leave the frame — the empty left and right margins of a zoomed-in view. Per-frame rendering cost is O(visible pixels + samples produced since the last frame), not O(samples): a trace denser than one sample per pixel is drawn from a min/max LOD pyramid rather than from raw samples. The `plot_multiplot_demo` run stages **20,901 GPU segments** to draw **~4.5 million visible samples** across six traces — the frame is bounded by the pixels it lights, not by the sample count behind them. That figure is from a `linux-release` build under `xvfb-run` on **llvmpipe** (Mesa's software rasteriser); no run touched a GPU, so read it as an upper bound and a record of the software path. ## Features - a trace whose series answers a *clamped* stamp for its oldest slots -- a ring whose published window is pinned while it slides -- is drawn from its longest increasing *suffix*, so an unreadable front cannot draw a segment from the newest sample back to the oldest (a line across the whole panel); - a following group's window stays on its members' data: while the members retain at least a window's worth, the scroll offset is bounded by `retained - span`, so a pan or a zoom-in near an edge cannot leave the window's own span over data the scope never had (a window *wider* than the data keeps the older placement -- right edge on the newest sample); - a trace whose gathered window is denser than the axis is reduced **onto the pixel grid** -- one snapped vertical segment per integer column, lit from that column's own extremes -- because an LOD envelope staged as a polyline puts three or four near-vertical hairlines per column at as many sub-pixel phases, and the beat between the bucket pitch and the pixel pitch is a crawling moiré; one segment per column leaves one phase, so there is nothing to beat (`FrameStats::column_traces`, and `docs/data-and-performance.md` for the four rules it keeps); - sample **markers** when a window is sparse enough that its samples are the shape (`Style::marker_below_samples`, 50 by default, `marker_px` across, in the trace's own colour); - grid with independent major/minor line classes (`Style::grid_major_visible`, `grid_minor_visible`), each with its own colour, width and (for the minors) dash — the subdivisions default to thinner, dashed and alpha-blended, so they read as texture under the labelled grid rather than as a second set of ticks; - x and y axes, with a vertical (bottom-to-top) y-axis title; - cursors in both orientations — vertical markers and horizontal rulers; - draggable regions and measurement bands with exact interval statistics; - a signal legend anchored to an axes corner; - a hover crosshair with a per-signal readout, quoting the nearest sample and marking it (`CrosshairValue::NearestSample`, the default) or interpolating between two (`CrosshairValue::Interpolated`); - a multi-plot `Figure` of stacked rows sharing one synchronised x axis; - live follow (`XFollow::Live`) with relative (multiplicative) zoom and mouse panning; - XY series for irregularly sampled data (`plot/data/xy_series.hpp`): sorted-ascending x with binary-searched crosshair, measurement and index snapping; - per-trace draw modes: dash patterns (`Trace::dash_on`/`dash_off`, logical px, 0 = solid) and point markers (`Trace::marker`: cross, circle, square) with a manual `marker_every` stride or an automatic one; `width == 0` draws the markers alone (scatter); - three downsample policies per trace (`Trace::mode`, `GatherMode`): `Envelope` (min/max pairs — the default), `Nearest` (one raw sample per bucket) and `Mean` (one averaged point per bucket); - readout text colour: crosshair value rows render in their row's trace colour, labels in the axis text colour; - input parity across hosts: pinch-zoom and pan gestures on Qt6 and GTK4 drive the same controller paths as mouse and wheel (touch parity), with keyboard panning (arrow keys), Escape cancelling a drag or box-zoom, double-click re-fitting the axes, and modifier-masked box zoom (Shift = both axes, Ctrl = x only, Alt = y only); - DPI-consistent sizing: metrics are logical units converted once through `Style::ui_scale`; - an anti-aliased embedded monospaced font, plus an optional runtime TrueType backend (`plot::font_ttf`). ## Build and install Needs a C++20 compiler and CMake **3.24+**. Presets live in `CMakePresets.json` (`linux-debug`, `linux-release`, `linux-asan`, `linux-coverage`). ```sh cmake --preset linux-release cmake --build build/release cmake --install build/release --prefix /where/you/want ``` Install is on by default (`PLOT_INSTALL=ON`) and, via `GNUInstallDirs`, installs the `plot_core` archive under `lib/`, the public headers under `include/plot/` — including the generated `plot/version.hpp` — a CMake package config under `lib/cmake/plot/`, a pkg-config file under `lib/pkgconfig/plot.pc`, and the licences under `share/licenses/plot/`. A consumer then needs only: ```cmake find_package(plot 0.1 REQUIRED) target_link_libraries(my_app PRIVATE plot::core) ``` **Versioning.** The package config carries **SameMinorVersion** compatibility: at 0.x the major component promises nothing and the minor is what moves when the surface breaks, so `find_package(plot 0.1 REQUIRED)` accepts 0.1.x and refuses anything newer. A shared build (`BUILD_SHARED_LIBS=ON`) names its ABI on the file itself — `libplot_core.so.0`, the SOVERSION being the major component. What a header set *is* is in its generated ``: `plot::version::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 // the optional GLFW adapter #include #include #include int main() { plot::glfw::Host host; plot::Plot& p = host.plot(); std::vector y(512); for (plot::usize i = 0; i < y.size(); ++i) y[i] = static_cast(std::sin(0.05 * static_cast(i))); plot::data::ArraySeries series(std::move(y), 0.0, 1.0); const plot::u32 x = p.add_x_axis({"x", {0.0, 511.0}, true}); const plot::u32 sig = p.add_y_axis({"signal", {-1.0, 1.0}, true, plot::AxisPlace::Left}); p.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, x, sig, "sine"}); plot::Status st; return host.run({800, 480, "plot"}, [](plot::Plot&, plot::f64) {}, &st) ? 0 : 1; } ``` It compiles and links against a built tree (see [docs/getting-started.md](docs/getting-started.md) for the same program written against `plot::core` directly, with no adapter). The one non-obvious flag is `-I build/debug/include`: that is where CMake generates `plot/version.hpp`, which the umbrella pulls in: ```sh g++ -std=c++20 -I include -I adapters -I build/debug/include readme_main.cpp \ build/debug/adapters/glfw/libplot_glfw.a build/debug/libplot_core.a \ $(pkg-config --libs glfw3) -ldl -lpthread -o plot_readme ``` ## Documentation The user guide lives under `docs/`; `docs/superpowers/` is the design record, not user documentation. - [Getting started](docs/getting-started.md) — prerequisites, build/install/consume, and a minimal GLFW program against `plot::core`. - [Architecture](docs/architecture.md) — the layers (`core`/`math` → `gl` → `data` → `render` → `plot` → `input`), what each owns, and the dependency rules. - [Embedding into a host](docs/embedding.md) — the four adapters, the `ui_scale`/DPI rule per host, and the headless demo invocations. - [Data and performance](docs/data-and-performance.md) — the sample stores, the single-producer/single-consumer contract, the LOD pyramid, and the measured frame times. - [Interaction](docs/interaction.md) — cursors, regions, the measurement band, the legend, the crosshair, the gesture table, and live follow. - [Styling](docs/styling.md) — every `Style` field with its default and unit, the grid classes, and installing a text backend. - [Building and testing](docs/building-and-testing.md) — presets, `PLOT_BUILD_*` options, the suite, the headless demo modes, and the coverage command. - [Coverage](docs/coverage.md) — the coverage method and the measured per-file table. ## Testing and coverage ```sh cmake --preset linux-debug cmake --build build/debug ctest --test-dir build/debug --output-on-failure # 350 cases, 0 failed tools/coverage.sh # configure+build+run+capture+floor check ``` The `linux-debug` suite runs **350 cases**; the `linux-coverage` preset builds **333** of them, because its adapters are off and 17 cases need an adapter target (11 in `test_imgui_widget.cpp`, 6 in `test_glfw_host.cpp`). Coverage is enforced by `tools/coverage.sh` against `tools/coverage_floor.py`: **80%** per file under `src/` and **90%** aggregate. The measured aggregate and per-file table live in [docs/coverage.md](docs/coverage.md), which the command regenerates — it is the single source for those numbers, so this page carries none. ## Licence and third-party provenance - **This library** is **MIT licensed**: the full text is in [`LICENSE`](LICENSE), and every first-party file (C++, Python and shell) carries its SPDX identifier in the file header — `// SPDX-License-Identifier: MIT` — so the grant travels with each source file, not just the repository root. - **Adwaita Mono** (the embedded font, `render::EmbeddedMonoFont`) is under **SIL OFL 1.1**, committed verbatim at [`licences/OFL-1.1.txt`](licences/OFL-1.1.txt). The backend class is named neutrally (`EmbeddedMonoFont`, not "Adwaita Mono") because OFL reserves the font name for modified redistributions. The baked atlas is generated at authoring time by `tools/fonts/gen_atlas.py` and committed as data, so `plot_core` rasterises nothing at build or run time. - **stb_truetype v1.26** (public domain, Sean Barrett / RAD Game Tools) is vendored at `third_party/stb_truetype.h` and compiled **only** by the optional `plot_font_ttf` target; its include directory is `PRIVATE`, so no consumer and no other target can reach it. `PLOT_BUILD_TTF_FONT` defaults on exactly when the vendored header is present. - **`plot_core` is third-party-free.** It links only `${CMAKE_DL_LIBS}`, and no public header under `include/plot/` includes a third-party header. The header self-containment and umbrella checks in `tests/CMakeLists.txt` fail the build if either rule breaks. ## Limitations - **Timings are llvmpipe under Xvfb** (`tools/coverage.sh`, the demo summaries). No measurement here used a GPU; there are no hardware numbers. - **macOS and Windows are untested.** Development and CI are Linux/EGL + X11 only. - **Fractional-scale compositing and touchpad scroll calibration are unverified** — the `ui_scale` sources are correct per host, but no run exercised a real fractional DPI or a calibrated scroll device. The new gesture paths (Qt6 and GTK4 pinch, the keyboard and wheel paths behind the parity list above) are tested at the toolkit-free seams and headless; no run has touched real touch hardware. - **Figure row rects are one frame stale during a resize.** The hosts cache `Figure::layout()`'s rows and refresh them once per frame, so an event arriving mid-resize routes against the previous frame's rects (see [docs/embedding.md](docs/embedding.md)). - **Untrusted font files are out of scope** for the runtime `plot::font_ttf` backend: stb_truetype does no range checking, so `from_file()` must only be handed files the host trusts. - **`render` ↔ `plot` is not a strict include chain.** `Renderer` includes `plot/plot.hpp` because drawing *is* being handed a `Plot`; the `plot` layer names only `render`'s value types (`Style`, `TextBackend`), never `Renderer`. The cycle and its direction are documented in [docs/architecture.md](docs/architecture.md).