Breaking: Series::gather emits x relative to x0() and takes a GatherMode; consumers reconstruct absolute x in f64 before mapping (series.hpp documents the contract). Bugs, each pinned by a fail-before regression test: - render: f32 staging quantised epoch-time x into 128 px columns; every data-space draw now stages to_space(x) - to_space(origin) with the origin folded into the offset uniform in f64 (epoch/zero-origin twins rasterise identically) - ticks: major test was ULP-fragile and blanked epoch axes; majors are an integer-index test and are never truncated by the minor cap - gather: LOD envelope emitted buckets straddling [begin,end); ceil/floor bucket edges + raw lead/trail spans - live follow: log x axes held a linear span and zoomed every frame; span and offset now live in axis space (plot + both figure link-group paths) - input: a zero-notch wheel disarmed auto_scale on every axis; dy==0 pans x, factor == 1 / inf refused in wheel and drag zoom - render: the single text flush painted legend names over the crosshair readout panel; two batches, under flushed before the panel - data: NaN dropped from min/max unless it seeded the fold; non-finite samples are now excluded from every aggregate and from cached bounds Robustness: viewport normalisation (no clamp UB), non-finite range refusal, pyramid level cap at 57 (no shift/div UB through public gather), rounded ring capacity documented + pre-sized, GL blend func and UNPACK_ALIGNMENT save/restore, Renderer::release_gl() teardown, u64 buffer epochs, annotation captions clamped, button-aware GLFW row capture, cached figure row rects (no per-event layout), ruler-pair measurement refused, span-nonfinite guards in axis_ends. Features: XY series (sorted x, binary-search neighbours/snap, LOD over sample index); StreamSeries::freeze(); ArraySeries::push(); per-trace dash and markers (scatter at width 0) with auto or literal stride; GatherMode Envelope/Nearest/Mean via bucket_sum/sumsq; Style::line_aa_px; per-glyph text colour (readout values in trace colour); wheel-dx x pan; arrow-key pan; Escape cancels a drag and restores captured ranges; modifier-masked box zoom (Shift both, Ctrl x, Alt y); double-click reset via PointerEvent::time_s; ImGui key repeat unified; pinch gestures on Qt and GTK; pixelDelta wheel path and DevicePixelRatioChange handling; GNUInstallDirs, SOVERSION, SameMinorVersion, generated plot/version.hpp, relocatable plot.pc, licence install, FILES_MATCHING, GitHub Actions CI (debug/coverage/asan). Suite 283 -> 350 cases, 0 failed; coverage 93.9% (3730/3971) over 24 files, floor 80/90 held; ASAN preset suite green; all four demos run headless (the GTK scripted-resize check needs a window manager and fails identically on the parent commit); readme_main.cpp committed and compiles with the README command; pkg-config and find_package consumption verified from a relocated install prefix.
209 lines
11 KiB
Markdown
209 lines
11 KiB
Markdown
# 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/` |
|
|
|
|
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
|
|
|
|
- grid with independent major/minor line classes (`Style::grid_major_visible`,
|
|
`grid_minor_visible`);
|
|
- 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 `Figure` of stacked rows sharing one synchronised x axis;
|
|
- live follow (`XFollow::Live`) with relative (multiplicative) zoom and mouse panning;
|
|
- XY series for irregularly sampled data (`plot/data/xy_series.hpp`): sorted-ascending x
|
|
with binary-searched crosshair, measurement and index snapping;
|
|
- per-trace draw modes: dash patterns (`Trace::dash_on`/`dash_off`, logical px, 0 = solid)
|
|
and point markers (`Trace::marker`: cross, circle, square) with a manual
|
|
`marker_every` stride or an automatic one; `width == 0` draws the markers alone
|
|
(scatter);
|
|
- three downsample policies per trace (`Trace::mode`, `GatherMode`): `Envelope`
|
|
(min/max pairs — the default), `Nearest` (one raw sample per bucket) and `Mean`
|
|
(one averaged point per bucket);
|
|
- readout text colour: crosshair value rows render in their row's trace colour, labels in
|
|
the axis text colour;
|
|
- input parity across hosts: pinch-zoom and pan gestures on Qt6 and GTK4 drive the same
|
|
controller paths as mouse and wheel (touch parity), with keyboard panning
|
|
(arrow keys), Escape cancelling a drag or box-zoom, double-click re-fitting the axes,
|
|
and modifier-masked box zoom (Shift = both axes, Ctrl = x only, Alt = y only);
|
|
- DPI-consistent sizing: metrics are logical units converted once through
|
|
`Style::ui_scale`;
|
|
- an anti-aliased embedded monospaced font, plus an optional runtime TrueType backend
|
|
(`plot::font_ttf`).
|
|
|
|
## Build and install
|
|
|
|
Needs a C++20 compiler and CMake **3.24+**. Presets live in `CMakePresets.json`
|
|
(`linux-debug`, `linux-release`, `linux-asan`, `linux-coverage`).
|
|
|
|
```sh
|
|
cmake --preset linux-release
|
|
cmake --build build/release
|
|
cmake --install build/release --prefix /where/you/want
|
|
```
|
|
|
|
Install is on by default (`PLOT_INSTALL=ON`) and, via `GNUInstallDirs`, installs the
|
|
`plot_core` archive under `lib/`, the public headers under `include/plot/` — including the
|
|
generated `plot/version.hpp` — a CMake package config under `lib/cmake/plot/`, a pkg-config
|
|
file under `lib/pkgconfig/plot.pc`, and the licences under `share/licenses/plot/`. A
|
|
consumer then needs only:
|
|
|
|
```cmake
|
|
find_package(plot 0.1 REQUIRED)
|
|
target_link_libraries(my_app PRIVATE plot::core)
|
|
```
|
|
|
|
**Versioning.** The package config carries **SameMinorVersion** compatibility: at 0.x the
|
|
major component promises nothing and the minor is what moves when the surface breaks, so
|
|
`find_package(plot 0.1 REQUIRED)` accepts 0.1.x and refuses anything newer. A shared build
|
|
(`BUILD_SHARED_LIBS=ON`) names its ABI on the file itself — `libplot_core.so.0`, the
|
|
SOVERSION being the major component. What a header set *is* is in its generated
|
|
`<plot/version.hpp>`: `plot::version::major`/`minor`/`patch`/`string`, or the
|
|
`PLOT_VERSION_*` macros for a preprocessor test. The full layout is tabled in
|
|
[docs/building-and-testing.md](docs/building-and-testing.md).
|
|
|
|
## Embedding
|
|
|
|
A host owns the window, the GL context and the event loop; `plot` owns none of them. This
|
|
is the whole of a minimal GLFW program — the adapter opens the window, runs the loop and
|
|
feeds the controller, and the callback is where live data would be pushed. It is committed
|
|
at the repository root as `readme_main.cpp`:
|
|
|
|
```cpp
|
|
#include <glfw/glfw_host.hpp> // the optional GLFW adapter
|
|
#include <plot/plot.hpp>
|
|
|
|
#include <cmath>
|
|
#include <vector>
|
|
|
|
int main() {
|
|
plot::glfw::Host host;
|
|
plot::Plot& p = host.plot();
|
|
|
|
std::vector<plot::f32> y(512);
|
|
for (plot::usize i = 0; i < y.size(); ++i)
|
|
y[i] = static_cast<plot::f32>(std::sin(0.05 * static_cast<double>(i)));
|
|
plot::data::ArraySeries series(std::move(y), 0.0, 1.0);
|
|
|
|
const plot::u32 x = p.add_x_axis({"x", {0.0, 511.0}, true});
|
|
const plot::u32 sig = p.add_y_axis({"signal", {-1.0, 1.0}, true, plot::AxisPlace::Left});
|
|
p.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, x, sig, "sine"});
|
|
|
|
plot::Status st;
|
|
return host.run({800, 480, "plot"}, [](plot::Plot&, plot::f64) {}, &st) ? 0 : 1;
|
|
}
|
|
```
|
|
|
|
It compiles and links against a built tree (see [docs/getting-started.md](docs/getting-started.md)
|
|
for the same program written against `plot::core` directly, with no adapter). The one
|
|
non-obvious flag is `-I build/debug/include`: that is where CMake generates
|
|
`plot/version.hpp`, which the umbrella pulls in:
|
|
|
|
```sh
|
|
g++ -std=c++20 -I include -I adapters -I build/debug/include readme_main.cpp \
|
|
build/debug/adapters/glfw/libplot_glfw.a build/debug/libplot_core.a \
|
|
$(pkg-config --libs glfw3) -ldl -lpthread -o plot_readme
|
|
```
|
|
|
|
## Documentation
|
|
|
|
The user guide lives under `docs/`; `docs/superpowers/` is the design record, not user
|
|
documentation.
|
|
|
|
- [Getting started](docs/getting-started.md) — prerequisites, build/install/consume, and a
|
|
minimal GLFW program against `plot::core`.
|
|
- [Architecture](docs/architecture.md) — the layers (`core`/`math` → `gl` → `data` →
|
|
`render` → `plot` → `input`), what each owns, and the dependency rules.
|
|
- [Embedding into a host](docs/embedding.md) — the four adapters, the `ui_scale`/DPI rule
|
|
per host, and the headless demo invocations.
|
|
- [Data and performance](docs/data-and-performance.md) — the sample stores, the
|
|
single-producer/single-consumer contract, the LOD pyramid, and the measured frame times.
|
|
- [Interaction](docs/interaction.md) — cursors, regions, the measurement band, the legend,
|
|
the crosshair, the gesture table, and live follow.
|
|
- [Styling](docs/styling.md) — every `Style` field with its default and unit, the grid
|
|
classes, and installing a text backend.
|
|
- [Building and testing](docs/building-and-testing.md) — presets, `PLOT_BUILD_*` options,
|
|
the suite, the headless demo modes, and the coverage command.
|
|
- [Coverage](docs/coverage.md) — the coverage method and the measured per-file table.
|
|
|
|
## Testing and coverage
|
|
|
|
```sh
|
|
cmake --preset linux-debug
|
|
cmake --build build/debug
|
|
ctest --test-dir build/debug --output-on-failure # 350 cases, 0 failed
|
|
|
|
tools/coverage.sh # configure+build+run+capture+floor check
|
|
```
|
|
|
|
The `linux-debug` suite runs **350 cases**; the `linux-coverage` preset builds **333** of
|
|
them, because its adapters are off and 17 cases need an adapter target (11 in
|
|
`test_imgui_widget.cpp`, 6 in `test_glfw_host.cpp`). Coverage is enforced by
|
|
`tools/coverage.sh` against `tools/coverage_floor.py`: **80%** per file under `src/` and
|
|
**90%** aggregate. The measured aggregate and per-file table live in
|
|
[docs/coverage.md](docs/coverage.md), which the command regenerates — it is the single
|
|
source for those numbers, so this page carries none.
|
|
|
|
## Licence and third-party provenance
|
|
|
|
- **This library** is **MIT licensed**: the full text is in [`LICENSE`](LICENSE), and
|
|
every first-party file (C++, Python and shell) carries its SPDX identifier in the file
|
|
header — `// SPDX-License-Identifier: MIT` — so the grant travels with each source file,
|
|
not just the repository root.
|
|
- **Adwaita Mono** (the embedded font, `render::EmbeddedMonoFont`) is under **SIL OFL
|
|
1.1**, committed verbatim at [`licences/OFL-1.1.txt`](licences/OFL-1.1.txt). The backend
|
|
class is named neutrally (`EmbeddedMonoFont`, not "Adwaita Mono") because OFL reserves
|
|
the font name for modified redistributions. The baked atlas is generated at authoring
|
|
time by `tools/fonts/gen_atlas.py` and committed as data, so `plot_core` rasterises
|
|
nothing at build or run time.
|
|
- **stb_truetype v1.26** (public domain, Sean Barrett / RAD Game Tools) is vendored at
|
|
`third_party/stb_truetype.h` and compiled **only** by the optional `plot_font_ttf`
|
|
target; its include directory is `PRIVATE`, so no consumer and no other target can reach
|
|
it. `PLOT_BUILD_TTF_FONT` defaults on exactly when the vendored header is present.
|
|
- **`plot_core` is third-party-free.** It links only `${CMAKE_DL_LIBS}`, and no public
|
|
header under `include/plot/` includes a third-party header. The header self-containment
|
|
and umbrella checks in `tests/CMakeLists.txt` fail the build if either rule breaks.
|
|
|
|
## Limitations
|
|
|
|
- **Timings are llvmpipe under Xvfb** (`tools/coverage.sh`, the demo summaries). No
|
|
measurement here used a GPU; there are no hardware numbers.
|
|
- **macOS and Windows are untested.** Development and CI are Linux/EGL + X11 only.
|
|
- **Fractional-scale compositing and touchpad scroll calibration are unverified** — the
|
|
`ui_scale` sources are correct per host, but no run exercised a real fractional DPI or a
|
|
calibrated scroll device. The new gesture paths (Qt6 and GTK4 pinch, the keyboard and
|
|
wheel paths behind the parity list above) are tested at the toolkit-free seams and
|
|
headless; no run has touched real touch hardware.
|
|
- **Figure row rects are one frame stale during a resize.** The hosts cache
|
|
`Figure::layout()`'s rows and refresh them once per frame, so an event arriving mid-resize
|
|
routes against the previous frame's rects (see [docs/embedding.md](docs/embedding.md)).
|
|
- **Untrusted font files are out of scope** for the runtime `plot::font_ttf` backend:
|
|
stb_truetype does no range checking, so `from_file()` must only be handed files the host
|
|
trusts.
|
|
- **`render` ↔ `plot` is not a strict include chain.** `Renderer` includes `plot/plot.hpp`
|
|
because drawing *is* being handed a `Plot`; the `plot` layer names only `render`'s value
|
|
types (`Style`, `TextBackend`), never `Renderer`. The cycle and its direction are
|
|
documented in [docs/architecture.md](docs/architecture.md).
|