diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..f64bc2a --- /dev/null +++ b/docs/architecture.md @@ -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 ` cannot drag in GLFW, ImGui, Qt or GTK. The adapter headers are + reached as ``, ``, `` + and ``. +- **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. diff --git a/docs/building-and-testing.md b/docs/building-and-testing.md new file mode 100644 index 0000000..66278b6 --- /dev/null +++ b/docs/building-and-testing.md @@ -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=` — 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=` — 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=` | `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). diff --git a/docs/data-and-performance.md b/docs/data-and-performance.md new file mode 100644 index 0000000..0466e62 --- /dev/null +++ b/docs/data-and-performance.md @@ -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& 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` of y samples plus an +`x0`/`dx` grid. Construct with `ArraySeries(std::vector 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. diff --git a/docs/embedding.md b/docs/embedding.md new file mode 100644 index 0000000..4a54863 --- /dev/null +++ b/docs/embedding.md @@ -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 = ; +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=` (stop the +loop after that long) and `PLOT_DEMO_SCREENSHOT=` (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 `, 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 `, 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 `, 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 ``, +because Qt reaches `` 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 `, 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`. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..7a1189c --- /dev/null +++ b/docs/getting-started.md @@ -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 // core, math, gl, data, render, plot, input — in layer order +``` + +Adapters are deliberately **not** in the umbrella: their headers are reached as +``, ``, `` and +``, 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 // the whole public surface, in layer order +#include + +#define GLFW_INCLUDE_NONE // keep GLFW from including and its macros +#include + +#include +#include +#include +#include + +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 y(512); + for (plot::usize i = 0; i < y.size(); ++i) { + y[i] = static_cast(std::sin(0.05 * static_cast(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(fb_w), + static_cast(fb_h)}; + controller.set_viewport(viewport); + renderer.style().ui_scale = 1.0f; // device pixels per logical unit + renderer.begin_frame(static_cast(fb_w), static_cast(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. diff --git a/docs/interaction.md b/docs/interaction.md new file mode 100644 index 0000000..8385c16 --- /dev/null +++ b/docs/interaction.md @@ -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`. diff --git a/docs/styling.md b/docs/styling.md new file mode 100644 index 0000000..a26efcf --- /dev/null +++ b/docs/styling.md @@ -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.