Files
Martino Ferrari f79a97be9b fix: a density grid is bounded in cells, not by the window's pixels
A grid was the axes rect in device pixels, so dragging a window wide made the
image wider with it -- and the per-frame pass over it is O(cells): the fade
multiplies every cell, a host's ink()/peak() read every cell, and a frame the
fade moved converts and uploads every cell as one byte. At 2x device scale a
1520x850 window's axes rect is 2877 device pixels wide, so four rows of one
trace are 4.5 M cells and 4.9 MB uploaded a frame; measured headless on
llvmpipe with a 2 s window, 12.9 ms of tick and 12.8 ms of draw a frame, every
frame under a time fade -- a cost that follows the window and has no ceiling.

The grid is now the axes rect up to kDensityCellCap (2^18 cells, a mebibyte of
counts), scaled down uniformly past it so cells stay square and the picture
keeps the axes' aspect, and magnified back over the rect with a linear filter:
a cell is a count, so a fragment between two of them is between their two ink
levels, which a point sample cannot give once a cell is several pixels wide.
The same four rows at the cap are 1.05 M cells, 3.9 ms of tick and 5.6 ms of
draw and 1.0 MB uploaded, while a grid under the cap is untouched -- a 640x360
viewport's 573x313 axes is still 179 349 cells, one a device pixel.

The image's footprint is the axes rect rounded up to whole device pixels on
both sides of the pipe, so a grid under the cap keeps the exact
one-texel-a-pixel mapping it has always had (sx/sy come out exactly 1) and only
a capped grid reads its cell index through a scale. Moving the decay itself to
the GPU was considered and left out: the CPU grid is what ink()/peak() read,
what the deposit adds to and what the per-sweep dedupe keys on, so a GPU image
would have to be reconciled with it per frame -- a larger change than this
bound, and not one to half-do.

Tests: a_density_grid_is_capped_whatever_the_window reads the bound off a
3840x2160 viewport (261 504 cells of a 3784x2138 axes, aspect kept),
a_capped_density_is_magnified_over_the_axes_rect reads the screen side of it
(569x460 cells covering all 832 of the rect's columns), and the density cases
now assert their capped figures. 399 cases, 0 failed.
2026-10-16 21:09:07 +02:00

266 lines
16 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;
- a **phosphor density** a host can turn on for a plot (`Plot::set_density()`, an image rather
than a set of retained traces): **one grid per trace**, in that trace's own colour, the axes
rect in device pixels, drawn *in place of* the traces -- a host may not also draw them over it --
into which each acquisition is deposited, 1.0 per cell a trace crosses, so brightness is how
often it passed. It fades either by a **time constant** in stream seconds or by a
**per-acquisition factor**, and the newest acquisition is deposited like every other, so it
fades too. The resolution is bounded (`kDensityCellCap`, 262 144 cells -- a mebibyte of counts --
a grid, past which the rect is scaled down uniformly and the image magnified back over it with a
linear filter), because the per-frame pass is `O(cells)` and the window is not bounded at all:
memory is the cap times the traces on screen times 4 bytes, whatever the window (a 640x360
viewport's axes is 179 349 cells per trace, 717 kB, under it, while four rows of one trace in a
3040x1700 one used to be 4.5 M cells and 18 MB between them). The deposit walks the axes'
columns rather than the samples, and a frame whose grids did not move costs one draw call per
trace (`FrameStats::density_cells`/`density_upload_bytes`); see `docs/data-and-performance.md`;
- 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 # 396 cases, 0 failed
tools/coverage.sh # configure+build+run+capture+floor check
```
The `linux-debug` suite runs **396 cases**; the `linux-coverage` preset builds **379** 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).