The README listed the four host adapters and never said what any of them was for. The GTK4 one exists for udpscope, whose URL is now here, and the library's interaction layer -- cursors, measurement marks, invalid-span shading -- was shaped by what that host needed. The source and test notes that say "in udpscope's case" have a pointer to it now.
252 lines
14 KiB
Markdown
252 lines
14 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/` |
|
|
|
|
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;
|
|
- 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 # 377 cases, 0 failed
|
|
|
|
tools/coverage.sh # configure+build+run+capture+floor check
|
|
```
|
|
|
|
The `linux-debug` suite runs **377 cases**; the `linux-coverage` preset builds **360** 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).
|