Intensity grading was gated on the column reduction, which is gated on a density whose only job is killing the moire beat. Dwell has nothing to do with that, so narrowing a window past the threshold turned the whole phosphor model off and a discontinuity went from a third of a flat top's ink to all of it in one pixel of zoom. stage_trace_slopes() grades each segment by its own slope instead, with a measure that agrees with the dense path at the boundary, and reports itself as FrameStats::slope_traces -- disjoint from column_traces, so a host asserting graded_traces == column_traces reads the same as before. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
243 lines
14 KiB
Markdown
243 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 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);
|
|
- 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).
|