docs: a user guide covering getting started, architecture, embedding, data, interaction, styling and testing

This commit is contained in:
Martino Ferrari committed 2026-09-28 21:50:19 +02:00
1 parent d03f9ce51a
commit b7d0f901cc
7 files changed
+1082

No files matched your search

+68
View File
@@ -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.
+109
View File
@@ -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).
+151
View File
@@ -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.
+213
View File
@@ -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`.
+171
View File
@@ -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.
+209
View File
@@ -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
View File
@@ -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.