docs: a user guide covering getting started, architecture, embedding, data, interaction, styling and testing
This commit is contained in:
1 parent
d03f9ce51a
commit
b7d0f901cc
7 files changed
+1082
No files matched your search
@@ -0,0 +1,68 @@
|
||||
# Architecture
|
||||
|
||||
`plot` is one library, `plot_core`, plus optional adapter targets. The public headers are
|
||||
grouped into seven directories, and the umbrella header `include/plot/plot.hpp` includes
|
||||
them in this order:
|
||||
|
||||
```
|
||||
core / math → gl → data → render → plot → input
|
||||
```
|
||||
|
||||
with the host adapters (`adapters/glfw`, `adapters/imgui`, `adapters/qt6`, `adapters/gtk4`)
|
||||
built on top. This page says what each layer owns and the rules the build enforces.
|
||||
|
||||
## What each layer owns
|
||||
|
||||
| layer | directory | owns |
|
||||
|---|---|---|
|
||||
| `core` | `include/plot/core/`, `src/core/` | the vocabulary: `f32`/`f64`/`u32`/`usize`, `kInvalid`, `Range`, `Rect`, `Color`, `Span`, and `Status`/`ErrorCode` for fallible operations. |
|
||||
| `math` | `include/plot/math/`, `src/math/` | `Vec2`, `Mat3`, and `Transform` — the data↔pixel affine mapping, including the log-axis handling and its invertibility guarantee. |
|
||||
| `gl` | `include/plot/gl/`, `src/gl/` | a hand-written OpenGL surface: enumerator typedefs, the entry-point table `plot::gl::load()` fills, `plot::gl::caps()`, and the move-only RAII wrappers (`gl::Buffer`, `VertexArray`, `Program`, `Texture`, `Framebuffer`, `Fence`). No GL loader library is used. |
|
||||
| `data` | `include/plot/data/`, `src/data/` | the sample stores: the `Series` interface, `ArraySeries`, `StreamSeries`, `RingBuffer`, and the min/max LOD `MinMaxPyramid`. |
|
||||
| `render` | `include/plot/render/`, `src/render/` | presentation: `Style`, the `Renderer`, the text machinery (`TextBackend`, `TextAtlas`, `EmbeddedMonoFont`), and the shader/program/buffer/vertex-array primitives. |
|
||||
| `plot` | `include/plot/plot/`, `src/plot/` | the scene model: `Plot`, `Figure`, and the primitives they own — `Axis`, `Trace`, `Cursor`, `Region`, `Measure`, `Legend`, `Crosshair`, `Annotation`, `Grid`, `Ticks`. Pure CPU state, no GL. |
|
||||
| `input` | `include/plot/input/`, `src/input/` | `Controller` and the host-neutral event types (`PointerEvent`, `WheelEvent`, `KeyEvent`); turns events into gestures. |
|
||||
| adapters | `adapters/*/` | one per host: they own the window/context/event loop and translate their toolkit's events into `input` events. |
|
||||
|
||||
## The dependency rules
|
||||
|
||||
- **`plot_core` is third-party-free.** Its `CMakeLists.txt` links only
|
||||
`${CMAKE_DL_LIBS}`, and the public headers under `include/plot/` include no third-party
|
||||
header. The one exception is the optional `plot::font_ttf` target, which compiles the
|
||||
vendored `third_party/stb_truetype.h` (stb_truetype v1.26, public domain) in its own
|
||||
object library `plot_stb_truetype`, whose include directory is `PRIVATE` — so nothing
|
||||
else in the build, and no consumer, can reach it by accident. `PLOT_BUILD_TTF_FONT`
|
||||
defaults to on exactly when the vendored header is present.
|
||||
- **Every public header compiles standalone, and `plot.hpp` includes every one of them.**
|
||||
`tests/CMakeLists.txt` generates one translation unit per header (globbed, so a new
|
||||
header is picked up automatically), builds an umbrella TU, and registers ctest cases
|
||||
(`plot_headers_self_contained`, `plot_umbrella_covers_public_headers`,
|
||||
`plot_umbrella_check`) that fail the build if either rule breaks.
|
||||
- **The umbrella stops at the library.** It never includes an adapter header, so
|
||||
`#include <plot/plot.hpp>` cannot drag in GLFW, ImGui, Qt or GTK. The adapter headers are
|
||||
reached as `<glfw/glfw_host.hpp>`, `<imgui/imgui_plot.hpp>`, `<qt6/qt_plot_widget.hpp>`
|
||||
and `<gtk4/gtk_plot_area.hpp>`.
|
||||
- **The renderer never owns the context or the event loop.** `Renderer` never creates,
|
||||
binds or resizes a framebuffer: `begin_frame(fb_w, fb_h, viewport_px)` draws into
|
||||
whatever framebuffer is bound, clears exactly the given rectangle (scissored, so a host
|
||||
that painted the rest of its surface keeps it), and `end_frame()` turns the GL state it
|
||||
moved back off. The renderer also never sees a pointer, a button or a modifier — it draws
|
||||
the handles the plot reports as hot, and only a host that drives the plot with an
|
||||
`input::Controller` ever makes anything hot. Owning the window, the context and the loop
|
||||
is the adapter's job (or, for a library user, the application's).
|
||||
|
||||
One direction is worth stating precisely, because it is the one place the layer order is
|
||||
not a strict include chain: `Renderer` (in `render/`) includes `plot/plot.hpp` and
|
||||
`plot/figure.hpp`, because drawing *is* being handed a `Plot` or a `Figure` to draw. The
|
||||
`plot` layer names only `render`'s value types — `render::Style` and
|
||||
`render::TextBackend` — and never names `Renderer`. So the runtime direction is
|
||||
"the renderer knows the plot, the plot does not know the renderer".
|
||||
|
||||
## The design record
|
||||
|
||||
The specifications and implementation plans — the requirements, the numbered spec sections
|
||||
this documentation sometimes refers to, and the reasoning behind decisions — live under
|
||||
[`docs/superpowers/`](superpowers/) (`specs/` and `plans/`). That tree is the project's
|
||||
design record, not user documentation: it is where a decision is argued, not where the
|
||||
library is explained. This `docs/` guide and [docs/coverage.md](coverage.md) are the
|
||||
user-facing documents.
|
||||
@@ -0,0 +1,109 @@
|
||||
# Building and testing
|
||||
|
||||
## Presets and options
|
||||
|
||||
Four configure/build presets live in `CMakePresets.json`:
|
||||
|
||||
| preset | binaryDir | what it builds |
|
||||
|---|---|---|
|
||||
| `linux-debug` | `build/debug` | Debug; tests, examples and all four adapters |
|
||||
| `linux-release` | `build/release` | Release; `PLOT_BUILD_TESTS=OFF` (so the installed `libplot_core.a` carries none of the test-only counter instrumentation); examples and all four adapters |
|
||||
| `linux-asan` | `build/asan` | Debug with `PLOT_SANITIZE=address`; tests and all four adapters |
|
||||
| `linux-coverage` | `build/cov` | the instrumented tree behind `tools/coverage.sh`: `--coverage -O0 -g`, `PLOT_COVERAGE=ON`, tests on, adapters and examples off |
|
||||
|
||||
The options (`cmake/plot_options.cmake`) and their defaults:
|
||||
|
||||
| option | default | meaning |
|
||||
|---|---|---|
|
||||
| `PLOT_BUILD_TESTS` | `ON` | build the test suite (needs EGL) |
|
||||
| `PLOT_BUILD_EXAMPLES` | `OFF` | build the demos |
|
||||
| `PLOT_BUILD_GLFW` | `OFF` | build the GLFW adapter and its demo |
|
||||
| `PLOT_BUILD_IMGUI` | `OFF` | build the Dear ImGui adapter and its demo (ImGui is fetched at configure time) |
|
||||
| `PLOT_BUILD_QT6` | `OFF` | build the Qt6 adapter and its demo |
|
||||
| `PLOT_BUILD_GTK4` | `OFF` | build the GTK4 adapter and its demo |
|
||||
| `PLOT_BUILD_TTF_FONT` | on iff `third_party/stb_truetype.h` exists | build the optional `plot::text::TrueTypeFont` backend |
|
||||
| `PLOT_INSTALL` | `ON` | generate install/export rules for `find_package(plot)` |
|
||||
| `PLOT_SANITIZE` | `""` | sanitizer list for `-fsanitize=` (e.g. `address`) |
|
||||
| `PLOT_COVERAGE` | `OFF` | add the `coverage` target (requires an instrumented tree) |
|
||||
|
||||
Adapters and examples are off by default, so the core library and its tests build on a bare
|
||||
toolchain. Each example is guarded by the adapter target it needs, so
|
||||
`-DPLOT_BUILD_EXAMPLES=ON` without the matching adapter is a no-op rather than a configure
|
||||
error.
|
||||
|
||||
## Running the suite
|
||||
|
||||
```sh
|
||||
cmake --preset linux-debug
|
||||
cmake --build build/debug
|
||||
ctest --test-dir build/debug --output-on-failure
|
||||
```
|
||||
|
||||
ctest registers four tests: `plot_tests` (the test binary), the two header/umbrella build
|
||||
checks (`plot_headers_self_contained`, `plot_umbrella_covers_public_headers`) and
|
||||
`plot_umbrella_check`. The `plot_tests` binary itself runs **283 cases** in the `linux-debug`
|
||||
preset; its harness prints one line per case and a final `283 cases, 0 failed` summary line.
|
||||
The case counts per file are the `PLOT_TEST(...)` declarations under `tests/`.
|
||||
|
||||
## Headless demo modes
|
||||
|
||||
Each demo supports two environment variables, implemented once and reused:
|
||||
|
||||
- `PLOT_DEMO_EXIT_AFTER_MS=<n>` — stop the loop after `n` milliseconds and return. Unset,
|
||||
unparsable or not positive means "run until the window is closed". A run with a deadline
|
||||
creates its window hidden, so an automated run never flashes a window on a real display,
|
||||
and its frame times are not throttled by a vblank.
|
||||
- `PLOT_DEMO_SCREENSHOT=<path>` — on that final frame, write it as a binary PPM before the
|
||||
swap. The GLFW host writes the framebuffer with `Renderer::dump_frame_ppm()`; the ImGui
|
||||
demo writes the whole framebuffer, while Qt and GTK write their widget's/area's own
|
||||
framebuffer (their toolkits' chrome lives outside it).
|
||||
|
||||
That pair is what lets CI run the GUI demos under `xvfb-run` with no display access and no
|
||||
screen capture (the invocations are in [embedding.md](embedding.md)).
|
||||
|
||||
Three demos add env-gated **self-checking** modes. These are verification affordances for
|
||||
the project's own testing, driven through the real host and its own controllers — they are
|
||||
not a second implementation of anything, and they exit non-zero if a measured property fails:
|
||||
|
||||
| variable | demo | what it drives |
|
||||
|---|---|---|
|
||||
| `PLOT_DEMO_FOLLOW=1` | `plot_glfw_demo` | the live-follow sequence: three wheel notches and a pan on a `XFollow::Live` axis, printing the range width, the distance behind the newest sample and the follow state at each step |
|
||||
| `PLOT_DEMO_REGIONS` (`=hot` to hover a bound) | `plot_glfw_demo` | the direct-manipulation sequence: freeze the view, place a region, pan 100 px with the press outside it, and check the region came along with its width untouched |
|
||||
| `PLOT_DEMO_SYNC=1` | `plot_multiplot_demo` | the synced-follow check: the three rows hold one range and its right edge keeps tracking the newest sample through a zoom and a pan on different rows |
|
||||
| `PLOT_DEMO_CROSSHAIR=<row>` | `plot_multiplot_demo` | parks the pointer in a row each frame so a headless screenshot shows the one horizontal line the hovered row draws |
|
||||
|
||||
(`plot_glfw_demo` also accepts `PLOT_DEMO_CROSSHAIR=1`, which parks the pointer at the centre
|
||||
of the data area so the two-line crosshair has ink to measure.)
|
||||
|
||||
## Coverage
|
||||
|
||||
```sh
|
||||
tools/coverage.sh # from the repository root
|
||||
cmake --build build/cov --target coverage # the same thing, through CMake
|
||||
```
|
||||
|
||||
The script configures `build/cov` from the `linux-coverage` preset, builds it, clears the
|
||||
`.gcda` counters, runs the whole suite, captures `src/**` with `lcov`, renders an HTML report
|
||||
to `build/cov/html/index.html`, prints the per-file table, and **exits non-zero when a floor
|
||||
is violated**. It needs `cmake`, `ninja`, `ctest`, `lcov`, `genhtml` and `python3`, and exits
|
||||
`2` with a message when one is missing.
|
||||
|
||||
The floors are enforced by `tools/coverage_floor.py`:
|
||||
|
||||
| scope | floor |
|
||||
|---|---|
|
||||
| every file under `src/` | **80%** of its instrumented lines |
|
||||
| `src/` as a whole | **90%** of instrumented lines |
|
||||
|
||||
Branch coverage is deliberately off. The current measurement (from `build/cov/coverage.info`,
|
||||
re-read with `python3 tools/coverage_floor.py build/cov/coverage.info`) is an **aggregate
|
||||
95.3% (3284/3447 lines)** over 23 files, every file at or above 80% — the floor is satisfied.
|
||||
The method, the full per-file table and the reproducibility notes are in
|
||||
[docs/coverage.md](coverage.md); re-run the command for the live numbers.
|
||||
|
||||
**One thing to know about the coverage preset:** it builds **272 of the 283 cases**. The 11
|
||||
that are missing are the adapter cases that need an adapter target to exist — 8 in
|
||||
`test_imgui_widget.cpp` and 3 in `test_glfw_host.cpp` — and the coverage preset turns the
|
||||
adapters off. The toolkit-free adapter seams still run there: `test_qt_pointer.cpp` (2 cases)
|
||||
and `test_gtk_input.cpp` (8 cases) compile without Qt or GTK. So the coverage number measures
|
||||
the library plus its unit suite, not the adapter bodies (which the headless demo runs cover).
|
||||
@@ -0,0 +1,151 @@
|
||||
# Data and performance
|
||||
|
||||
## The sample stores
|
||||
|
||||
All three series derive from `plot::data::Series` (`include/plot/data/series.hpp`), which
|
||||
describes a **uniform-grid** series: sample `i` lives at `x = x0() + i * dx()`.
|
||||
|
||||
```cpp
|
||||
class Series {
|
||||
public:
|
||||
virtual u64 size() const;
|
||||
virtual f64 x0() const; // x of sample 0 (the oldest retained sample)
|
||||
virtual f64 dx() const;
|
||||
virtual Range x_bounds() const;
|
||||
virtual Range y_bounds() const;
|
||||
virtual const MinMaxPyramid& pyramid() const;
|
||||
virtual bool streaming() const noexcept; // default false
|
||||
virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const;
|
||||
|
||||
Range reduce(u64 begin, u64 end) const; // exact min/max over [begin, end)
|
||||
f64 sum(u64 begin, u64 end) const; // exact sum over [begin, end)
|
||||
f64 sumsq(u64 begin, u64 end) const; // exact sum of squares
|
||||
};
|
||||
```
|
||||
|
||||
Indices are the series' **own window**: index 0 is the oldest sample it currently holds, so
|
||||
`size()`, `x0()`, `reduce()`, `gather()` and `pyramid()` are all in the same coordinates.
|
||||
|
||||
**`ArraySeries`** (`array_series.hpp`) owns a `std::vector<f32>` of y samples plus an
|
||||
`x0`/`dx` grid. Construct with `ArraySeries(std::vector<f32> y, f64 x0, f64 dx)`. The vector
|
||||
is taken by value and owned, so the samples cannot change behind the series' back; the store
|
||||
changes only through `reset(y, x0, dx)` (a whole new vector, then a rebuild) or `commit()`,
|
||||
which rebuilds the pyramid and cached bounds from the current `y`.
|
||||
|
||||
**`StreamSeries`** (`stream_series.hpp`) is the single-producer / single-consumer streaming
|
||||
store: `StreamSeries(u64 capacity, f64 x0, f64 dx)`.
|
||||
|
||||
## The SPSC contract
|
||||
|
||||
One producer thread, one consumer thread, and the division is strict
|
||||
(`stream_series.hpp`):
|
||||
|
||||
- **The producer may only call** `push(f32 y)`, `push(const f32* ys, u64 n)` and
|
||||
`produced()`. Those touch the ring buffer and nothing else: **no lock, no pyramid, no GL**.
|
||||
The acquisition thread can therefore never stall the renderer.
|
||||
- **The consumer calls `acquire()`**, which snapshots the produced count and brings the
|
||||
pyramid and bounds up to it, and then queries the published window. `size()`, `reduce()`,
|
||||
`sum()`, `sumsq()`, `gather()` and `pyramid()` all use the window published by the last
|
||||
`acquire()`.
|
||||
|
||||
`acquire()` costs O(samples produced since the last acquire), not O(capacity): the pyramid
|
||||
is indexed in absolute sample space, so a sliding window only drops the leading buckets the
|
||||
ring overwrote and appends what is new; aggregates still in the window are never recomputed.
|
||||
The one exception is a producer that laps the consumer (more than `capacity()` samples
|
||||
between two acquires), where the missing samples cannot be represented and the pyramid
|
||||
restarts at the new base, re-reading the whole window.
|
||||
|
||||
The ring overwrites the oldest samples — that is what keeps a live stream showing the newest
|
||||
data — so it is **not a frozen window**. Those slots are relaxed atomics, so this is not a
|
||||
data race and no value tears; but if the producer pushes `k` samples while a read is in
|
||||
flight, the first `k` positions of that read can hold one of those `k` newer samples rather
|
||||
than the one the snapshot named. Ingest with no reuse underneath a read at all needs the
|
||||
producer to stop before unread data (drop-on-full) or a claim protocol — a different
|
||||
contract from this one.
|
||||
|
||||
`streaming()` returns `true` for `StreamSeries` and `false` for everything else. Only a
|
||||
streaming series has a **live edge** (the newest x it holds, which moves on its own), and it
|
||||
is that property `XFollow::Live` follows (see [interaction.md](interaction.md)).
|
||||
|
||||
## The min/max LOD pyramid
|
||||
|
||||
`data::MinMaxPyramid` (`minmax_pyramid.hpp`) is the index that makes per-frame cost depend on
|
||||
the visible pixels rather than on the sample count.
|
||||
|
||||
- Samples are grouped into buckets of `kBucket = 64` (**`kBucket`** is a fixed constant, so
|
||||
all series share one LOD geometry). Level `l` bucket `k` covers the absolute samples
|
||||
`[k * (kBucket << l), (k + 1) * (kBucket << l))`, and bucket boundaries are fixed for the
|
||||
pyramid's life — appending never re-buckets existing aggregates, and a window can slide
|
||||
over the same buckets.
|
||||
- Each level holds its complete buckets in a `std::deque` (O(1) `push_back` and
|
||||
`pop_front`) plus a `base`. `append()` folds a sample into the in-progress level-0 bucket
|
||||
and, when it completes, pushes it and walks up, stopping at the first level that gains no
|
||||
entry: an append costs **amortised O(1)**. `drop_before(u)` slides the window's leading
|
||||
edge, bounding the memory by the retained window rather than by everything ever appended.
|
||||
- An entry is 24 bytes (`MinMax` plus the exact `sum`/`sumsq` the exact range queries fold
|
||||
over), so level 0 costs `0.375n` bytes and the geometric tail brings the whole pyramid to
|
||||
about **`0.75n` bytes — roughly 19% of the `4n` bytes of raw samples** (`f32`). A
|
||||
min/max-only index would be `0.25n` (~6%); the two doubles are the price of answering
|
||||
mean/RMS/integral from whole buckets instead of raw samples.
|
||||
|
||||
Queries on the pyramid are **bucket-grained** and named `*_buckets` (`reduce_buckets`,
|
||||
`sum_buckets`, `sumsq_buckets`): a pyramid stores aggregates, so it can only answer exactly
|
||||
for whole buckets, and partial edge buckets are excluded rather than approximated. Exact
|
||||
range queries live on `Series`, which owns the raw samples.
|
||||
|
||||
`level_for(u64 n, f64 samples_per_pixel)` returns the pyramid level whose buckets hold about
|
||||
`samples_per_pixel` samples for a window of `n` samples (clamped to levels that exist, to
|
||||
levels with more than one bucket, and to levels that give the window at least two buckets).
|
||||
|
||||
The renderer uses this: a trace's x range is mapped onto sample indices, and `Series::gather`
|
||||
is asked for that window *at `pyramid().level_for(n, samples_per_pixel)`* whenever a pixel
|
||||
covers `Style::dense_threshold_spp` samples or more (default 1.5). A 100-million-sample
|
||||
series therefore stages O(visible pixels) vertices, not O(samples), and `FrameStats` records
|
||||
which path each trace took. Below the threshold the raw samples are drawn instead; both
|
||||
sides go through the same instanced anti-aliased quad pipeline.
|
||||
|
||||
## Exact versus interpolated
|
||||
|
||||
The library deliberately holds two conventions next to each other, and they do **not**
|
||||
agree:
|
||||
|
||||
- **`Series::reduce` / `sum` / `sumsq` are exact over a sample range.** They answer "what
|
||||
are the samples' extremes / sum / sum of squares over `[begin, end)`". Each is
|
||||
implemented once over `copy_raw()`: the bucket-aligned interior comes from the pyramid
|
||||
(O(log n) bucket aggregates) and at most two partial edge buckets are read raw and
|
||||
scanned, so the cost is O(`kBucket`) worst case and **independent of the series length**,
|
||||
and no query allocates. `Measure::evaluate` and `Plot::region_stats` are built on these,
|
||||
never on the `*_buckets` forms, which would silently report wrong extremes for an
|
||||
unaligned interval.
|
||||
- **The crosshair readout is interpolated, and says so.** `crosshair_span(series, x)`
|
||||
returns the two samples `x` lies between (`begin`, `end`, `t`), and
|
||||
`crosshair_interpolate(y_begin, y_end, t)` does a linear interpolation: it answers "what
|
||||
is the signal *at* this instant", which is generally between two samples — a different
|
||||
question from an interval statistic. The lookup is O(1) (one division and a floor) and
|
||||
allocation-free; `Plot::crosshair_value(trace, x, out_y)` exposes the same number to a
|
||||
host. A row whose trace has no sample at the crosshair's x shows `kCrosshairNoValue`
|
||||
(an em dash, `"—"`), and an x outside the series window yields no value rather than the
|
||||
nearest sample's.
|
||||
|
||||
`Measurement` (from `Plot::measurement()`) documents `integral` as a **left-Riemann sum**,
|
||||
`sum(begin, end) * dx` — the samples taken as the value at the left edge of each step. That
|
||||
is part of the result's contract, not an implementation detail.
|
||||
|
||||
## Measured frame times
|
||||
|
||||
These are the demos' own frame summaries, taken from a `linux-release` build, **all of them
|
||||
llvmpipe (Mesa software rasteriser) under Xvfb** — no run touched a GPU. Treat them as
|
||||
upper bounds and as a record of the software path, not as hardware numbers.
|
||||
|
||||
| demo | window | mean ms/frame | work |
|
||||
|---|---|---|---|
|
||||
| GLFW (`plot_glfw_demo`) | 1280×720 | 0.42 | one 1 MS/s `StreamSeries` |
|
||||
| ImGui (`plot_imgui_demo`) | 1280×720 | 0.37 | the same stream, embedded |
|
||||
| multiplot (`plot_multiplot_demo`) | 1280×720 | 0.66 | 3 rows, 6 traces, 500 kS/s each |
|
||||
| Qt6 (`plot_qt_demo`) | 820×588 at exit | 3.23 | one stream, `QOpenGLWidget` FBO |
|
||||
| GTK4 (`plot_gtk_demo`) | 810×564 at exit | 2.85 | one stream, `GtkGLArea` FBO |
|
||||
|
||||
The multiplot run stages 18 651 segments for ~4.5 million visible samples, which is the
|
||||
pyramid doing its job: the frame is bounded by the pixels, not the samples. The Qt and GTK
|
||||
figures are dominated by their toolkit's texture-backed FBO under llvmpipe, not by the
|
||||
library.
|
||||
@@ -0,0 +1,213 @@
|
||||
# Embedding into a host
|
||||
|
||||
A host owns the window, the GL context and the event loop; `plot` owns none of them. The
|
||||
contract the renderer offers is small: make a 3.3 core context current, resolve the entry
|
||||
points with `plot::gl::load()`, call `Renderer::init()`, then each frame
|
||||
|
||||
```cpp
|
||||
renderer.style().ui_scale = <device pixels per logical unit>;
|
||||
renderer.begin_frame(fb_w, fb_h, viewport_px); // clears exactly viewport_px
|
||||
renderer.draw(plot); // or renderer.draw(figure)
|
||||
renderer.end_frame(); // restores the GL state it moved
|
||||
```
|
||||
|
||||
and hand the plot (or figure) whatever events the host receives. The `Renderer` never
|
||||
creates, binds or resizes a framebuffer, never reads your event loop, and never sees a
|
||||
pointer or a button.
|
||||
|
||||
Below are the four shipped adapters. Each is a separate opt-in target
|
||||
(`plot::glfw`, `plot::imgui`, `plot::qt6`, `plot::gtk4`) and each reads its own source
|
||||
under `adapters/`.
|
||||
|
||||
## Shared conventions
|
||||
|
||||
**Pixel space.** Every `Rect`, viewport and pointer position `plot` speaks is in
|
||||
**device (framebuffer) pixels, origin top-left**. Every host below reports geometry in
|
||||
*logical* units (window points, widget pixels) and converts before forwarding, so a
|
||||
gesture lands on the pixels the plot was laid out in.
|
||||
|
||||
**DPI / `ui_scale`.** The metrics in `render::Style` (font size, stroke widths, paddings,
|
||||
tick spacing) are **logical** units; `Style::ui_scale` is device pixels per logical unit,
|
||||
and the renderer converts each metric through `Style::device()` exactly once. The host
|
||||
writes `ui_scale` from its own display, so a 12 px label is the same physical size on a 1x
|
||||
and a 2x panel. The one metric that is *not* scaled is `dense_threshold_spp`, a
|
||||
samples-per-pixel density (see [styling.md](styling.md)). How each host supplies the
|
||||
scale:
|
||||
|
||||
| host | value written to `ui_scale` | source |
|
||||
|---|---|---|
|
||||
| GLFW | `fb_scale_x` = framebuffer width / window width | `glfwGetFramebufferSize()` vs `glfwGetWindowSize()`, re-read on resize |
|
||||
| ImGui | `io.DisplayFramebufferScale.x` | filled by the platform backend |
|
||||
| Qt6 | `devicePixelRatioF()` | re-read every `paintGL()` |
|
||||
| GTK4 | `mapping_.factor` = device size / logical allocation | the two sizes `::resize` reports, not `gtk_widget_get_scale_factor()` |
|
||||
|
||||
**Headless verification.** Every demo supports `PLOT_DEMO_EXIT_AFTER_MS=<ms>` (stop the
|
||||
loop after that long) and `PLOT_DEMO_SCREENSHOT=<path>` (write the final frame as a PPM).
|
||||
This is how the project runs its own GUI demos in CI under Xvfb, with no display access
|
||||
and no screen capture. The invocations it uses (each demo, from the repository root, all
|
||||
five exit `0`):
|
||||
|
||||
```sh
|
||||
PLOT_DEMO_EXIT_AFTER_MS=4000 PLOT_DEMO_SCREENSHOT=/tmp/glfw.ppm \
|
||||
xvfb-run -a ./build/release/examples/plot_glfw_demo
|
||||
PLOT_DEMO_EXIT_AFTER_MS=4000 PLOT_DEMO_SCREENSHOT=/tmp/multiplot.ppm \
|
||||
xvfb-run -a ./build/release/examples/plot_multiplot_demo
|
||||
PLOT_DEMO_EXIT_AFTER_MS=4000 PLOT_DEMO_SCREENSHOT=/tmp/imgui.ppm \
|
||||
xvfb-run -a ./build/release/examples/plot_imgui_demo
|
||||
PLOT_DEMO_EXIT_AFTER_MS=4000 PLOT_DEMO_SCREENSHOT=/tmp/qt.ppm \
|
||||
QT_QPA_PLATFORM=xcb xvfb-run -a -s "-screen 0 1920x1200x24" \
|
||||
./build/release/examples/plot_qt_demo
|
||||
PLOT_DEMO_EXIT_AFTER_MS=4000 PLOT_DEMO_SCREENSHOT=/tmp/gtk.ppm \
|
||||
GDK_BACKEND=x11 xvfb-run -a -s "-screen 0 1920x1200x24" \
|
||||
./build/release/examples/plot_gtk_demo
|
||||
```
|
||||
|
||||
The `-s "-screen 0 1920x1200x24"` shape matters for Qt and GTK, whose demos script a
|
||||
resize before the exit frame; a bare `xvfb-run` screen (640×480) clamps the window and
|
||||
that self-check reports a false failure.
|
||||
|
||||
## GLFW — `plot::glfw`
|
||||
|
||||
`#include <glfw/glfw_host.hpp>`, link `plot::glfw` (which links `plot::core`). This is the
|
||||
reference host: `Host` owns the GLFW window and its 3.3 core context, the GL entry points,
|
||||
the `Renderer`, the `Plot`, an `input::Controller` and the frame loop.
|
||||
|
||||
```cpp
|
||||
plot::glfw::Host host;
|
||||
plot::Plot& p = host.plot(); // populate axes/traces here
|
||||
plot::Status st;
|
||||
if (!host.run({1280, 720, "plot"}, [](plot::Plot& p, plot::f64 dt) { /* feed data */ }, &st)) { ... }
|
||||
```
|
||||
|
||||
What `run()` does, in order:
|
||||
|
||||
1. `glfwInit()`, then window hints for OpenGL **3.3 core** (plus forward compatibility) and
|
||||
`glfwCreateWindow()`, `glfwMakeContextCurrent()`, `glfwSwapInterval(vsync)`.
|
||||
2. `plot::gl::load(&resolve_gl)`, where `resolve_gl` wraps `glfwGetProcAddress` (a function
|
||||
pointer respelled as `void*` by bit copy).
|
||||
3. `Renderer::init()`.
|
||||
4. Per frame: `glfwPollEvents()` → `refresh_sizes()` →
|
||||
`controller.set_viewport(framebuffer rect)` → the host's `frame` callback →
|
||||
`begin_frame(fb_width, fb_height, viewport)` → `draw(plot)` (or `draw(figure)`) →
|
||||
`[screenshot]` → `end_frame()` → `glfwSwapBuffers()`.
|
||||
|
||||
Pointer translation: `on_cursor_pos()` multiplies the GLFW window coordinates by
|
||||
`fb_scale_x_`/`fb_scale_y_` (framebuffer ÷ window) and synthesises `dx`/`dy` itself (GLFW
|
||||
reports no delta); `down` comes from `glfwGetMouseButton`, and the modifier mask is read
|
||||
from the key state, because GLFW's pointer and scroll callbacks carry no mask. Wheel
|
||||
notches are forwarded as `WheelEvent`, and keys as `KeyEvent`.
|
||||
|
||||
Figure mode: `host.set_figure(&figure)` makes the loop lay the figure over the framebuffer,
|
||||
create **one `Controller` per row**, route each pointer/wheel/key event to the row it lands
|
||||
in (`controller_at(x, y)`), and draw with `Renderer::draw(Figure&)`. The host also spreads
|
||||
one hover over every row (`sync_figure_crosshair`), since only the host knows which row the
|
||||
pointer is in.
|
||||
|
||||
`run()` leaves the window open on success so a producer thread can be joined before the GL
|
||||
resources go away; `close()` (or the destructor) releases the renderer, context and window
|
||||
on the calling thread. A run with `PLOT_DEMO_EXIT_AFTER_MS` set creates the window hidden.
|
||||
|
||||
## Dear ImGui — `plot::imgui`
|
||||
|
||||
`#include <imgui/imgui_plot.hpp>`, link `plot::imgui`. Build-tree only: ImGui is supplied
|
||||
by `FetchContent`, so `find_package(plot)` does not export this target.
|
||||
|
||||
`PlotWidget::draw(id, plot, size, renderer)` must be called between `ImGui::NewFrame()` and
|
||||
`ImGui::Render()`, from inside a window. It:
|
||||
|
||||
1. opens a `BeginChild` region of the requested size (no scrollbar) and forces
|
||||
`ImGuiCol_ChildBg` transparent, so the widget never paints over its own plot;
|
||||
2. intersects the child's rectangle with ImGui's clip rect, so a child the host cuts off
|
||||
does not paint over what is beside it;
|
||||
3. covers the visible region with an `InvisibleButton` and reads `IsItemHovered()`; this is
|
||||
the hover gate (false while another widget is being dragged) and the item that
|
||||
`SetItemKeyOwner(ImGuiKey_MouseWheelY)` claims the wheel with, so the plot's zoom wins
|
||||
over window scrolling;
|
||||
4. maps the child's point rectangle to framebuffer pixels through
|
||||
`io.DisplayFramebufferScale`, gives the controller that region, and renders immediately
|
||||
(`begin_frame`/`draw`/`end_frame`) into it.
|
||||
|
||||
Because ImGui reports button *levels* and the controller wants *edges*, the widget
|
||||
remembers the previous frame's buttons and synthesises `pressed`/`released`; a button
|
||||
pressed over the child keeps being delivered until every button is up, so a drag that
|
||||
leaves the region still ends. ImGui's `(-FLT_MAX, -FLT_MAX)` "no pointer" sentinel is
|
||||
treated as no position update (no movement, buttons still flow).
|
||||
|
||||
Coordinate assumption: the points→pixels mapping is the whole framebuffer's only when the
|
||||
host's GL viewport is the whole framebuffer at origin `(0,0)` and the bound target is the
|
||||
default framebuffer. A host that renders into an FBO or a sub-viewport must place the plot
|
||||
itself with `Renderer::begin_frame()` and `plot.set_viewport()`.
|
||||
|
||||
## Qt6 — `plot::qt6`
|
||||
|
||||
`#include <qt6/qt_plot_widget.hpp>`, link `plot::qt6`. `PlotWidget` is a `QOpenGLWidget`,
|
||||
so it draws into the FBO Qt binds before `paintGL()`, and `QOpenGLWidget` needs no target
|
||||
of its own.
|
||||
|
||||
**Precondition, set before the widget exists** (a `QOpenGLWidget` is handed the
|
||||
application default `QSurfaceFormat` and cannot renegotiate):
|
||||
|
||||
```cpp
|
||||
QSurfaceFormat fmt;
|
||||
fmt.setVersion(3, 3);
|
||||
fmt.setProfile(QSurfaceFormat::CoreProfile);
|
||||
fmt.setSwapBehavior(QSurfaceFormat::DoubleBuffer);
|
||||
fmt.setDepthBufferSize(0);
|
||||
fmt.setStencilBufferSize(0);
|
||||
QSurfaceFormat::setDefaultFormat(fmt);
|
||||
QCoreApplication::setAttribute(Qt::AA_UseDesktopOpenGL); // before QApplication
|
||||
```
|
||||
|
||||
Lifecycle: `initializeGL()` calls `initializeOpenGLFunctions()`, `plot::gl::load()`,
|
||||
`Renderer::init()` and records the outcome in `init_status()` (a `QOpenGLWidget` virtual
|
||||
cannot return a `Status`; it does not assert). `resizeGL()` refreshes the viewport so a
|
||||
gesture arriving before the next paint maps against the new geometry. `paintGL()` sets the
|
||||
viewport on the controller, runs the frame callback, sets `ui_scale` to
|
||||
`devicePixelRatioF()`, then `begin_frame`/`draw`/`end_frame`, and records frame timings
|
||||
(`frames_painted()`, `last_frame_ms()`, `mean_frame_ms()`).
|
||||
|
||||
Input translation: Qt reports press/release edges directly, so `mousePressEvent` /
|
||||
`mouseMoveEvent` / `mouseReleaseEvent` / `mouseDoubleClickEvent` / `wheelEvent` /
|
||||
`keyPressEvent` become `PointerEvent`/`WheelEvent`/`KeyEvent`. Every position is multiplied
|
||||
by `devicePixelRatioF()`; the *anchor* a move's delta is measured from is kept in logical
|
||||
pixels, so a drag crossing onto a display with another scale factor pans by the distance
|
||||
moved rather than a cross-scale jump (`PointerAnchor`, `qt_pointer.hpp`). Qt's
|
||||
`angleDelta()` is in eighths of a degree (120 per notch) and is converted; a wheel with no
|
||||
vertical component is refused before it reaches the controller.
|
||||
|
||||
Include-order note: this header includes `plot/gl/api.hpp` before `<QOpenGLWidget>`,
|
||||
because Qt reaches `<GL/gl.h>` whose macros would rewrite `plot::gl`'s enumerators. A
|
||||
translation unit using the widget must keep that order and must not name a `plot::gl::GL_*`
|
||||
enumerator after a Qt GL header.
|
||||
|
||||
## GTK4 — `plot::gtk4`
|
||||
|
||||
`#include <gtk4/gtk_plot_area.hpp>`, link `plot::gtk4`. GTK cannot have a GObject subclass
|
||||
declared around a C++ class (and `GtkGLArea`'s data is private), so this adapter is a
|
||||
factory, not a subclass:
|
||||
|
||||
```cpp
|
||||
plot::gtk4::PlotArea* area = plot::gtk4::PlotArea::create(plot);
|
||||
GtkWidget* widget = area->widget(); // put it in your container
|
||||
```
|
||||
|
||||
`create()` builds a `gtk_gl_area_new()`, registers the signals and controllers that drive
|
||||
it, and returns the wrapper — which GTK owns and frees with the widget; never delete it.
|
||||
|
||||
Signals: `::realize` makes the context current, checks `gtk_gl_area_get_error()`, loads the
|
||||
entry points and initialises the renderer (recording the outcome in `init_status()`);
|
||||
`::unrealize` releases the renderer's GL objects while the context can still be made
|
||||
current; `::resize` derives the device-pixel mapping from the widget's logical allocation
|
||||
and the device size GTK reports, and refreshes the viewport; `::render` sets the viewport on
|
||||
the controller, runs the frame callback, sets `ui_scale` to the mapping's factor, and draws.
|
||||
|
||||
Input translation uses GTK's gesture recognisers: `GtkGestureDrag` supplies motion,
|
||||
`GtkGestureClick` supplies the press/release edges, `GtkEventControllerMotion` supplies
|
||||
hover (forwarded with `down == false`, so a drag is not applied twice), and
|
||||
`GtkEventControllerScroll` (vertical) and `GtkEventControllerKey` supply the wheel and the
|
||||
`+`/`-`/`f` keys. GTK emits `drag-begin` before the click's `pressed`, so the adapter opens
|
||||
its gesture from the first press and closes it from the first release. The area queues a
|
||||
render after every input event that can change the picture (not on hover).
|
||||
|
||||
A `GtkGLArea` renders into its own FBO, so a screenshot of the *plot* is a readback of that
|
||||
FBO, not of the window (GTK's chrome lives outside it) — see the demo's `dump_area_ppm`.
|
||||
@@ -0,0 +1,171 @@
|
||||
# Getting started
|
||||
|
||||
`plot` is a C++20 library for real-time, high-sample-rate time-series plots drawn with
|
||||
OpenGL 3.3 core. The core library — the CMake target `plot::core` — has no third-party
|
||||
dependency at all; the host adapters for GLFW, Dear ImGui, Qt6 and GTK4 are separate,
|
||||
opt-in targets on top of it (see [embedding.md](embedding.md)).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A C++20 compiler.
|
||||
- CMake **3.24 or newer**. `cmake/plot_options.cmake` fails the configure with a
|
||||
`FATAL_ERROR` below that, and the presets in `CMakePresets.json` declare
|
||||
`cmakeMinimumRequired` 3.24.
|
||||
- A driver exposing an **OpenGL 3.3 core** profile. `plot::gl::load()` resolves only the
|
||||
3.3 entry points the library actually calls; anything newer (persistent buffer mapping,
|
||||
sync objects) is optional, probed through `plot::gl::caps()` and only used when the
|
||||
driver offers it.
|
||||
- To build the **test suite** you also need EGL: `tests/CMakeLists.txt` fails the configure
|
||||
without it, because the GL tests create a real headless EGL context. The library itself
|
||||
does not need EGL.
|
||||
- Each adapter needs its own toolkit (GLFW, Dear ImGui downloaded at configure time, Qt6 or
|
||||
GTK4) — see [building-and-testing.md](building-and-testing.md) for the options.
|
||||
|
||||
## Build and install
|
||||
|
||||
The presets live in `CMakePresets.json`:
|
||||
|
||||
```sh
|
||||
cmake --preset linux-release # or linux-debug, linux-asan, linux-coverage
|
||||
cmake --build build/release
|
||||
cmake --install build/release --prefix /where/you/want
|
||||
```
|
||||
|
||||
Install is on by default (`PLOT_INSTALL`, `ON`). It installs the `plot_core` archive, the
|
||||
public headers under `include/plot/`, and a CMake package config under
|
||||
`lib/cmake/plot/`. Adapters join the install/export set only when their toolkit can be
|
||||
re-found on a consumer's machine; the configure log records which ones were exported and
|
||||
why.
|
||||
|
||||
## Consuming it
|
||||
|
||||
```cmake
|
||||
find_package(plot 0.1 REQUIRED)
|
||||
target_link_libraries(my_app PRIVATE plot::core)
|
||||
```
|
||||
|
||||
`plot::core` is the alias of the `plot_core` target (`EXPORT_NAME core`, so the installed
|
||||
name matches the build-tree name). It carries the C++20 requirement and the include
|
||||
directory as its public interface and nothing else. The exported adapters are named
|
||||
`plot::glfw`, `plot::qt6`, `plot::gtk4` and (when built) `plot::font_ttf`;
|
||||
`plot::imgui` is a build-tree target only, because ImGui is supplied by `FetchContent` and
|
||||
there is no ImGui package for an installed config file to point at.
|
||||
|
||||
Every public component is reachable through the umbrella header:
|
||||
|
||||
```cpp
|
||||
#include <plot/plot.hpp> // core, math, gl, data, render, plot, input — in layer order
|
||||
```
|
||||
|
||||
Adapters are deliberately **not** in the umbrella: their headers are reached as
|
||||
`<glfw/glfw_host.hpp>`, `<imgui/imgui_plot.hpp>`, `<qt6/qt_plot_widget.hpp>` and
|
||||
`<gtk4/gtk_plot_area.hpp>`, so including the umbrella never drags a toolkit in.
|
||||
|
||||
## A minimal GLFW program
|
||||
|
||||
The program below opens a window, creates a 3.3 core context, loads the GL entry points,
|
||||
and draws one trace. It uses the GLFW toolkit for the window and `plot::core` directly —
|
||||
it does not use the shipped `plot::glfw::Host` adapter, so it also shows the pieces the
|
||||
adapter hides (window, context, loader, renderer, controller, frame loop).
|
||||
|
||||
It was compiled and linked as part of writing this page:
|
||||
|
||||
```sh
|
||||
g++ -std=c++20 -I include main.cpp build/debug/libplot_core.a \
|
||||
$(pkg-config --libs glfw3) -ldl -lpthread -o plot_quickstart
|
||||
```
|
||||
|
||||
```cpp
|
||||
// A minimal plot: one window, one axis pair, one trace.
|
||||
#include <plot/plot.hpp> // the whole public surface, in layer order
|
||||
#include <plot/gl/loader.hpp>
|
||||
|
||||
#define GLFW_INCLUDE_NONE // keep GLFW from including <GL/gl.h> and its macros
|
||||
#include <GLFW/glfw3.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
// GLFW hands back `void (*)(void)`; plot::gl::ProcLoader wants `void*`.
|
||||
void* resolve_gl(const char* name) {
|
||||
GLFWglproc p = glfwGetProcAddress(name);
|
||||
void* out = nullptr;
|
||||
static_assert(sizeof(out) == sizeof(p));
|
||||
std::memcpy(&out, &p, sizeof(out));
|
||||
return out;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
if (glfwInit() != GLFW_TRUE) return 1;
|
||||
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
|
||||
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
|
||||
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
|
||||
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE);
|
||||
GLFWwindow* window = glfwCreateWindow(800, 480, "plot quick-start", nullptr, nullptr);
|
||||
if (window == nullptr) { glfwTerminate(); return 1; }
|
||||
glfwMakeContextCurrent(window);
|
||||
glfwSwapInterval(1);
|
||||
|
||||
plot::Status st;
|
||||
if (!plot::gl::load(&resolve_gl)) { std::fprintf(stderr, "gl::load failed\n"); return 1; }
|
||||
plot::Renderer renderer;
|
||||
if (!renderer.init(&st)) { std::fprintf(stderr, "init failed: %s\n", st.message); return 1; }
|
||||
|
||||
// One trace: 512 samples of a sine, x = sample index.
|
||||
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);
|
||||
|
||||
plot::Plot plot;
|
||||
const plot::u32 x_axis = plot.add_x_axis({"x", {0.0, 511.0}, true});
|
||||
const plot::u32 y_axis =
|
||||
plot.add_y_axis({"signal", {-1.0, 1.0}, true, plot::AxisPlace::Left});
|
||||
plot.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, x_axis, y_axis, "sine"});
|
||||
|
||||
plot::input::Controller controller(plot);
|
||||
|
||||
while (glfwWindowShouldClose(window) == GLFW_FALSE) {
|
||||
glfwPollEvents();
|
||||
int fb_w = 0, fb_h = 0;
|
||||
glfwGetFramebufferSize(window, &fb_w, &fb_h);
|
||||
if (fb_w == 0 || fb_h == 0) continue;
|
||||
const plot::Rect viewport{0.0f, 0.0f, static_cast<plot::f32>(fb_w),
|
||||
static_cast<plot::f32>(fb_h)};
|
||||
controller.set_viewport(viewport);
|
||||
renderer.style().ui_scale = 1.0f; // device pixels per logical unit
|
||||
renderer.begin_frame(static_cast<plot::u32>(fb_w), static_cast<plot::u32>(fb_h), viewport);
|
||||
renderer.draw(plot);
|
||||
renderer.end_frame();
|
||||
glfwSwapBuffers(window);
|
||||
}
|
||||
|
||||
glfwDestroyWindow(window);
|
||||
glfwTerminate();
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
Things worth noticing, because they are the embedding contract in miniature:
|
||||
|
||||
- `plot::gl::load()` must run on the thread that owns the current context, and
|
||||
`Renderer::init()` returns `false` with a `Status` when the load failed or the driver
|
||||
refused a shader/buffer. The renderer never creates, binds or resizes a framebuffer.
|
||||
- `begin_frame(fb_w, fb_h, viewport_px)` takes the framebuffer size (for the projection)
|
||||
and the top-left-origin rectangle the plot occupies; `draw()` fills exactly that region;
|
||||
`end_frame()` restores the GL state it changed.
|
||||
- The `Plot` is pure CPU state. When you draw it through a `Renderer`, set colours and
|
||||
metrics on `renderer.style()`, not on the plot — `draw()` copies the style into the plot
|
||||
and hands it the `ui_scale` before laying out.
|
||||
- `ui_scale` is device pixels per logical unit. Here it is `1.0` because the program uses
|
||||
the framebuffer 1:1; a HiDPI host passes the framebuffer/window ratio instead (see
|
||||
[embedding.md](embedding.md)).
|
||||
|
||||
Next: [architecture.md](architecture.md) for how the pieces fit, [embedding.md](embedding.md)
|
||||
to put a plot into a real host, and [data-and-performance.md](data-and-performance.md) for
|
||||
feeding it a live stream.
|
||||
@@ -0,0 +1,209 @@
|
||||
# Interaction
|
||||
|
||||
Everything in this page is driven by `input::Controller` (`include/plot/input/controller.hpp`,
|
||||
`src/input/controller.cpp`), which turns host events into gestures knowing nothing about Qt,
|
||||
GTK, GLFW or ImGui. It is pure CPU and needs no GL context and no font — everything it reads
|
||||
comes from the `Plot`'s layout, ranges and transforms. It owns no viewport of its own:
|
||||
`Controller::set_viewport(Rect)` forwards to `Plot::set_viewport()` and refreshes the layout,
|
||||
so the two cannot disagree.
|
||||
|
||||
The plot's own state — cursors, regions, the measurement, the legend, the crosshair — is
|
||||
owned by `Plot`; the controller only moves it and publishes what is hot.
|
||||
|
||||
## Cursors
|
||||
|
||||
A cursor is a draggable marker at a data coordinate (`Cursor`, `plot/cursor.hpp`). There are
|
||||
two orientations, one type:
|
||||
|
||||
| orientation | created by | live coordinate | live snap | dead fields |
|
||||
|---|---|---|---|---|
|
||||
| `CursorOrientation::Vertical` | `Plot::add_cursor(f64 x, Color)` | `x` on `axis_x` | `SnapMode` | `y`, `h_snap`, `axis_y` |
|
||||
| `CursorOrientation::Horizontal` | `Plot::add_h_ruler(f64 y, Color)` | `y` on `axis_y` | `HRulerSnap` | `x`, `snap`, `axis_x` |
|
||||
|
||||
A vertical cursor is a marker at `x` spanning the axes' **height**; a horizontal ruler is at
|
||||
`y` spanning their **width**. Dead fields are *ignored*, not honoured: `set_cursor_y()` on a
|
||||
vertical cursor and `set_cursor_x()` on a ruler change nothing, and neither does the other
|
||||
orientation's snap setter.
|
||||
|
||||
Snaps. `SnapMode::NearestSample` pulls a marker onto the nearest sample of the first visible
|
||||
trace on its x axis; `SnapMode::Free` leaves it. `HRulerSnap::NearestTick` pulls a ruler onto
|
||||
the nearest tick of its y axis' tick set (the *last* `layout()`'s set — ticks are generated
|
||||
there and nowhere else); `HRulerSnap::Free` leaves it.
|
||||
|
||||
Grab radius. A pointer within **7 logical pixels** (`kCursorHitRadiusLogical`) of a cursor's
|
||||
line grabs it. The nearest of a vertical and a horizontal cursor wins; neither orientation
|
||||
widens the other's reach. The distance is logical, converted through `Style::device()` at the
|
||||
plot's scale, so the aim is the same physical distance on any display.
|
||||
|
||||
Snap on drop. `set_cursor_x()`/`set_cursor_y()` take a `CursorPlacement`: `Snapped` (the
|
||||
default) applies the cursor's snap rule immediately — the programmatic path; `Free` stores
|
||||
the value verbatim and leaves the snap to `Plot::snap_cursor()`. The controller uses `Free`
|
||||
while dragging, so the marker tracks the pointer under the hand and the snap is committed
|
||||
once, on release. A dragged ruler is additionally clamped to its own y axis' range, because a
|
||||
pointer past the top or bottom of the axes has no data under it.
|
||||
|
||||
## Regions
|
||||
|
||||
A region is a shaded x interval with exact statistics (`Region`, `plot/region.hpp`). Create
|
||||
with `Plot::add_region(const Region&)`; `Region::snap` is `RegionSnap::NearestSample` or
|
||||
`Free`. The bounds are **ordered**: `set_region(id, x0, x1)` stores min/max, so a drag that
|
||||
crosses one bound over the other **swaps** them rather than inverting the region. A pair of
|
||||
bounds that is not both finite is refused (nothing changes).
|
||||
|
||||
Three grab zones (`RegionGrab`): `Left` (the bound at `x0`), `Right` (the bound at `x1`) and
|
||||
`Body` (the interior). The controller resolves them nearest-first against the geometry
|
||||
`Plot::layout()` measured — **a bound within the grab radius beats the body**, and the nearer
|
||||
of the two bounds wins; with no bound in range, the topmost region whose interior holds the
|
||||
pointer is the body. That geometry is the same `RegionLayout` the renderer paints, so what is
|
||||
drawn and what can be grabbed cannot drift apart.
|
||||
|
||||
The bound band is the same 7-logical-pixel radius, cut back so a narrow region keeps a
|
||||
middle a body drag can reach (`min(radius, (width − 2)/2)`, with a minimum interior of
|
||||
**2 logical pixels**, `kMinRegionBodyLogical`). A region narrower than **3 logical pixels**
|
||||
(`kMinRegionEdgeGrabLogical`) is grabbed by its body instead, widened to that same minimum —
|
||||
otherwise its two bounds would be one target and it could never be moved again. `Body` drag
|
||||
translates both bounds by one delta, preserving the width exactly; a bound drag moves one end
|
||||
and holds the other. A snapping region is put on the sample grid once, on release
|
||||
(`snap_region()`), not on every motion. `remove_region(id)` drops the region and shifts the
|
||||
ids above it down.
|
||||
|
||||
## The measurement band
|
||||
|
||||
`Plot::add_measure(cursor_a, cursor_b, trace = 0)` measures the interval between two cursors
|
||||
over the series of `trace`. The renderer draws the shaded band the readout belongs to from
|
||||
`Plot::measure_span(id)` (which reports the cursor pair), and the statistics come from
|
||||
`Plot::measurement(id)`: `dx`, `dy`, `slope`, `mean`, `rms`, `ymin`, `ymax` and the
|
||||
left-Riemann `integral`. The band's edges *are* those cursors, so it is resized by dragging
|
||||
either of them. A measurement is re-evaluated only when a cursor moved, when it was added, or
|
||||
when the series underneath changed — never once per frame; `const` because recomputing fills
|
||||
a cache.
|
||||
|
||||
## The legend
|
||||
|
||||
The legend is on by default (`Legend::visible = true`) and free until a trace is named: with
|
||||
no named visible trace — or no text backend to measure a name with — `Plot::layout()` produces
|
||||
an empty layout and nothing is drawn. A trace earns an entry by being given a `name`
|
||||
(`TraceDesc::name`/`Trace::name`). Configure it with `Plot::set_legend(const Legend&)`;
|
||||
`Legend::place` anchors the panel to a corner of the axes (`TopLeft`, `TopRight`,
|
||||
`BottomLeft`, `BottomRight`) and `Legend::margin` is both the anchor's inset and the budget
|
||||
the panel has to fit in, so a panel that does not fit drops entries and ends with `...` rather
|
||||
than overhanging. See [styling.md](styling.md) for the knobs and their defaults.
|
||||
|
||||
## The crosshair
|
||||
|
||||
The crosshair is a **hover** instrument, not a cursor: it follows the pointer and exists only
|
||||
while the pointer is over the data area (`Crosshair`, `plot/crosshair.hpp`). There is at most
|
||||
one per plot.
|
||||
|
||||
- `Plot::set_crosshair(f64 x, bool active)` places a **vertical-only** crosshair: the column
|
||||
of `x`, a marker on every visible trace at that x, and the readout panel beside it. This is
|
||||
the form for a caller that has a coordinate and not a pointer.
|
||||
- `Plot::set_crosshair(f64 x, f64 y, bool active)` adds the **horizontal** line at `y`. This is
|
||||
the form the controller uses, because a pointer over the axes has both coordinates.
|
||||
- `Plot::clear_crosshair()` hides it, leaving it where it was. The controller calls this when
|
||||
the pointer leaves the axes; a press, wheel or key that moves the data under the pointer
|
||||
*re-anchors* the crosshair rather than clearing it.
|
||||
- `set_crosshair_readout(bool)` switches the panel off — or back on — without moving the
|
||||
crosshair. `set_crosshair_style(const CrosshairStyle&)` overrides this plot's stroke and
|
||||
dash.
|
||||
|
||||
In a `Figure`, the crosshair is applied to **every row** (one shared `x`): each row's readout
|
||||
lists its own traces at that instant. `Figure::set_crosshair(x, active)` is vertical-only
|
||||
across the figure, and `Figure::set_crosshair(x, hovered_row, y_in_row, active)` adds the
|
||||
horizontal line **only in the row the pointer is in** — a figure's rows share x and not y, so
|
||||
a figure-wide horizontal line would be a value that means something different in each row.
|
||||
|
||||
Readout values are **interpolated**, not interval statistics (see
|
||||
[data-and-performance.md](data-and-performance.md)); a row whose trace has no sample at the
|
||||
crosshair's x is listed with the em dash `"—"` and no marker, rather than a fabricated value.
|
||||
|
||||
## The gesture table (exactly as implemented)
|
||||
|
||||
A press opens the first of these that applies, in this order (from `Controller::begin_drag`
|
||||
and `Controller::begin_handle_drag`):
|
||||
|
||||
| gesture | drag mode | effect |
|
||||
|---|---|---|
|
||||
| **shift + left drag** | `BoxZoom` | both ranges from the selection rect, applied on release |
|
||||
| **middle drag on the x label strip** | `AxisZoomX` | x-only zoom |
|
||||
| **middle drag on the y label strip** | `AxisZoomY` | y-only zoom |
|
||||
| middle drag anywhere else | — | nothing |
|
||||
| **left or right drag on a handle** | `MoveCursor` / `MoveRegion` / `ResizeRegionLeft` / `ResizeRegionRight` | move the grabbed handle |
|
||||
| **left or right drag elsewhere** | `Pan` | translate both ranges |
|
||||
| **wheel** | — | zoom about the pointer (shift: x only, ctrl: y only; neither or both: both axes) |
|
||||
| **`+` / `-`** | — | zoom about the plot centre |
|
||||
| **`f`** (or `F`) | — | `auto_fit_x()` / `auto_fit_y()` every axis |
|
||||
|
||||
Precedence for a press that is not shift+left and not middle: **cursor marker → region bound
|
||||
→ region body → pan**. Shift is checked first of all, so a selection box is what shift+left
|
||||
means even when it starts over a handle. The right button **pans and grabs** exactly as the
|
||||
left does — both are buttons a host drives drags with, so neither is singled out for handles.
|
||||
`Ctrl` is not needed for a cursor move and does not change what a press on one does.
|
||||
|
||||
Values behind the gestures:
|
||||
|
||||
- one wheel notch (or one `+`/`-` press) changes the span by `exp(-0.15)` (~14%,
|
||||
`kZoomPerNotch`); positive = zoom in;
|
||||
- a middle-drag axis zoom is one decade per **200 logical pixels**
|
||||
(`kZoomPerPixel = ln10 / 200`), with the wheel's sign convention (dragging down zooms in);
|
||||
- a box-zoom selection under **4 logical pixels** on either side (`kMinBoxLogical`) is a
|
||||
click and leaves the ranges alone.
|
||||
|
||||
Events are sanitised before they act: a non-finite coordinate or delta becomes the last known
|
||||
position and no movement, but the buttons still flow, so a drag whose release arrives with a
|
||||
garbage position still ends. A wheel amount that is not a number is refused outright (a
|
||||
zeroed notch would still run the range setters, which clear auto scale). A gesture that sets
|
||||
a range also clears that axis' `auto_scale` — one place, in `Plot`'s range setters.
|
||||
|
||||
What the pointer is on is published to the plot (`Plot::set_hot_handles()`) after every event
|
||||
that can move the geometry under the pointer, so the renderer draws hovered/grab highlights
|
||||
without knowing what a pointer is. `hovered_cursor()`, `grabbed_cursor()`, `hovered_region()`,
|
||||
`grabbed_region()` and `region_grab(u32)` expose the same answers to a host (a resize cursor, a
|
||||
tooltip, a status line).
|
||||
|
||||
## Live follow
|
||||
|
||||
An x axis has a follow state, `XFollow::Off` (the default) or `XFollow::Live`
|
||||
(`Plot::set_x_follow(axis, f)`). `Live` makes the visible window a view from the live edge:
|
||||
every `layout()` places it at `[newest − offset − span, newest − offset]`, where `newest` is
|
||||
the newest x any visible streaming trace bound to the axis holds (`Plot::x_live_edge()`).
|
||||
`Off` means the range is exactly what a caller set or an auto-fit produced.
|
||||
|
||||
**`set_x_range()` on a live axis is not an absolute window.** A range handed to
|
||||
`Plot::set_x_range(axis, r)` while the axis is `Live` *and* has a live edge is read as the
|
||||
**span** the window should have and the **offset** it implies — `offset = newest − r.max`,
|
||||
clamped to what the traces still retain — and the visible window follows from those at the
|
||||
live edge of the moment. `XFollow::Off` stores the range verbatim. The point of the
|
||||
reinterpretation is that every gesture (pan, wheel, box zoom, axis zoom) reaches the x range
|
||||
through this one setter, so they all keep following for free.
|
||||
|
||||
The offset is clamped to `[0, retained span]` (`Plot::set_x_offset()`, read back with
|
||||
`Plot::x_offset()`): the upper limit is what the traces still hold, so the window's right
|
||||
edge never goes behind the oldest retained sample (and the view is left showing the dropped
|
||||
data rather than inventing samples that are gone); the lower limit is why dragging toward the
|
||||
live edge stops instead of turning the window inside out. A non-finite offset is refused. The
|
||||
clamp is applied again by `layout()` every frame.
|
||||
|
||||
Consequences while an axis is `Live` *and* has a live edge:
|
||||
|
||||
- `auto_scale` is **inert** on that axis: `layout()` does not fit it, because a fit would
|
||||
overwrite the span (the user's zoom) on every frame. The follow state wins; `auto_fit_x()`
|
||||
is how such an axis is framed (it fits, sets the span to the retained window's, and returns
|
||||
the offset to 0 without turning following off).
|
||||
- Nothing but `set_x_follow()` disarms following. No gesture and no `set_x_range()` reaches
|
||||
the follow state.
|
||||
- **Zoom is relative (multiplicative)**: the wheel multiplies the span, so notches compound,
|
||||
and the anchor is kept still by adjusting the offset. **Pan** shifts the offset by the
|
||||
drag's data delta and leaves the span alone, so a user can walk back through the retained
|
||||
window and stops at the live edge.
|
||||
|
||||
Arming `Live` on an axis with a live edge places its window at the live edge at once, from the
|
||||
span it already had. Turning `Live` off leaves the axis where it is: the last live window
|
||||
becomes an ordinary stored range.
|
||||
|
||||
**Figures.** In a `Figure`, the follow state belongs to the link **group**, not the row: a
|
||||
group pushes one window to many rows, so the state has to be the group's or the same range
|
||||
would mean a different window in each member. Use `Figure::add_x_link()`, `link_x(row, group)`,
|
||||
`set_x_link_range(group, r)` and `set_x_link_follow(group, XFollow::Live)`; a group derives
|
||||
its window once per frame from the newest x across all its members' traces. `link_x()` refuses
|
||||
a member whose own axis is `Live`.
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
# Styling
|
||||
|
||||
Every colour and metric the renderer draws with lives in `render::Style`
|
||||
(`include/plot/render/style.hpp`). A caller sets fields on `Renderer::style()`; the next frame
|
||||
picks them up, because nothing here is baked into a shader.
|
||||
|
||||
Set colours and metrics on the **renderer**, not on the plot: `Renderer::draw()` copies its
|
||||
`Style` into the plot and hands it the `ui_scale` *before* it lays out, so the strips, tick
|
||||
densities and strokes the plot measures with are the values the renderer draws with. A plot
|
||||
drawn without a renderer lays out with whatever `Plot::set_style()` was given.
|
||||
|
||||
## The unit rule: logical versus device
|
||||
|
||||
A metric a user perceives as a **size** — a font, a stroke width, a padding, a marker, a
|
||||
swatch, the minimum spacing between ticks — is written in **logical** units, so a 12 px label
|
||||
is the same physical size on a 1x and a 2x panel. The host states its display with `ui_scale`
|
||||
(device pixels per logical unit); every consumer converts exactly once, through `device()`:
|
||||
|
||||
```cpp
|
||||
f32 device(f32 logical) const noexcept {
|
||||
return ui_scale > 0.0f ? logical * ui_scale : logical; // non-positive scale converts 1:1
|
||||
}
|
||||
```
|
||||
|
||||
`Plot::layout()` uses it to size the label strips, title strips and legend chrome; the renderer
|
||||
uses it to convert widths and font px (the font is reset at `device(axis_font_px)`).
|
||||
|
||||
The one exception is `dense_threshold_spp`, which is a **density** (samples per *rasterised*
|
||||
pixel), compared against a ratio computed from the framebuffer's own width. Scaling it would
|
||||
make the chosen LOD level depend on the display rather than on the data, so it is used raw.
|
||||
|
||||
## `Style` fields
|
||||
|
||||
Colours (neither logical nor device — nothing to scale):
|
||||
|
||||
| field | default | meaning |
|
||||
|---|---|---|
|
||||
| `background` | `0x101418` | frame clear colour and legend/readout panel base |
|
||||
| `grid_major` | `0x2A3038` | major (labelled-tick) grid lines |
|
||||
| `grid_minor` | `0x1C2126` | minor (subdivision) grid lines |
|
||||
| `axis_line` | `0x8A94A6` | axis lines |
|
||||
| `axis_text` | `0xC8D0DC` | tick labels and axis titles |
|
||||
| `cursor` | `0xFFB020` | cursor markers; also the hovered/grab highlight |
|
||||
| `measure_fill` | `0xFFB020` at α 0.15 | the measurement band |
|
||||
| `crosshair` | `0xE6EBF2` | crosshair lines and the readout panel's border (hue-neutral by design; trace markers keep each trace's own colour) |
|
||||
|
||||
The scale:
|
||||
|
||||
| field | default | unit |
|
||||
|---|---|---|
|
||||
| `ui_scale` | `1.0` | device pixels per logical unit; supplied by the host |
|
||||
|
||||
Logical metrics (scaled by `device()`):
|
||||
|
||||
| field | default | meaning |
|
||||
|---|---|---|
|
||||
| `axis_font_px` | `12.0` | logical em the axis text is drawn at |
|
||||
| `grid_label_pad` | `4.0` | gap between a tick label and the axes, and the inset inside a title strip |
|
||||
| `grid_major_width` | `1.0` | width of a major grid line |
|
||||
| `grid_minor_width` | `1.0` | width of a minor grid line |
|
||||
| `grid_major_visible` | `true` | stage the major grid at all |
|
||||
| `grid_minor_visible` | `true` | stage the minor grid at all |
|
||||
| `line_width_px` | `1.5` | width of the strokes framing the data (axis lines, cursor markers) |
|
||||
| `handle_width_px` | `5.0` | width of a highlighted draggable handle (a hovered/grabbed cursor tab, a region's hot bound) |
|
||||
| `tick_min_px` | `60.0` | minimum spacing between major ticks |
|
||||
|
||||
Crosshair stroke and dash (logical):
|
||||
|
||||
| field | default | meaning |
|
||||
|---|---|---|
|
||||
| `crosshair_width` | `0.75` | the crosshair's stroke width (deliberately thinner than a trace's `1.5`) |
|
||||
| `crosshair_dash_on` | `4.0` | ink length of the dash |
|
||||
| `crosshair_dash_off` | `4.0` | gap length of the dash; **`0` means solid** |
|
||||
|
||||
Device metric (never scaled):
|
||||
|
||||
| field | default | meaning |
|
||||
|---|---|---|
|
||||
| `dense_threshold_spp` | `1.5` | samples per device pixel at or above which a trace is gathered from the LOD pyramid instead of from raw samples |
|
||||
|
||||
## The grid
|
||||
|
||||
The grid's **positions** come from the tick set alone: `Grid::build()` emits one `GridLine`
|
||||
per tick, spanning the perpendicular axis' full range, tagged `major` (a labelled tick) or
|
||||
not. `Style` decides only how each class is drawn — its width and visibility — and the two
|
||||
colours above.
|
||||
|
||||
A class switched off stages **no geometry at all**: the renderer skips it before the first
|
||||
segment is staged, so a dense plot's minor grid costs nothing rather than being drawn
|
||||
transparent. `Grid` therefore carries both classes' geometry whatever the style says; a
|
||||
caller that draws the grid itself applies the same two switches. `Grid::vertical` and
|
||||
`Grid::horizontal` (both `true`) control which directions emit lines at all.
|
||||
|
||||
## The crosshair's dash
|
||||
|
||||
`crosshair_dash_on`/`crosshair_dash_off` are the fragment shader's discard pattern, so no
|
||||
geometry is added. `dash_off == 0` draws a solid line — the value every other line in the
|
||||
library uses, so the one pipeline serves them unchanged. A single plot may override all three
|
||||
knobs without touching the global `Style` through `CrosshairStyle` (below).
|
||||
|
||||
## `Legend`
|
||||
|
||||
`Legend` (`include/plot/legend.hpp`), set with `Plot::set_legend()`. All sizes are **logical**,
|
||||
converted through `Style::device()` when `Plot::layout()` measures the panel.
|
||||
|
||||
| field | default | meaning |
|
||||
|---|---|---|
|
||||
| `visible` | `true` | draw the legend at all (free when nothing is named) |
|
||||
| `place` | `LegendPlace::TopRight` | corner of `axes` the panel is anchored to |
|
||||
| `frame` | `true` | paint the panel (background at partial alpha plus a 1-px border) |
|
||||
| `pad` | `6.0` | inset from the panel edge to its first row, and the swatch↔name gutter (one knob for both) |
|
||||
| `swatch_w` | `14.0` | swatch width |
|
||||
| `swatch_h` | `8.0` | swatch height; the row height is `max(swatch_h, line height)` |
|
||||
| `row_gap` | `3.0` | blank space between rows |
|
||||
| `margin` | `8.0` | inset from the axes edge, *and* the budget the panel must fit in (so it drops entries rather than overhanging) |
|
||||
|
||||
There is no "outside the plot" placement: the strips around `axes` belong to the tick labels
|
||||
and axis titles, and a legend that reserved space would resize the plot the moment a trace was
|
||||
named. A truncated legend ends with the constant `kLegendEllipsis` (`"..."`).
|
||||
|
||||
## `CrosshairStyle`
|
||||
|
||||
A per-`Crosshair` override of the three crosshair knobs, in logical pixels
|
||||
(`include/plot/crosshair.hpp`), set with `Plot::set_crosshair_style()`. Every field defaults to
|
||||
`-1.0`, which means "not overridden": the field falls back to the matching `Style::crosshair_*`
|
||||
metric, so a default-constructed `CrosshairStyle` is exactly the global style.
|
||||
|
||||
| field | default | meaning |
|
||||
|---|---|---|
|
||||
| `width` | `-1.0` | logical stroke width; `< 0` takes `Style::crosshair_width` |
|
||||
| `dash_on` | `-1.0` | logical ink length; `< 0` takes `Style::crosshair_dash_on` |
|
||||
| `dash_off` | `-1.0` | logical gap; `< 0` takes `Style::crosshair_dash_off`, and `0` (once resolved) means solid |
|
||||
|
||||
## Traces
|
||||
|
||||
A trace's own `width` (`TraceDesc::width`, `Trace::width`) is **logical** too — a stroke like
|
||||
`line_width_px`, converted by the same `device()` call. Its default colour is `0x4FA3FF` and
|
||||
its default width `1.5`. Region fill/edge colours are the region's own (`Region::fill`
|
||||
`0xFFB020` at α 0.15, `Region::edge` `0xFFB020`); a cursor's colour is per-cursor
|
||||
(`0xFFB020` by default).
|
||||
|
||||
## Installing a text backend
|
||||
|
||||
The font a plot measures with and the renderer draws with is a `render::TextBackend`
|
||||
(`include/plot/render/text.hpp`): `measure()`, `height()`, `ascent()`, `em_px()`, `glyph()`,
|
||||
`cell_uv()`, `atlas_pixels()`, `atlas_width()`, `atlas_height()` and `revision()`.
|
||||
|
||||
- `Plot::set_text_backend(const render::TextBackend*)` sets the backend a plot lays its labels
|
||||
out with. It is **borrowed**, not owned, and may be null (then the label areas fall back to
|
||||
`PlotLayout`'s defaults and no string is measured).
|
||||
- `Renderer::set_text_backend(const render::TextBackend*)` sets the face the renderer draws
|
||||
with. It is also borrowed and must outlive the frames.
|
||||
|
||||
**Note the interaction:** `Renderer::init()` binds `render::EmbeddedMonoFont`, and every
|
||||
`Renderer::draw()` installs that binding on the plot it is given — replacing whatever a host
|
||||
set with `Plot::set_text_backend()` (a debug build names the one it dropped). So installing a
|
||||
backend on the *plot* only takes effect for a plot no renderer draws. To draw with your own
|
||||
face (say a `plot::text::TrueTypeFont` from the optional `plot::font_ttf` target), install it
|
||||
with `Renderer::set_text_backend()`; the swap takes effect on the next frame, which re-reads
|
||||
the backend's metrics at `style().device(axis_font_px)` and re-uploads its atlas when the
|
||||
backend's `revision()` moved. Passing `nullptr` restores the embedded face.
|
||||
Reference in new issue
Block a user