Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f132a5ec92 | ||
|
|
1f5ce0d4a5 | ||
|
|
2fbd1de7d4 | ||
|
|
9aff06acee | ||
|
|
81792dcd05 | ||
|
|
040e40254d | ||
|
|
ed3a8ce645 | ||
|
|
059227d080 | ||
|
|
19c8d4368d | ||
|
|
b7d0f901cc |
No files matched your search
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Martino Ferrari
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,174 @@
|
||||
# plot
|
||||
|
||||
An embeddable OpenGL time-series plotting library for real-time, high-sample-rate data.
|
||||
The core (`plot::core`, C++20, OpenGL 3.3 core) has no third-party dependency at all;
|
||||
the host integrations that own a window and an event loop are separate, opt-in targets
|
||||
built on top of it.
|
||||
|
||||
## Hosts, and what a frame costs
|
||||
|
||||
| host | CMake target | source |
|
||||
|---|---|---|
|
||||
| GLFW | `plot::glfw` | `adapters/glfw/` |
|
||||
| Dear ImGui | `plot::imgui` (build-tree only) | `adapters/imgui/` |
|
||||
| Qt6 | `plot::qt6` | `adapters/qt6/` |
|
||||
| GTK4 | `plot::gtk4` | `adapters/gtk4/` |
|
||||
|
||||
Per-frame rendering cost is O(visible pixels + samples produced since the last frame),
|
||||
not O(samples): a trace denser than one sample per pixel is drawn from a min/max LOD
|
||||
pyramid rather than from raw samples. The `plot_multiplot_demo` run stages **18,651 GPU
|
||||
segments** to draw **~4.5 million visible samples** across six traces — the frame is
|
||||
bounded by the pixels it lights, not by the sample count behind them. That figure is from
|
||||
a `linux-release` build under `xvfb-run` on **llvmpipe** (Mesa's software rasteriser); no
|
||||
run touched a GPU, so read it as an upper bound and a record of the software path.
|
||||
|
||||
## Features
|
||||
|
||||
- grid with independent major/minor line classes (`Style::grid_major_visible`,
|
||||
`grid_minor_visible`);
|
||||
- x and y axes, with a vertical (bottom-to-top) y-axis title;
|
||||
- cursors in both orientations — vertical markers and horizontal rulers;
|
||||
- draggable regions and measurement bands with exact interval statistics;
|
||||
- a signal legend anchored to an axes corner;
|
||||
- a hover crosshair with a per-signal, interpolated readout;
|
||||
- a multi-plot `Figure` of stacked rows sharing one synchronised x axis;
|
||||
- live follow (`XFollow::Live`) with relative (multiplicative) zoom and mouse panning;
|
||||
- DPI-consistent sizing: metrics are logical units converted once through
|
||||
`Style::ui_scale`;
|
||||
- an anti-aliased embedded monospaced font, plus an optional runtime TrueType backend
|
||||
(`plot::font_ttf`).
|
||||
|
||||
## Build and install
|
||||
|
||||
Needs a C++20 compiler and CMake **3.24+**. Presets live in `CMakePresets.json`
|
||||
(`linux-debug`, `linux-release`, `linux-asan`, `linux-coverage`).
|
||||
|
||||
```sh
|
||||
cmake --preset linux-release
|
||||
cmake --build build/release
|
||||
cmake --install build/release --prefix /where/you/want
|
||||
```
|
||||
|
||||
Install is on by default (`PLOT_INSTALL=ON`) and installs the `plot_core` archive, the
|
||||
public headers under `include/plot/`, and a CMake package config under `lib/cmake/plot/`.
|
||||
A consumer then needs only:
|
||||
|
||||
```cmake
|
||||
find_package(plot 0.1 REQUIRED)
|
||||
target_link_libraries(my_app PRIVATE plot::core)
|
||||
```
|
||||
|
||||
## Embedding
|
||||
|
||||
A host owns the window, the GL context and the event loop; `plot` owns none of them. This
|
||||
is the whole of a minimal GLFW program — the adapter opens the window, runs the loop and
|
||||
feeds the controller, and the callback is where live data would be pushed:
|
||||
|
||||
```cpp
|
||||
#include <glfw/glfw_host.hpp> // the optional GLFW adapter
|
||||
#include <plot/plot.hpp>
|
||||
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
|
||||
int main() {
|
||||
plot::glfw::Host host;
|
||||
plot::Plot& p = host.plot();
|
||||
|
||||
std::vector<plot::f32> y(512);
|
||||
for (plot::usize i = 0; i < y.size(); ++i)
|
||||
y[i] = static_cast<plot::f32>(std::sin(0.05 * static_cast<double>(i)));
|
||||
plot::data::ArraySeries series(std::move(y), 0.0, 1.0);
|
||||
|
||||
const plot::u32 x = p.add_x_axis({"x", {0.0, 511.0}, true});
|
||||
const plot::u32 sig = p.add_y_axis({"signal", {-1.0, 1.0}, true, plot::AxisPlace::Left});
|
||||
p.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, x, sig, "sine"});
|
||||
|
||||
plot::Status st;
|
||||
return host.run({800, 480, "plot"}, [](plot::Plot&, plot::f64) {}, &st) ? 0 : 1;
|
||||
}
|
||||
```
|
||||
|
||||
It compiles and links against a built tree (see [docs/getting-started.md](docs/getting-started.md)
|
||||
for the same program written against `plot::core` directly, with no adapter):
|
||||
|
||||
```sh
|
||||
g++ -std=c++20 -I include -I adapters readme_main.cpp \
|
||||
build/debug/adapters/glfw/libplot_glfw.a build/debug/libplot_core.a \
|
||||
$(pkg-config --libs glfw3) -ldl -lpthread -o plot_readme
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
The user guide lives under `docs/`; `docs/superpowers/` is the design record, not user
|
||||
documentation.
|
||||
|
||||
- [Getting started](docs/getting-started.md) — prerequisites, build/install/consume, and a
|
||||
minimal GLFW program against `plot::core`.
|
||||
- [Architecture](docs/architecture.md) — the layers (`core`/`math` → `gl` → `data` →
|
||||
`render` → `plot` → `input`), what each owns, and the dependency rules.
|
||||
- [Embedding into a host](docs/embedding.md) — the four adapters, the `ui_scale`/DPI rule
|
||||
per host, and the headless demo invocations.
|
||||
- [Data and performance](docs/data-and-performance.md) — the sample stores, the
|
||||
single-producer/single-consumer contract, the LOD pyramid, and the measured frame times.
|
||||
- [Interaction](docs/interaction.md) — cursors, regions, the measurement band, the legend,
|
||||
the crosshair, the gesture table, and live follow.
|
||||
- [Styling](docs/styling.md) — every `Style` field with its default and unit, the grid
|
||||
classes, and installing a text backend.
|
||||
- [Building and testing](docs/building-and-testing.md) — presets, `PLOT_BUILD_*` options,
|
||||
the suite, the headless demo modes, and the coverage command.
|
||||
- [Coverage](docs/coverage.md) — the coverage method and the measured per-file table.
|
||||
|
||||
## Testing and coverage
|
||||
|
||||
```sh
|
||||
cmake --preset linux-debug
|
||||
cmake --build build/debug
|
||||
ctest --test-dir build/debug --output-on-failure # 283 cases, 0 failed
|
||||
|
||||
tools/coverage.sh # configure+build+run+capture+floor check
|
||||
```
|
||||
|
||||
The `linux-debug` suite runs **283 cases**; the `linux-coverage` preset builds **272** of
|
||||
them, because its adapters are off and 11 cases need an adapter target (8 in
|
||||
`test_imgui_widget.cpp`, 3 in `test_glfw_host.cpp`). Coverage is enforced by
|
||||
`tools/coverage.sh` against `tools/coverage_floor.py`: **80%** per file under `src/` and
|
||||
**90%** aggregate. The current measurement is an aggregate **95.3% (3284/3447 lines)** over
|
||||
23 files, every file at or above 80%; the per-file table is in
|
||||
[docs/coverage.md](docs/coverage.md), which the command regenerates.
|
||||
|
||||
## Licence and third-party provenance
|
||||
|
||||
- **This library** is **MIT licensed**: the full text is in [`LICENSE`](LICENSE), and
|
||||
every first-party file (C++, Python and shell) carries its SPDX identifier in the file
|
||||
header — `// SPDX-License-Identifier: MIT` — so the grant travels with each source file,
|
||||
not just the repository root.
|
||||
- **Adwaita Mono** (the embedded font, `render::EmbeddedMonoFont`) is under **SIL OFL
|
||||
1.1**, committed verbatim at [`licences/OFL-1.1.txt`](licences/OFL-1.1.txt). The backend
|
||||
class is named neutrally (`EmbeddedMonoFont`, not "Adwaita Mono") because OFL reserves
|
||||
the font name for modified redistributions. The baked atlas is generated at authoring
|
||||
time by `tools/fonts/gen_atlas.py` and committed as data, so `plot_core` rasterises
|
||||
nothing at build or run time.
|
||||
- **stb_truetype v1.26** (public domain, Sean Barrett / RAD Game Tools) is vendored at
|
||||
`third_party/stb_truetype.h` and compiled **only** by the optional `plot_font_ttf`
|
||||
target; its include directory is `PRIVATE`, so no consumer and no other target can reach
|
||||
it. `PLOT_BUILD_TTF_FONT` defaults on exactly when the vendored header is present.
|
||||
- **`plot_core` is third-party-free.** It links only `${CMAKE_DL_LIBS}`, and no public
|
||||
header under `include/plot/` includes a third-party header. The header self-containment
|
||||
and umbrella checks in `tests/CMakeLists.txt` fail the build if either rule breaks.
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Timings are llvmpipe under Xvfb** (`tools/coverage.sh`, the demo summaries). No
|
||||
measurement here used a GPU; there are no hardware numbers.
|
||||
- **macOS and Windows are untested.** Development and CI are Linux/EGL + X11 only.
|
||||
- **Fractional-scale compositing and touchpad scroll calibration are unverified** — the
|
||||
`ui_scale` sources are correct per host, but no run exercised a real fractional DPI or a
|
||||
calibrated scroll device.
|
||||
- **Untrusted font files are out of scope** for the runtime `plot::font_ttf` backend:
|
||||
stb_truetype does no range checking, so `from_file()` must only be handed files the host
|
||||
trusts.
|
||||
- **`render` ↔ `plot` is not a strict include chain.** `Renderer` includes `plot/plot.hpp`
|
||||
because drawing *is* being handed a `Plot`; the `plot` layer names only `render`'s value
|
||||
types (`Style`, `TextBackend`), never `Renderer`. The cycle and its direction are
|
||||
documented in [docs/architecture.md](docs/architecture.md).
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include <glfw/glfw_host.hpp>
|
||||
|
||||
// Before GLFW's header, so it does not include <GL/gl.h>: that header's macros
|
||||
@@ -207,12 +210,13 @@ bool Host::run(const RunConfig& cfg, const FrameFn& frame, Status* st) {
|
||||
// that hands each row its rect (and so its own `Plot` its viewport). The single
|
||||
// controller is untouched -- each row has its own, see controller_at().
|
||||
figure_->set_viewport(viewport);
|
||||
figure_->layout();
|
||||
// Every row's controller gets that row's rect, so a gesture -- routed by
|
||||
// controller_at() or injected by a scripted run -- is hit-tested against the
|
||||
// pointer_target() or injected by a scripted run -- is hit-tested against the
|
||||
// geometry the last frame drew, and the two readers of a row's geometry cannot
|
||||
// disagree about it.
|
||||
const std::vector<Rect>& rows = figure_->layout().rows; // the one layout of this frame
|
||||
// disagree about it. The rows come off the one recomputing layout of this frame --
|
||||
// calling `layout()` again here, or in `controller_at()`, would lay every row out a
|
||||
// second and a third time for the same frame.
|
||||
const std::vector<Rect>& rows = figure_->layout().rows;
|
||||
for (u32 i = 0; i < row_controllers_.size(); ++i) {
|
||||
if (i < rows.size()) row_controllers_[i]->set_viewport(rows[i]);
|
||||
}
|
||||
@@ -222,6 +226,18 @@ bool Host::run(const RunConfig& cfg, const FrameFn& frame, Status* st) {
|
||||
// signals at the one x the pointer is over. In the run loop rather than in a callback, so a
|
||||
// scripted run that injects its events straight into a row controller is synced too.
|
||||
sync_figure_crosshair(rows);
|
||||
// Only the row the pointer's events go to ever republishes its own hot handles, so a row
|
||||
// the pointer left keeps the `HotHandles` it last published and goes on drawing a hovered
|
||||
// marker or region bound as if the pointer were still on it -- and its `hovered_region()`/
|
||||
// `grabbed_cursor()` keep answering for it. Clear every *other* row here, in the run loop,
|
||||
// where that row is known (the captured row while a drag owns the button, so an in-flight
|
||||
// gesture keeps its highlight).
|
||||
if (have_pointer_ && !rows.empty()) {
|
||||
const u32 active = pointer_row(rows);
|
||||
for (u32 i = 0; i < rows.size() && i < row_controllers_.size(); ++i) {
|
||||
if (i != active) figure_->plot(i).set_hot_handles(HotHandles{});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
controller_.set_viewport(viewport); // also Plot::set_viewport, then layout()
|
||||
}
|
||||
@@ -337,7 +353,7 @@ void Host::on_cursor_pos(f64 wx, f64 wy) noexcept {
|
||||
have_pointer_ = true;
|
||||
e.mods = mods_now(window_);
|
||||
e.down = any_button_down(window_);
|
||||
controller_at(e.x, e.y).on_pointer(e);
|
||||
feed_pointer_event(e);
|
||||
}
|
||||
|
||||
void Host::on_mouse_button(int button, int action, int mods) noexcept {
|
||||
@@ -364,7 +380,7 @@ void Host::on_mouse_button(int button, int action, int mods) noexcept {
|
||||
e.pressed = action == GLFW_PRESS;
|
||||
e.released = action == GLFW_RELEASE;
|
||||
e.down = any_button_down(window_);
|
||||
controller_at(e.x, e.y).on_pointer(e);
|
||||
feed_pointer_event(e);
|
||||
}
|
||||
|
||||
void Host::on_scroll(f64 dx, f64 dy) noexcept {
|
||||
@@ -415,8 +431,13 @@ void Host::set_figure(Figure* figure) noexcept {
|
||||
last_row_ = 0u;
|
||||
// A new figure holds no crosshair this host has spread: the comparison in
|
||||
// `sync_figure_crosshair()` starts from "nothing spread", which is the state its rows are in.
|
||||
// Nor does it hold a captured drag row: the controllers are rebuilt below, so the old
|
||||
// capture names a controller that no longer exists.
|
||||
spread_active_ = false;
|
||||
spread_x_ = 0.0;
|
||||
spread_y_ = 0.0;
|
||||
captured_ = false;
|
||||
captured_row_ = 0u;
|
||||
if (figure_ == nullptr) return;
|
||||
const u32 rows = figure_->row_count();
|
||||
row_controllers_.reserve(rows);
|
||||
@@ -446,6 +467,49 @@ input::Controller& Host::controller_at(f32 x, f32 y) noexcept {
|
||||
return controller;
|
||||
}
|
||||
|
||||
void Host::feed_pointer_event(const input::PointerEvent& e) noexcept {
|
||||
if (figure_ == nullptr) {
|
||||
controller_.on_pointer(e);
|
||||
return;
|
||||
}
|
||||
pointer_target(e.x, e.y, e.pressed, e.released).on_pointer(e);
|
||||
}
|
||||
|
||||
input::Controller& Host::pointer_target(f32 x, f32 y, bool pressed, bool released) noexcept {
|
||||
(void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in()
|
||||
if (figure_ == nullptr || row_controllers_.empty()) return controller_;
|
||||
const std::vector<Rect>& rows = figure_->layout().rows;
|
||||
if (rows.empty() || rows.size() != row_controllers_.size()) return controller_;
|
||||
|
||||
u32 row = 0u;
|
||||
if (captured_) {
|
||||
// A button-down gesture belongs to the row it began in for its whole life: a drag
|
||||
// that crosses into a neighbouring row must not hand its motion -- or its release --
|
||||
// to a controller that never saw the press, which would leave the owner's drag open
|
||||
// for ever (and let a later gesture resume it from the old anchor).
|
||||
row = captured_row_;
|
||||
if (released) captured_ = false;
|
||||
} else {
|
||||
row = row_in(rows, y);
|
||||
if (pressed) {
|
||||
captured_ = true;
|
||||
captured_row_ = row;
|
||||
}
|
||||
}
|
||||
// The captured row is re-read against this frame's rects: the figure can be resized
|
||||
// mid-drag, and the guard keeps a stale index from reaching past the controller list.
|
||||
if (row >= row_controllers_.size()) row = 0u;
|
||||
last_row_ = row;
|
||||
input::Controller& controller = *row_controllers_[row];
|
||||
controller.set_viewport(rows[row]);
|
||||
return controller;
|
||||
}
|
||||
|
||||
u32 Host::pointer_row(const std::vector<Rect>& rows) noexcept {
|
||||
if (captured_ && captured_row_ < rows.size()) return captured_row_;
|
||||
return row_in(rows, last_pointer_y_);
|
||||
}
|
||||
|
||||
u32 Host::row_in(const std::vector<Rect>& rows, f32 y) noexcept {
|
||||
if (rows.empty()) return 0u;
|
||||
// The row the pixel is in, or -- when it is in none of them, which is the row gap a
|
||||
@@ -482,11 +546,18 @@ void Host::sync_figure_crosshair(const std::vector<Rect>& rows) noexcept {
|
||||
// keeps it.
|
||||
u32 hovered = row_count;
|
||||
if (have_pointer_) {
|
||||
hovered = row_in(rows, last_pointer_y_);
|
||||
// The row the pointer's events currently go to -- the captured row while a drag owns
|
||||
// the button, the hovered row otherwise -- so the hover follows the gesture it began.
|
||||
hovered = pointer_row(rows);
|
||||
} else {
|
||||
for (u32 i = 0; i < row_count; ++i) {
|
||||
const Crosshair& candidate = figure_->plot(i).crosshair();
|
||||
if (candidate.active != spread_active_ || (candidate.active && candidate.x != spread_x_)) {
|
||||
// A row differs when its active state, its x, or the y of *its own* horizontal line
|
||||
// differs from what was spread: a pointer that moves to another row at the same x is
|
||||
// a new hover and must move the one horizontal line, not leave the old row's behind.
|
||||
const bool y_differs = candidate.horizontal && candidate.y != spread_y_;
|
||||
if (candidate.active != spread_active_ ||
|
||||
(candidate.active && (candidate.x != spread_x_ || y_differs))) {
|
||||
hovered = i;
|
||||
break;
|
||||
}
|
||||
@@ -502,8 +573,19 @@ void Host::sync_figure_crosshair(const std::vector<Rect>& rows) noexcept {
|
||||
// figure's, so the rows cannot be left showing a hover the pointer has abandoned.
|
||||
const Plot& row = figure_->plot(hovered);
|
||||
if (row.crosshair().active) {
|
||||
figure_->set_crosshair(row.crosshair().x, hovered, row.crosshair().y, true);
|
||||
if (row.crosshair().horizontal) {
|
||||
// The row carries its own y (its controller placed both coordinates), so the figure's
|
||||
// four-argument overload gives the pointer row the one horizontal line.
|
||||
figure_->set_crosshair(row.crosshair().x, hovered, row.crosshair().y, true);
|
||||
} else {
|
||||
// The row's crosshair is vertical-only -- a caller placed it through the
|
||||
// single-coordinate `Figure::set_crosshair()`, which is documented vertical-only. The
|
||||
// four-argument overload would turn the horizontal line on at the stored (meaningless)
|
||||
// y == 0, clamped to the axes edge and made permanent. Promote only the vertical line.
|
||||
figure_->set_crosshair(row.crosshair().x, true);
|
||||
}
|
||||
spread_x_ = row.crosshair().x;
|
||||
spread_y_ = row.crosshair().y;
|
||||
spread_active_ = true;
|
||||
} else {
|
||||
figure_->clear_crosshair();
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
e.mods = static_cast<u32>(mods);
|
||||
// GLFW reports auto-repeat as its own action; a held key keeps acting, exactly
|
||||
// as holding the key on a keyboard does.
|
||||
e.down = action != GLFW_RELEASE;
|
||||
// A key has no position of its own: it goes to the row the pointer was last over,
|
||||
// which is the row the user is looking at.
|
||||
input::Controller& controller =
|
||||
(figure_ == nullptr || last_row_ >= row_controllers_.size()) ? controller_
|
||||
: *row_controllers_[last_row_];
|
||||
controller.on_key(e);
|
||||
}
|
||||
|
||||
} // namespace plot::glfw
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
@@ -132,11 +135,25 @@ class Host {
|
||||
/// that leaves the data area still ends where it should.
|
||||
///
|
||||
/// It also hands that row its own viewport, which is what keeps the controller's
|
||||
/// hit-test geometry in step with the layout between two frames. The event callbacks
|
||||
/// route through it, and so can a host that wants to know where a synthetic gesture
|
||||
/// would land.
|
||||
/// hit-test geometry in step with the layout between two frames. It is the pure
|
||||
/// query -- what a gesture *would* land on -- and it does not move the capture
|
||||
/// below; the GLFW callbacks and `feed_pointer_event()` are the routing.
|
||||
input::Controller& controller_at(f32 x, f32 y) noexcept;
|
||||
|
||||
/// Feeds one pointer event, stated in framebuffer pixels, through the host's own
|
||||
/// routing and into the controller it belongs to. The GLFW callbacks fill the
|
||||
/// position and the button transitions out of GLFW and call exactly this, so a host
|
||||
/// or a test with no window can drive the routing directly.
|
||||
///
|
||||
/// The routing is the row the event lands in, with one exception that is the whole
|
||||
/// point of it: a press **captures** the row it landed in, and every motion and the
|
||||
/// release until that button comes up go to the captured row. A drag that crosses
|
||||
/// into a neighbouring figure row is therefore still the drag it began as -- the
|
||||
/// owning controller sees the motion and the release that end it -- rather than
|
||||
/// being handed to a controller whose `mode_ == None`, which would leave the owner's
|
||||
/// drag open for ever and let a later gesture resume it from the old anchor.
|
||||
void feed_pointer_event(const input::PointerEvent& e) noexcept;
|
||||
|
||||
/// The gesture controller the host feeds. Valid for the host's whole life.
|
||||
input::Controller& controller() noexcept { return controller_; }
|
||||
|
||||
@@ -180,6 +197,19 @@ class Host {
|
||||
/// that has just laid the figure out does not make it lay out again for this.
|
||||
static u32 row_in(const std::vector<Rect>& rows, f32 y) noexcept;
|
||||
|
||||
/// The controller a pointer event at (x, y) belongs to, applying the button capture:
|
||||
/// a press claims the row it landed in (`captured_row_`) and every motion and the
|
||||
/// release until the button comes up are routed to it; `controller_at()` is the pure
|
||||
/// query that never captures. Also hands the target its row's viewport, so the
|
||||
/// hit-test geometry is the last frame's.
|
||||
input::Controller& pointer_target(f32 x, f32 y, bool pressed, bool released) noexcept;
|
||||
|
||||
/// The row the pointer's events currently go to: the captured row while a drag owns
|
||||
/// the button, the row under the pointer otherwise. The run loop uses it to clear
|
||||
/// every *other* row's hot handles, so a row the pointer left cannot keep drawing a
|
||||
/// hover or a grab it last published.
|
||||
u32 pointer_row(const std::vector<Rect>& rows) noexcept;
|
||||
|
||||
/// Spreads the hover over a whole `Figure` (spec §15).
|
||||
///
|
||||
/// A figure's rows share one x, so the crosshair one row's controller placed is read back here and
|
||||
@@ -217,10 +247,19 @@ class Host {
|
||||
/// `sync_figure_crosshair()`): what a row is compared against to find the one a
|
||||
/// scripted run touched, since the figure writes one value to every row.
|
||||
/// `spread_active_` false means "spread nothing" -- the state before the first
|
||||
/// sync, and the one a clear leaves.
|
||||
/// sync, and the one a clear leaves. `spread_y_` is the horizontal line's own
|
||||
/// value, compared as well so a y-only change -- the same x in another row --
|
||||
/// is not mistaken for "nothing changed".
|
||||
f64 spread_x_ = 0.0;
|
||||
f64 spread_y_ = 0.0;
|
||||
bool spread_active_ = false;
|
||||
|
||||
/// The row a button-down drag has captured: the row its press landed in, which
|
||||
/// owns its motions and its release until the button comes up. `captured_` is
|
||||
/// false between gestures, and cleared when another figure is set.
|
||||
bool captured_ = false;
|
||||
u32 captured_row_ = 0u;
|
||||
|
||||
u32 fb_width_ = 0; ///< framebuffer size of the last refresh_sizes()
|
||||
u32 fb_height_ = 0;
|
||||
/// Framebuffer pixels per window pixel. Taken from the two sizes GLFW reports
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <plot/core/rect.hpp>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include <gtk4/gtk_plot_area.hpp>
|
||||
|
||||
#include <gtk4/gtk_input.hpp>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include <imgui/imgui_plot.hpp>
|
||||
|
||||
#include <imgui.h>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include <qt6/qt_plot_widget.hpp>
|
||||
#include <qt6/qt_pointer.hpp>
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
|
||||
// The plot headers come first, and must. Qt's <QOpenGLWidget> reaches
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <plot/core/types.hpp>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Architecture
|
||||
|
||||
`plot` is one library, `plot_core`, plus optional adapter targets. The public headers are
|
||||
grouped into seven directories, and the umbrella header `include/plot/plot.hpp` includes
|
||||
them in this order:
|
||||
|
||||
```
|
||||
core / math → gl → data → render → plot → input
|
||||
```
|
||||
|
||||
with the host adapters (`adapters/glfw`, `adapters/imgui`, `adapters/qt6`, `adapters/gtk4`)
|
||||
built on top. This page says what each layer owns and the rules the build enforces.
|
||||
|
||||
## What each layer owns
|
||||
|
||||
| layer | directory | owns |
|
||||
|---|---|---|
|
||||
| `core` | `include/plot/core/`, `src/core/` | the vocabulary: `f32`/`f64`/`u32`/`usize`, `kInvalid`, `Range`, `Rect`, `Color`, `Span`, and `Status`/`ErrorCode` for fallible operations. |
|
||||
| `math` | `include/plot/math/`, `src/math/` | `Vec2`, `Mat3`, and `Transform` — the data↔pixel affine mapping, including the log-axis handling and its invertibility guarantee. |
|
||||
| `gl` | `include/plot/gl/`, `src/gl/` | a hand-written OpenGL surface: enumerator typedefs, the entry-point table `plot::gl::load()` fills, `plot::gl::caps()`, and the move-only RAII wrappers (`gl::Buffer`, `VertexArray`, `Program`, `Texture`, `Framebuffer`, `Fence`). No GL loader library is used. |
|
||||
| `data` | `include/plot/data/`, `src/data/` | the sample stores: the `Series` interface, `ArraySeries`, `StreamSeries`, `RingBuffer`, and the min/max LOD `MinMaxPyramid`. |
|
||||
| `render` | `include/plot/render/`, `src/render/` | presentation: `Style`, the `Renderer`, the text machinery (`TextBackend`, `TextAtlas`, `EmbeddedMonoFont`), and the shader/program/buffer/vertex-array primitives. |
|
||||
| `plot` | `include/plot/plot/`, `src/plot/` | the scene model: `Plot`, `Figure`, and the primitives they own — `Axis`, `Trace`, `Cursor`, `Region`, `Measure`, `Legend`, `Crosshair`, `Annotation`, `Grid`, `Ticks`. Pure CPU state, no GL. |
|
||||
| `input` | `include/plot/input/`, `src/input/` | `Controller` and the host-neutral event types (`PointerEvent`, `WheelEvent`, `KeyEvent`); turns events into gestures. |
|
||||
| adapters | `adapters/*/` | one per host: they own the window/context/event loop and translate their toolkit's events into `input` events. |
|
||||
|
||||
## The dependency rules
|
||||
|
||||
- **`plot_core` is third-party-free.** Its `CMakeLists.txt` links only
|
||||
`${CMAKE_DL_LIBS}`, and the public headers under `include/plot/` include no third-party
|
||||
header. The one exception is the optional `plot::font_ttf` target, which compiles the
|
||||
vendored `third_party/stb_truetype.h` (stb_truetype v1.26, public domain) in its own
|
||||
object library `plot_stb_truetype`, whose include directory is `PRIVATE` — so nothing
|
||||
else in the build, and no consumer, can reach it by accident. `PLOT_BUILD_TTF_FONT`
|
||||
defaults to on exactly when the vendored header is present.
|
||||
- **Every public header compiles standalone, and `plot.hpp` includes every one of them.**
|
||||
`tests/CMakeLists.txt` generates one translation unit per header (globbed, so a new
|
||||
header is picked up automatically), builds an umbrella TU, and registers ctest cases
|
||||
(`plot_headers_self_contained`, `plot_umbrella_covers_public_headers`,
|
||||
`plot_umbrella_check`) that fail the build if either rule breaks.
|
||||
- **The umbrella stops at the library.** It never includes an adapter header, so
|
||||
`#include <plot/plot.hpp>` cannot drag in GLFW, ImGui, Qt or GTK. The adapter headers are
|
||||
reached as `<glfw/glfw_host.hpp>`, `<imgui/imgui_plot.hpp>`, `<qt6/qt_plot_widget.hpp>`
|
||||
and `<gtk4/gtk_plot_area.hpp>`.
|
||||
- **The renderer never owns the context or the event loop.** `Renderer` never creates,
|
||||
binds or resizes a framebuffer: `begin_frame(fb_w, fb_h, viewport_px)` draws into
|
||||
whatever framebuffer is bound, clears exactly the given rectangle (scissored, so a host
|
||||
that painted the rest of its surface keeps it), and `end_frame()` turns the GL state it
|
||||
moved back off. The renderer also never sees a pointer, a button or a modifier — it draws
|
||||
the handles the plot reports as hot, and only a host that drives the plot with an
|
||||
`input::Controller` ever makes anything hot. Owning the window, the context and the loop
|
||||
is the adapter's job (or, for a library user, the application's).
|
||||
|
||||
One direction is worth stating precisely, because it is the one place the layer order is
|
||||
not a strict include chain: `Renderer` (in `render/`) includes `plot/plot.hpp` and
|
||||
`plot/figure.hpp`, because drawing *is* being handed a `Plot` or a `Figure` to draw. The
|
||||
`plot` layer names only `render`'s value types — `render::Style` and
|
||||
`render::TextBackend` — and never names `Renderer`. So the runtime direction is
|
||||
"the renderer knows the plot, the plot does not know the renderer".
|
||||
|
||||
## The design record
|
||||
|
||||
The specifications and implementation plans — the requirements, the numbered spec sections
|
||||
this documentation sometimes refers to, and the reasoning behind decisions — live under
|
||||
[`docs/superpowers/`](superpowers/) (`specs/` and `plans/`). That tree is the project's
|
||||
design record, not user documentation: it is where a decision is argued, not where the
|
||||
library is explained. This `docs/` guide and [docs/coverage.md](coverage.md) are the
|
||||
user-facing documents.
|
||||
@@ -0,0 +1,109 @@
|
||||
# Building and testing
|
||||
|
||||
## Presets and options
|
||||
|
||||
Four configure/build presets live in `CMakePresets.json`:
|
||||
|
||||
| preset | binaryDir | what it builds |
|
||||
|---|---|---|
|
||||
| `linux-debug` | `build/debug` | Debug; tests, examples and all four adapters |
|
||||
| `linux-release` | `build/release` | Release; `PLOT_BUILD_TESTS=OFF` (so the installed `libplot_core.a` carries none of the test-only counter instrumentation); examples and all four adapters |
|
||||
| `linux-asan` | `build/asan` | Debug with `PLOT_SANITIZE=address`; tests and all four adapters |
|
||||
| `linux-coverage` | `build/cov` | the instrumented tree behind `tools/coverage.sh`: `--coverage -O0 -g`, `PLOT_COVERAGE=ON`, tests on, adapters and examples off |
|
||||
|
||||
The options (`cmake/plot_options.cmake`) and their defaults:
|
||||
|
||||
| option | default | meaning |
|
||||
|---|---|---|
|
||||
| `PLOT_BUILD_TESTS` | `ON` | build the test suite (needs EGL) |
|
||||
| `PLOT_BUILD_EXAMPLES` | `OFF` | build the demos |
|
||||
| `PLOT_BUILD_GLFW` | `OFF` | build the GLFW adapter and its demo |
|
||||
| `PLOT_BUILD_IMGUI` | `OFF` | build the Dear ImGui adapter and its demo (ImGui is fetched at configure time) |
|
||||
| `PLOT_BUILD_QT6` | `OFF` | build the Qt6 adapter and its demo |
|
||||
| `PLOT_BUILD_GTK4` | `OFF` | build the GTK4 adapter and its demo |
|
||||
| `PLOT_BUILD_TTF_FONT` | on iff `third_party/stb_truetype.h` exists | build the optional `plot::text::TrueTypeFont` backend |
|
||||
| `PLOT_INSTALL` | `ON` | generate install/export rules for `find_package(plot)` |
|
||||
| `PLOT_SANITIZE` | `""` | sanitizer list for `-fsanitize=` (e.g. `address`) |
|
||||
| `PLOT_COVERAGE` | `OFF` | add the `coverage` target (requires an instrumented tree) |
|
||||
|
||||
Adapters and examples are off by default, so the core library and its tests build on a bare
|
||||
toolchain. Each example is guarded by the adapter target it needs, so
|
||||
`-DPLOT_BUILD_EXAMPLES=ON` without the matching adapter is a no-op rather than a configure
|
||||
error.
|
||||
|
||||
## Running the suite
|
||||
|
||||
```sh
|
||||
cmake --preset linux-debug
|
||||
cmake --build build/debug
|
||||
ctest --test-dir build/debug --output-on-failure
|
||||
```
|
||||
|
||||
ctest registers four tests: `plot_tests` (the test binary), the two header/umbrella build
|
||||
checks (`plot_headers_self_contained`, `plot_umbrella_covers_public_headers`) and
|
||||
`plot_umbrella_check`. The `plot_tests` binary itself runs **283 cases** in the `linux-debug`
|
||||
preset; its harness prints one line per case and a final `283 cases, 0 failed` summary line.
|
||||
The case counts per file are the `PLOT_TEST(...)` declarations under `tests/`.
|
||||
|
||||
## Headless demo modes
|
||||
|
||||
Each demo supports two environment variables, implemented once and reused:
|
||||
|
||||
- `PLOT_DEMO_EXIT_AFTER_MS=<n>` — stop the loop after `n` milliseconds and return. Unset,
|
||||
unparsable or not positive means "run until the window is closed". A run with a deadline
|
||||
creates its window hidden, so an automated run never flashes a window on a real display,
|
||||
and its frame times are not throttled by a vblank.
|
||||
- `PLOT_DEMO_SCREENSHOT=<path>` — on that final frame, write it as a binary PPM before the
|
||||
swap. The GLFW host writes the framebuffer with `Renderer::dump_frame_ppm()`; the ImGui
|
||||
demo writes the whole framebuffer, while Qt and GTK write their widget's/area's own
|
||||
framebuffer (their toolkits' chrome lives outside it).
|
||||
|
||||
That pair is what lets CI run the GUI demos under `xvfb-run` with no display access and no
|
||||
screen capture (the invocations are in [embedding.md](embedding.md)).
|
||||
|
||||
Three demos add env-gated **self-checking** modes. These are verification affordances for
|
||||
the project's own testing, driven through the real host and its own controllers — they are
|
||||
not a second implementation of anything, and they exit non-zero if a measured property fails:
|
||||
|
||||
| variable | demo | what it drives |
|
||||
|---|---|---|
|
||||
| `PLOT_DEMO_FOLLOW=1` | `plot_glfw_demo` | the live-follow sequence: three wheel notches and a pan on a `XFollow::Live` axis, printing the range width, the distance behind the newest sample and the follow state at each step |
|
||||
| `PLOT_DEMO_REGIONS` (`=hot` to hover a bound) | `plot_glfw_demo` | the direct-manipulation sequence: freeze the view, place a region, pan 100 px with the press outside it, and check the region came along with its width untouched |
|
||||
| `PLOT_DEMO_SYNC=1` | `plot_multiplot_demo` | the synced-follow check: the three rows hold one range and its right edge keeps tracking the newest sample through a zoom and a pan on different rows |
|
||||
| `PLOT_DEMO_CROSSHAIR=<row>` | `plot_multiplot_demo` | parks the pointer in a row each frame so a headless screenshot shows the one horizontal line the hovered row draws |
|
||||
|
||||
(`plot_glfw_demo` also accepts `PLOT_DEMO_CROSSHAIR=1`, which parks the pointer at the centre
|
||||
of the data area so the two-line crosshair has ink to measure.)
|
||||
|
||||
## Coverage
|
||||
|
||||
```sh
|
||||
tools/coverage.sh # from the repository root
|
||||
cmake --build build/cov --target coverage # the same thing, through CMake
|
||||
```
|
||||
|
||||
The script configures `build/cov` from the `linux-coverage` preset, builds it, clears the
|
||||
`.gcda` counters, runs the whole suite, captures `src/**` with `lcov`, renders an HTML report
|
||||
to `build/cov/html/index.html`, prints the per-file table, and **exits non-zero when a floor
|
||||
is violated**. It needs `cmake`, `ninja`, `ctest`, `lcov`, `genhtml` and `python3`, and exits
|
||||
`2` with a message when one is missing.
|
||||
|
||||
The floors are enforced by `tools/coverage_floor.py`:
|
||||
|
||||
| scope | floor |
|
||||
|---|---|
|
||||
| every file under `src/` | **80%** of its instrumented lines |
|
||||
| `src/` as a whole | **90%** of instrumented lines |
|
||||
|
||||
Branch coverage is deliberately off. The current measurement (from `build/cov/coverage.info`,
|
||||
re-read with `python3 tools/coverage_floor.py build/cov/coverage.info`) is an **aggregate
|
||||
95.3% (3284/3447 lines)** over 23 files, every file at or above 80% — the floor is satisfied.
|
||||
The method, the full per-file table and the reproducibility notes are in
|
||||
[docs/coverage.md](coverage.md); re-run the command for the live numbers.
|
||||
|
||||
**One thing to know about the coverage preset:** it builds **272 of the 283 cases**. The 11
|
||||
that are missing are the adapter cases that need an adapter target to exist — 8 in
|
||||
`test_imgui_widget.cpp` and 3 in `test_glfw_host.cpp` — and the coverage preset turns the
|
||||
adapters off. The toolkit-free adapter seams still run there: `test_qt_pointer.cpp` (2 cases)
|
||||
and `test_gtk_input.cpp` (8 cases) compile without Qt or GTK. So the coverage number measures
|
||||
the library plus its unit suite, not the adapter bodies (which the headless demo runs cover).
|
||||
+47
-52
@@ -3,6 +3,13 @@
|
||||
`plot` measures line coverage of the compiled library bodies under `src/` and enforces a floor on
|
||||
it, so a regression fails a command instead of quietly lowering a number in a document.
|
||||
|
||||
**The table and the floor verdict below are generated.** `tools/coverage.sh` rewrites the region
|
||||
between `<!-- BEGIN GENERATED: tools/coverage.sh -->` and
|
||||
`<!-- END GENERATED: tools/coverage.sh -->` from the report that run just captured, by calling
|
||||
`tools/coverage_floor.py --write-doc`. Do not edit inside those markers: the numbers there come
|
||||
from the command, not from a hand-typed copy, and a run on an unchanged tree rewrites the region
|
||||
with identical content.
|
||||
|
||||
## The one command
|
||||
|
||||
```sh
|
||||
@@ -12,12 +19,10 @@ cmake --build build/cov --target coverage # the same thing, through CMake
|
||||
|
||||
The script configures `build/cov` from the `linux-coverage` preset, builds it, runs the whole
|
||||
`ctest` suite, captures `src/**` with `lcov` into `build/cov/coverage.info`, renders the HTML report
|
||||
to `build/cov/html/index.html`, prints the per-file table below, and **exits non-zero when a floor
|
||||
is violated**. It needs `cmake`, `ninja`, `ctest`, `lcov`, `genhtml` and `python3`
|
||||
(Arch: `pacman -S cmake ninja lcov python`); it exits `2` with a message when one is missing.
|
||||
|
||||
A clean run of it — no build tree, all 26 translation units under `src/` compiled, 274 cases — takes
|
||||
about two minutes here, of which the suite is ~107 s.
|
||||
to `build/cov/html/index.html`, prints the per-file table, rewrites the generated region of this
|
||||
file, and **exits non-zero when a floor is violated**. It needs `cmake`, `ninja`, `ctest`, `lcov`,
|
||||
`genhtml` and `python3` (Arch: `pacman -S cmake ninja lcov python`); it exits `2` with a message
|
||||
when one is missing.
|
||||
|
||||
## The floors
|
||||
|
||||
@@ -35,45 +40,40 @@ about two minutes here, of which the suite is ~107 s.
|
||||
|
||||
## The measured table
|
||||
|
||||
Measured on 2026-09-28 with GCC 16.2.1 from a clean `build/cov`, over the full test suite.
|
||||
`coverage.sh` prints exactly this table on every run.
|
||||
<!-- BEGIN GENERATED: tools/coverage.sh -->
|
||||
|
||||
```
|
||||
coverage_floor: ok -- 23 files under src/, aggregate 95.2% lines (3302/3469), every file at or above 80%, aggregate target 90%
|
||||
```
|
||||
|
||||
<!-- BEGIN GENERATED TABLE (tools/coverage_floor.py --table --format markdown) -->
|
||||
| File | Lines | Line rate | Functions |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| `src/data/array_series.cpp` | 43/43 | 100.0% | 100.0% |
|
||||
| `src/data/minmax_pyramid.cpp` | 163/163 | 100.0% | 100.0% |
|
||||
| `src/data/series.cpp` | 105/107 | 98.1% | 100.0% |
|
||||
| `src/data/stream_series.cpp` | 39/39 | 100.0% | 100.0% |
|
||||
| `src/gl/handle.cpp` | 214/256 | 83.6% | 100.0% |
|
||||
| `src/gl/loader.cpp` | 53/81 | 65.4% | 77.8% |
|
||||
| `src/input/controller.cpp` | 327/341 | 95.9% | 100.0% |
|
||||
| `src/gl/handle.cpp` | 256/256 | 100.0% | 100.0% |
|
||||
| `src/gl/loader.cpp` | 81/81 | 100.0% | 100.0% |
|
||||
| `src/input/controller.cpp` | 329/343 | 95.9% | 100.0% |
|
||||
| `src/math/transform.cpp` | 28/28 | 100.0% | 100.0% |
|
||||
| `src/plot/axis.cpp` | 12/12 | 100.0% | 100.0% |
|
||||
| `src/plot/figure.cpp` | 213/218 | 97.7% | 95.5% |
|
||||
| `src/plot/figure.cpp` | 218/223 | 97.8% | 95.5% |
|
||||
| `src/plot/grid.cpp` | 15/16 | 93.8% | 100.0% |
|
||||
| `src/plot/measure.cpp` | 21/21 | 100.0% | 100.0% |
|
||||
| `src/plot/plot.cpp` | 733/753 | 97.3% | 98.9% |
|
||||
| `src/plot/plot.cpp` | 743/763 | 97.4% | 98.9% |
|
||||
| `src/plot/ticks.cpp` | 93/99 | 93.9% | 100.0% |
|
||||
| `src/render/buffer.cpp` | 87/98 | 88.8% | 100.0% |
|
||||
| `src/render/buffer.cpp` | 88/98 | 89.8% | 100.0% |
|
||||
| `src/render/embedded_mono_font.cpp` | 45/45 | 100.0% | 100.0% |
|
||||
| `src/render/primitives.cpp` | 71/75 | 94.7% | 100.0% |
|
||||
| `src/render/program.cpp` | 26/27 | 96.3% | 100.0% |
|
||||
| `src/render/renderer.cpp` | 529/604 | 87.6% | 93.7% |
|
||||
| `src/render/renderer.cpp` | 530/609 | 87.0% | 93.7% |
|
||||
| `src/render/text.cpp` | 27/30 | 90.0% | 100.0% |
|
||||
| `src/render/text_atlas.cpp` | 40/41 | 97.6% | 100.0% |
|
||||
| `src/render/vertex_array.cpp` | 49/59 | 83.1% | 100.0% |
|
||||
| `src/render/vertex_array.cpp` | 59/59 | 100.0% | 100.0% |
|
||||
| `src/text/true_type_font.cpp` | 270/291 | 92.8% | 96.8% |
|
||||
| **src/ aggregate** | **3203/3447** | **92.9%** | **97.8%** |
|
||||
<!-- END GENERATED TABLE -->
|
||||
| **src/ aggregate** | **3302/3469** | **95.2%** | **98.3%** |
|
||||
|
||||
**The floor currently fails, and that is the point.** `src/gl/loader.cpp` sits at 65.4% lines
|
||||
(53/81) — below the 80% per-file floor — so `tools/coverage.sh` exits `1` on today's tree. What is
|
||||
uncovered there is the loader's error paths and its extension scanner; `src/gl/handle.cpp` (83.6%)
|
||||
and `src/render/vertex_array.cpp` (83.1%) are the same kind of gap, just above the line. Those tests
|
||||
are Task 2 of `docs/superpowers/plans/2026-09-28-plot-docs-and-coverage.md`; the floor went in first
|
||||
so the fix has something to satisfy rather than something to negotiate with, and so the *next*
|
||||
regression in that area is a failing command rather than a number nobody reads.
|
||||
<!-- END GENERATED: tools/coverage.sh -->
|
||||
|
||||
## What the report measures
|
||||
|
||||
@@ -118,40 +118,35 @@ before `genhtml` re-creates it.
|
||||
per-file `LF`/`LH`/`FNF`/`FNH` tallies that are identical for all 23 files — verified by diffing the
|
||||
two tracefiles — but the per-line hit *counts* differ by a few parts per million, because a handful
|
||||
of tests drive a producer thread whose iteration count follows wall-clock timing. Everything the
|
||||
floor reads (the `LF`/`LH` tallies, and therefore every rate in the table above) is unaffected.
|
||||
floor reads (the `LF`/`LH` tallies, and therefore every rate in the generated table above) is
|
||||
unaffected.
|
||||
|
||||
## Adjusting the floors, and seeing the gate fail
|
||||
|
||||
Both floors are flags, which is how the failure path is demonstrated — and how a later task tightens
|
||||
the contract. Against `build/cov/coverage.info` from the table above:
|
||||
the contract. Against the report in the generated table above, a floor raised above every file's
|
||||
rate fails the check and names each offender:
|
||||
|
||||
```sh
|
||||
$ python3 tools/coverage_floor.py build/cov/coverage.info # the real floors
|
||||
coverage_floor: FAILED -- 1 floor(s) violated in build/cov/coverage.info:
|
||||
src/gl/loader.cpp: 65.4% lines (53/81) is below the 80% per-file floor
|
||||
$ echo $?
|
||||
1
|
||||
# Any file not at 100% fails: a floor that is guaranteed to bite, whatever the
|
||||
# current rates are. The list of offenders is the files in the table above whose
|
||||
# rate is below 100%.
|
||||
python3 tools/coverage_floor.py --per-file 100 build/cov/coverage.info
|
||||
# coverage_floor: FAILED -- <n> floor(s) violated in build/cov/coverage.info:
|
||||
# <file>: <rate>% lines (<hit>/<found>) is below the 100% per-file floor
|
||||
# ...
|
||||
# $? == 1
|
||||
|
||||
$ python3 tools/coverage_floor.py --per-file 95 build/cov/coverage.info # floor raised above rates
|
||||
coverage_floor: FAILED -- 10 floor(s) violated in build/cov/coverage.info:
|
||||
src/gl/handle.cpp: 83.6% lines (214/256) is below the 95% per-file floor
|
||||
src/gl/loader.cpp: 65.4% lines (53/81) is below the 95% per-file floor
|
||||
src/plot/grid.cpp: 93.8% lines (15/16) is below the 95% per-file floor
|
||||
src/plot/ticks.cpp: 93.9% lines (93/99) is below the 95% per-file floor
|
||||
src/render/buffer.cpp: 88.8% lines (87/98) is below the 95% per-file floor
|
||||
src/render/primitives.cpp: 94.7% lines (71/75) is below the 95% per-file floor
|
||||
src/render/renderer.cpp: 87.6% lines (529/604) is below the 95% per-file floor
|
||||
src/render/text.cpp: 90.0% lines (27/30) is below the 95% per-file floor
|
||||
src/render/vertex_array.cpp: 83.1% lines (49/59) is below the 95% per-file floor
|
||||
src/text/true_type_font.cpp: 92.8% lines (270/291) is below the 95% per-file floor
|
||||
$ echo $?
|
||||
1
|
||||
# The same, for the aggregate: a target above the measured aggregate fails with one line.
|
||||
python3 tools/coverage_floor.py --aggregate 99 build/cov/coverage.info
|
||||
# coverage_floor: FAILED -- 1 floor(s) violated in build/cov/coverage.info:
|
||||
# src/ aggregate: <rate>% lines (<hit>/<found>) is below the 99% aggregate floor
|
||||
# $? == 1
|
||||
|
||||
$ python3 tools/coverage_floor.py --per-file 60 --aggregate 90 build/cov/coverage.info
|
||||
coverage_floor: ok -- 23 files under src/, aggregate 92.9% lines (3203/3447), every file at or
|
||||
above 60%, aggregate target 90%
|
||||
$ echo $?
|
||||
0
|
||||
# A floor at or below the measured rates passes.
|
||||
python3 tools/coverage_floor.py build/cov/coverage.info
|
||||
# coverage_floor: ok -- ...
|
||||
# $? == 0
|
||||
```
|
||||
|
||||
## Why the check is a CMake target and not a ctest case
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
# Data and performance
|
||||
|
||||
## The sample stores
|
||||
|
||||
All three series derive from `plot::data::Series` (`include/plot/data/series.hpp`), which
|
||||
describes a **uniform-grid** series: sample `i` lives at `x = x0() + i * dx()`.
|
||||
|
||||
```cpp
|
||||
class Series {
|
||||
public:
|
||||
virtual u64 size() const;
|
||||
virtual f64 x0() const; // x of sample 0 (the oldest retained sample)
|
||||
virtual f64 dx() const;
|
||||
virtual Range x_bounds() const;
|
||||
virtual Range y_bounds() const;
|
||||
virtual const MinMaxPyramid& pyramid() const;
|
||||
virtual bool streaming() const noexcept; // default false
|
||||
virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const;
|
||||
|
||||
Range reduce(u64 begin, u64 end) const; // exact min/max over [begin, end)
|
||||
f64 sum(u64 begin, u64 end) const; // exact sum over [begin, end)
|
||||
f64 sumsq(u64 begin, u64 end) const; // exact sum of squares
|
||||
};
|
||||
```
|
||||
|
||||
Indices are the series' **own window**: index 0 is the oldest sample it currently holds, so
|
||||
`size()`, `x0()`, `reduce()`, `gather()` and `pyramid()` are all in the same coordinates.
|
||||
|
||||
**`ArraySeries`** (`array_series.hpp`) owns a `std::vector<f32>` of y samples plus an
|
||||
`x0`/`dx` grid. Construct with `ArraySeries(std::vector<f32> y, f64 x0, f64 dx)`. The vector
|
||||
is taken by value and owned, so the samples cannot change behind the series' back; the store
|
||||
changes only through `reset(y, x0, dx)` (a whole new vector, then a rebuild) or `commit()`,
|
||||
which rebuilds the pyramid and cached bounds from the current `y`.
|
||||
|
||||
**`StreamSeries`** (`stream_series.hpp`) is the single-producer / single-consumer streaming
|
||||
store: `StreamSeries(u64 capacity, f64 x0, f64 dx)`.
|
||||
|
||||
## The SPSC contract
|
||||
|
||||
One producer thread, one consumer thread, and the division is strict
|
||||
(`stream_series.hpp`):
|
||||
|
||||
- **The producer may only call** `push(f32 y)`, `push(const f32* ys, u64 n)` and
|
||||
`produced()`. Those touch the ring buffer and nothing else: **no lock, no pyramid, no GL**.
|
||||
The acquisition thread can therefore never stall the renderer.
|
||||
- **The consumer calls `acquire()`**, which snapshots the produced count and brings the
|
||||
pyramid and bounds up to it, and then queries the published window. `size()`, `reduce()`,
|
||||
`sum()`, `sumsq()`, `gather()` and `pyramid()` all use the window published by the last
|
||||
`acquire()`.
|
||||
|
||||
`acquire()` costs O(samples produced since the last acquire), not O(capacity): the pyramid
|
||||
is indexed in absolute sample space, so a sliding window only drops the leading buckets the
|
||||
ring overwrote and appends what is new; aggregates still in the window are never recomputed.
|
||||
The one exception is a producer that laps the consumer (more than `capacity()` samples
|
||||
between two acquires), where the missing samples cannot be represented and the pyramid
|
||||
restarts at the new base, re-reading the whole window.
|
||||
|
||||
The ring overwrites the oldest samples — that is what keeps a live stream showing the newest
|
||||
data — so it is **not a frozen window**. Those slots are relaxed atomics, so this is not a
|
||||
data race and no value tears; but if the producer pushes `k` samples while a read is in
|
||||
flight, the first `k` positions of that read can hold one of those `k` newer samples rather
|
||||
than the one the snapshot named. Ingest with no reuse underneath a read at all needs the
|
||||
producer to stop before unread data (drop-on-full) or a claim protocol — a different
|
||||
contract from this one.
|
||||
|
||||
`streaming()` returns `true` for `StreamSeries` and `false` for everything else. Only a
|
||||
streaming series has a **live edge** (the newest x it holds, which moves on its own), and it
|
||||
is that property `XFollow::Live` follows (see [interaction.md](interaction.md)).
|
||||
|
||||
## The min/max LOD pyramid
|
||||
|
||||
`data::MinMaxPyramid` (`minmax_pyramid.hpp`) is the index that makes per-frame cost depend on
|
||||
the visible pixels rather than on the sample count.
|
||||
|
||||
- Samples are grouped into buckets of `kBucket = 64` (**`kBucket`** is a fixed constant, so
|
||||
all series share one LOD geometry). Level `l` bucket `k` covers the absolute samples
|
||||
`[k * (kBucket << l), (k + 1) * (kBucket << l))`, and bucket boundaries are fixed for the
|
||||
pyramid's life — appending never re-buckets existing aggregates, and a window can slide
|
||||
over the same buckets.
|
||||
- Each level holds its complete buckets in a `std::deque` (O(1) `push_back` and
|
||||
`pop_front`) plus a `base`. `append()` folds a sample into the in-progress level-0 bucket
|
||||
and, when it completes, pushes it and walks up, stopping at the first level that gains no
|
||||
entry: an append costs **amortised O(1)**. `drop_before(u)` slides the window's leading
|
||||
edge, bounding the memory by the retained window rather than by everything ever appended.
|
||||
- An entry is 24 bytes (`MinMax` plus the exact `sum`/`sumsq` the exact range queries fold
|
||||
over), so level 0 costs `0.375n` bytes and the geometric tail brings the whole pyramid to
|
||||
about **`0.75n` bytes — roughly 19% of the `4n` bytes of raw samples** (`f32`). A
|
||||
min/max-only index would be `0.25n` (~6%); the two doubles are the price of answering
|
||||
mean/RMS/integral from whole buckets instead of raw samples.
|
||||
|
||||
Queries on the pyramid are **bucket-grained** and named `*_buckets` (`reduce_buckets`,
|
||||
`sum_buckets`, `sumsq_buckets`): a pyramid stores aggregates, so it can only answer exactly
|
||||
for whole buckets, and partial edge buckets are excluded rather than approximated. Exact
|
||||
range queries live on `Series`, which owns the raw samples.
|
||||
|
||||
`level_for(u64 n, f64 samples_per_pixel)` returns the pyramid level whose buckets hold about
|
||||
`samples_per_pixel` samples for a window of `n` samples (clamped to levels that exist, to
|
||||
levels with more than one bucket, and to levels that give the window at least two buckets).
|
||||
|
||||
The renderer uses this: a trace's x range is mapped onto sample indices, and `Series::gather`
|
||||
is asked for that window *at `pyramid().level_for(n, samples_per_pixel)`* whenever a pixel
|
||||
covers `Style::dense_threshold_spp` samples or more (default 1.5). A 100-million-sample
|
||||
series therefore stages O(visible pixels) vertices, not O(samples), and `FrameStats` records
|
||||
which path each trace took. Below the threshold the raw samples are drawn instead; both
|
||||
sides go through the same instanced anti-aliased quad pipeline.
|
||||
|
||||
## Exact versus interpolated
|
||||
|
||||
The library deliberately holds two conventions next to each other, and they do **not**
|
||||
agree:
|
||||
|
||||
- **`Series::reduce` / `sum` / `sumsq` are exact over a sample range.** They answer "what
|
||||
are the samples' extremes / sum / sum of squares over `[begin, end)`". Each is
|
||||
implemented once over `copy_raw()`: the bucket-aligned interior comes from the pyramid
|
||||
(O(log n) bucket aggregates) and at most two partial edge buckets are read raw and
|
||||
scanned, so the cost is O(`kBucket`) worst case and **independent of the series length**,
|
||||
and no query allocates. `Measure::evaluate` and `Plot::region_stats` are built on these,
|
||||
never on the `*_buckets` forms, which would silently report wrong extremes for an
|
||||
unaligned interval.
|
||||
- **The crosshair readout is interpolated, and says so.** `crosshair_span(series, x)`
|
||||
returns the two samples `x` lies between (`begin`, `end`, `t`), and
|
||||
`crosshair_interpolate(y_begin, y_end, t)` does a linear interpolation: it answers "what
|
||||
is the signal *at* this instant", which is generally between two samples — a different
|
||||
question from an interval statistic. The lookup is O(1) (one division and a floor) and
|
||||
allocation-free; `Plot::crosshair_value(trace, x, out_y)` exposes the same number to a
|
||||
host. A row whose trace has no sample at the crosshair's x shows `kCrosshairNoValue`
|
||||
(an em dash, `"—"`), and an x outside the series window yields no value rather than the
|
||||
nearest sample's.
|
||||
|
||||
`Measurement` (from `Plot::measurement()`) documents `integral` as a **left-Riemann sum**,
|
||||
`sum(begin, end) * dx` — the samples taken as the value at the left edge of each step. That
|
||||
is part of the result's contract, not an implementation detail.
|
||||
|
||||
## Measured frame times
|
||||
|
||||
These are the demos' own frame summaries, taken from a `linux-release` build, **all of them
|
||||
llvmpipe (Mesa software rasteriser) under Xvfb** — no run touched a GPU. Treat them as
|
||||
upper bounds and as a record of the software path, not as hardware numbers.
|
||||
|
||||
| demo | window | mean ms/frame | work |
|
||||
|---|---|---|---|
|
||||
| GLFW (`plot_glfw_demo`) | 1280×720 | 0.42 | one 1 MS/s `StreamSeries` |
|
||||
| ImGui (`plot_imgui_demo`) | 1280×720 | 0.37 | the same stream, embedded |
|
||||
| multiplot (`plot_multiplot_demo`) | 1280×720 | 0.66 | 3 rows, 6 traces, 500 kS/s each |
|
||||
| Qt6 (`plot_qt_demo`) | 820×588 at exit | 3.23 | one stream, `QOpenGLWidget` FBO |
|
||||
| GTK4 (`plot_gtk_demo`) | 810×564 at exit | 2.85 | one stream, `GtkGLArea` FBO |
|
||||
|
||||
The multiplot run stages 18 651 segments for ~4.5 million visible samples, which is the
|
||||
pyramid doing its job: the frame is bounded by the pixels, not the samples. The Qt and GTK
|
||||
figures are dominated by their toolkit's texture-backed FBO under llvmpipe, not by the
|
||||
library.
|
||||
@@ -0,0 +1,213 @@
|
||||
# Embedding into a host
|
||||
|
||||
A host owns the window, the GL context and the event loop; `plot` owns none of them. The
|
||||
contract the renderer offers is small: make a 3.3 core context current, resolve the entry
|
||||
points with `plot::gl::load()`, call `Renderer::init()`, then each frame
|
||||
|
||||
```cpp
|
||||
renderer.style().ui_scale = <device pixels per logical unit>;
|
||||
renderer.begin_frame(fb_w, fb_h, viewport_px); // clears exactly viewport_px
|
||||
renderer.draw(plot); // or renderer.draw(figure)
|
||||
renderer.end_frame(); // restores the GL state it moved
|
||||
```
|
||||
|
||||
and hand the plot (or figure) whatever events the host receives. The `Renderer` never
|
||||
creates, binds or resizes a framebuffer, never reads your event loop, and never sees a
|
||||
pointer or a button.
|
||||
|
||||
Below are the four shipped adapters. Each is a separate opt-in target
|
||||
(`plot::glfw`, `plot::imgui`, `plot::qt6`, `plot::gtk4`) and each reads its own source
|
||||
under `adapters/`.
|
||||
|
||||
## Shared conventions
|
||||
|
||||
**Pixel space.** Every `Rect`, viewport and pointer position `plot` speaks is in
|
||||
**device (framebuffer) pixels, origin top-left**. Every host below reports geometry in
|
||||
*logical* units (window points, widget pixels) and converts before forwarding, so a
|
||||
gesture lands on the pixels the plot was laid out in.
|
||||
|
||||
**DPI / `ui_scale`.** The metrics in `render::Style` (font size, stroke widths, paddings,
|
||||
tick spacing) are **logical** units; `Style::ui_scale` is device pixels per logical unit,
|
||||
and the renderer converts each metric through `Style::device()` exactly once. The host
|
||||
writes `ui_scale` from its own display, so a 12 px label is the same physical size on a 1x
|
||||
and a 2x panel. The one metric that is *not* scaled is `dense_threshold_spp`, a
|
||||
samples-per-pixel density (see [styling.md](styling.md)). How each host supplies the
|
||||
scale:
|
||||
|
||||
| host | value written to `ui_scale` | source |
|
||||
|---|---|---|
|
||||
| GLFW | `fb_scale_x` = framebuffer width / window width | `glfwGetFramebufferSize()` vs `glfwGetWindowSize()`, re-read on resize |
|
||||
| ImGui | `io.DisplayFramebufferScale.x` | filled by the platform backend |
|
||||
| Qt6 | `devicePixelRatioF()` | re-read every `paintGL()` |
|
||||
| GTK4 | `mapping_.factor` = device size / logical allocation | the two sizes `::resize` reports, not `gtk_widget_get_scale_factor()` |
|
||||
|
||||
**Headless verification.** Every demo supports `PLOT_DEMO_EXIT_AFTER_MS=<ms>` (stop the
|
||||
loop after that long) and `PLOT_DEMO_SCREENSHOT=<path>` (write the final frame as a PPM).
|
||||
This is how the project runs its own GUI demos in CI under Xvfb, with no display access
|
||||
and no screen capture. The invocations it uses (each demo, from the repository root, all
|
||||
five exit `0`):
|
||||
|
||||
```sh
|
||||
PLOT_DEMO_EXIT_AFTER_MS=4000 PLOT_DEMO_SCREENSHOT=/tmp/glfw.ppm \
|
||||
xvfb-run -a ./build/release/examples/plot_glfw_demo
|
||||
PLOT_DEMO_EXIT_AFTER_MS=4000 PLOT_DEMO_SCREENSHOT=/tmp/multiplot.ppm \
|
||||
xvfb-run -a ./build/release/examples/plot_multiplot_demo
|
||||
PLOT_DEMO_EXIT_AFTER_MS=4000 PLOT_DEMO_SCREENSHOT=/tmp/imgui.ppm \
|
||||
xvfb-run -a ./build/release/examples/plot_imgui_demo
|
||||
PLOT_DEMO_EXIT_AFTER_MS=4000 PLOT_DEMO_SCREENSHOT=/tmp/qt.ppm \
|
||||
QT_QPA_PLATFORM=xcb xvfb-run -a -s "-screen 0 1920x1200x24" \
|
||||
./build/release/examples/plot_qt_demo
|
||||
PLOT_DEMO_EXIT_AFTER_MS=4000 PLOT_DEMO_SCREENSHOT=/tmp/gtk.ppm \
|
||||
GDK_BACKEND=x11 xvfb-run -a -s "-screen 0 1920x1200x24" \
|
||||
./build/release/examples/plot_gtk_demo
|
||||
```
|
||||
|
||||
The `-s "-screen 0 1920x1200x24"` shape matters for Qt and GTK, whose demos script a
|
||||
resize before the exit frame; a bare `xvfb-run` screen (640×480) clamps the window and
|
||||
that self-check reports a false failure.
|
||||
|
||||
## GLFW — `plot::glfw`
|
||||
|
||||
`#include <glfw/glfw_host.hpp>`, link `plot::glfw` (which links `plot::core`). This is the
|
||||
reference host: `Host` owns the GLFW window and its 3.3 core context, the GL entry points,
|
||||
the `Renderer`, the `Plot`, an `input::Controller` and the frame loop.
|
||||
|
||||
```cpp
|
||||
plot::glfw::Host host;
|
||||
plot::Plot& p = host.plot(); // populate axes/traces here
|
||||
plot::Status st;
|
||||
if (!host.run({1280, 720, "plot"}, [](plot::Plot& p, plot::f64 dt) { /* feed data */ }, &st)) { ... }
|
||||
```
|
||||
|
||||
What `run()` does, in order:
|
||||
|
||||
1. `glfwInit()`, then window hints for OpenGL **3.3 core** (plus forward compatibility) and
|
||||
`glfwCreateWindow()`, `glfwMakeContextCurrent()`, `glfwSwapInterval(vsync)`.
|
||||
2. `plot::gl::load(&resolve_gl)`, where `resolve_gl` wraps `glfwGetProcAddress` (a function
|
||||
pointer respelled as `void*` by bit copy).
|
||||
3. `Renderer::init()`.
|
||||
4. Per frame: `glfwPollEvents()` → `refresh_sizes()` →
|
||||
`controller.set_viewport(framebuffer rect)` → the host's `frame` callback →
|
||||
`begin_frame(fb_width, fb_height, viewport)` → `draw(plot)` (or `draw(figure)`) →
|
||||
`[screenshot]` → `end_frame()` → `glfwSwapBuffers()`.
|
||||
|
||||
Pointer translation: `on_cursor_pos()` multiplies the GLFW window coordinates by
|
||||
`fb_scale_x_`/`fb_scale_y_` (framebuffer ÷ window) and synthesises `dx`/`dy` itself (GLFW
|
||||
reports no delta); `down` comes from `glfwGetMouseButton`, and the modifier mask is read
|
||||
from the key state, because GLFW's pointer and scroll callbacks carry no mask. Wheel
|
||||
notches are forwarded as `WheelEvent`, and keys as `KeyEvent`.
|
||||
|
||||
Figure mode: `host.set_figure(&figure)` makes the loop lay the figure over the framebuffer,
|
||||
create **one `Controller` per row**, route each pointer/wheel/key event to the row it lands
|
||||
in (`controller_at(x, y)`), and draw with `Renderer::draw(Figure&)`. The host also spreads
|
||||
one hover over every row (`sync_figure_crosshair`), since only the host knows which row the
|
||||
pointer is in.
|
||||
|
||||
`run()` leaves the window open on success so a producer thread can be joined before the GL
|
||||
resources go away; `close()` (or the destructor) releases the renderer, context and window
|
||||
on the calling thread. A run with `PLOT_DEMO_EXIT_AFTER_MS` set creates the window hidden.
|
||||
|
||||
## Dear ImGui — `plot::imgui`
|
||||
|
||||
`#include <imgui/imgui_plot.hpp>`, link `plot::imgui`. Build-tree only: ImGui is supplied
|
||||
by `FetchContent`, so `find_package(plot)` does not export this target.
|
||||
|
||||
`PlotWidget::draw(id, plot, size, renderer)` must be called between `ImGui::NewFrame()` and
|
||||
`ImGui::Render()`, from inside a window. It:
|
||||
|
||||
1. opens a `BeginChild` region of the requested size (no scrollbar) and forces
|
||||
`ImGuiCol_ChildBg` transparent, so the widget never paints over its own plot;
|
||||
2. intersects the child's rectangle with ImGui's clip rect, so a child the host cuts off
|
||||
does not paint over what is beside it;
|
||||
3. covers the visible region with an `InvisibleButton` and reads `IsItemHovered()`; this is
|
||||
the hover gate (false while another widget is being dragged) and the item that
|
||||
`SetItemKeyOwner(ImGuiKey_MouseWheelY)` claims the wheel with, so the plot's zoom wins
|
||||
over window scrolling;
|
||||
4. maps the child's point rectangle to framebuffer pixels through
|
||||
`io.DisplayFramebufferScale`, gives the controller that region, and renders immediately
|
||||
(`begin_frame`/`draw`/`end_frame`) into it.
|
||||
|
||||
Because ImGui reports button *levels* and the controller wants *edges*, the widget
|
||||
remembers the previous frame's buttons and synthesises `pressed`/`released`; a button
|
||||
pressed over the child keeps being delivered until every button is up, so a drag that
|
||||
leaves the region still ends. ImGui's `(-FLT_MAX, -FLT_MAX)` "no pointer" sentinel is
|
||||
treated as no position update (no movement, buttons still flow).
|
||||
|
||||
Coordinate assumption: the points→pixels mapping is the whole framebuffer's only when the
|
||||
host's GL viewport is the whole framebuffer at origin `(0,0)` and the bound target is the
|
||||
default framebuffer. A host that renders into an FBO or a sub-viewport must place the plot
|
||||
itself with `Renderer::begin_frame()` and `plot.set_viewport()`.
|
||||
|
||||
## Qt6 — `plot::qt6`
|
||||
|
||||
`#include <qt6/qt_plot_widget.hpp>`, link `plot::qt6`. `PlotWidget` is a `QOpenGLWidget`,
|
||||
so it draws into the FBO Qt binds before `paintGL()`, and `QOpenGLWidget` needs no target
|
||||
of its own.
|
||||
|
||||
**Precondition, set before the widget exists** (a `QOpenGLWidget` is handed the
|
||||
application default `QSurfaceFormat` and cannot renegotiate):
|
||||
|
||||
```cpp
|
||||
QSurfaceFormat fmt;
|
||||
fmt.setVersion(3, 3);
|
||||
fmt.setProfile(QSurfaceFormat::CoreProfile);
|
||||
fmt.setSwapBehavior(QSurfaceFormat::DoubleBuffer);
|
||||
fmt.setDepthBufferSize(0);
|
||||
fmt.setStencilBufferSize(0);
|
||||
QSurfaceFormat::setDefaultFormat(fmt);
|
||||
QCoreApplication::setAttribute(Qt::AA_UseDesktopOpenGL); // before QApplication
|
||||
```
|
||||
|
||||
Lifecycle: `initializeGL()` calls `initializeOpenGLFunctions()`, `plot::gl::load()`,
|
||||
`Renderer::init()` and records the outcome in `init_status()` (a `QOpenGLWidget` virtual
|
||||
cannot return a `Status`; it does not assert). `resizeGL()` refreshes the viewport so a
|
||||
gesture arriving before the next paint maps against the new geometry. `paintGL()` sets the
|
||||
viewport on the controller, runs the frame callback, sets `ui_scale` to
|
||||
`devicePixelRatioF()`, then `begin_frame`/`draw`/`end_frame`, and records frame timings
|
||||
(`frames_painted()`, `last_frame_ms()`, `mean_frame_ms()`).
|
||||
|
||||
Input translation: Qt reports press/release edges directly, so `mousePressEvent` /
|
||||
`mouseMoveEvent` / `mouseReleaseEvent` / `mouseDoubleClickEvent` / `wheelEvent` /
|
||||
`keyPressEvent` become `PointerEvent`/`WheelEvent`/`KeyEvent`. Every position is multiplied
|
||||
by `devicePixelRatioF()`; the *anchor* a move's delta is measured from is kept in logical
|
||||
pixels, so a drag crossing onto a display with another scale factor pans by the distance
|
||||
moved rather than a cross-scale jump (`PointerAnchor`, `qt_pointer.hpp`). Qt's
|
||||
`angleDelta()` is in eighths of a degree (120 per notch) and is converted; a wheel with no
|
||||
vertical component is refused before it reaches the controller.
|
||||
|
||||
Include-order note: this header includes `plot/gl/api.hpp` before `<QOpenGLWidget>`,
|
||||
because Qt reaches `<GL/gl.h>` whose macros would rewrite `plot::gl`'s enumerators. A
|
||||
translation unit using the widget must keep that order and must not name a `plot::gl::GL_*`
|
||||
enumerator after a Qt GL header.
|
||||
|
||||
## GTK4 — `plot::gtk4`
|
||||
|
||||
`#include <gtk4/gtk_plot_area.hpp>`, link `plot::gtk4`. GTK cannot have a GObject subclass
|
||||
declared around a C++ class (and `GtkGLArea`'s data is private), so this adapter is a
|
||||
factory, not a subclass:
|
||||
|
||||
```cpp
|
||||
plot::gtk4::PlotArea* area = plot::gtk4::PlotArea::create(plot);
|
||||
GtkWidget* widget = area->widget(); // put it in your container
|
||||
```
|
||||
|
||||
`create()` builds a `gtk_gl_area_new()`, registers the signals and controllers that drive
|
||||
it, and returns the wrapper — which GTK owns and frees with the widget; never delete it.
|
||||
|
||||
Signals: `::realize` makes the context current, checks `gtk_gl_area_get_error()`, loads the
|
||||
entry points and initialises the renderer (recording the outcome in `init_status()`);
|
||||
`::unrealize` releases the renderer's GL objects while the context can still be made
|
||||
current; `::resize` derives the device-pixel mapping from the widget's logical allocation
|
||||
and the device size GTK reports, and refreshes the viewport; `::render` sets the viewport on
|
||||
the controller, runs the frame callback, sets `ui_scale` to the mapping's factor, and draws.
|
||||
|
||||
Input translation uses GTK's gesture recognisers: `GtkGestureDrag` supplies motion,
|
||||
`GtkGestureClick` supplies the press/release edges, `GtkEventControllerMotion` supplies
|
||||
hover (forwarded with `down == false`, so a drag is not applied twice), and
|
||||
`GtkEventControllerScroll` (vertical) and `GtkEventControllerKey` supply the wheel and the
|
||||
`+`/`-`/`f` keys. GTK emits `drag-begin` before the click's `pressed`, so the adapter opens
|
||||
its gesture from the first press and closes it from the first release. The area queues a
|
||||
render after every input event that can change the picture (not on hover).
|
||||
|
||||
A `GtkGLArea` renders into its own FBO, so a screenshot of the *plot* is a readback of that
|
||||
FBO, not of the window (GTK's chrome lives outside it) — see the demo's `dump_area_ppm`.
|
||||
@@ -0,0 +1,171 @@
|
||||
# Getting started
|
||||
|
||||
`plot` is a C++20 library for real-time, high-sample-rate time-series plots drawn with
|
||||
OpenGL 3.3 core. The core library — the CMake target `plot::core` — has no third-party
|
||||
dependency at all; the host adapters for GLFW, Dear ImGui, Qt6 and GTK4 are separate,
|
||||
opt-in targets on top of it (see [embedding.md](embedding.md)).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A C++20 compiler.
|
||||
- CMake **3.24 or newer**. `cmake/plot_options.cmake` fails the configure with a
|
||||
`FATAL_ERROR` below that, and the presets in `CMakePresets.json` declare
|
||||
`cmakeMinimumRequired` 3.24.
|
||||
- A driver exposing an **OpenGL 3.3 core** profile. `plot::gl::load()` resolves only the
|
||||
3.3 entry points the library actually calls; anything newer (persistent buffer mapping,
|
||||
sync objects) is optional, probed through `plot::gl::caps()` and only used when the
|
||||
driver offers it.
|
||||
- To build the **test suite** you also need EGL: `tests/CMakeLists.txt` fails the configure
|
||||
without it, because the GL tests create a real headless EGL context. The library itself
|
||||
does not need EGL.
|
||||
- Each adapter needs its own toolkit (GLFW, Dear ImGui downloaded at configure time, Qt6 or
|
||||
GTK4) — see [building-and-testing.md](building-and-testing.md) for the options.
|
||||
|
||||
## Build and install
|
||||
|
||||
The presets live in `CMakePresets.json`:
|
||||
|
||||
```sh
|
||||
cmake --preset linux-release # or linux-debug, linux-asan, linux-coverage
|
||||
cmake --build build/release
|
||||
cmake --install build/release --prefix /where/you/want
|
||||
```
|
||||
|
||||
Install is on by default (`PLOT_INSTALL`, `ON`). It installs the `plot_core` archive, the
|
||||
public headers under `include/plot/`, and a CMake package config under
|
||||
`lib/cmake/plot/`. Adapters join the install/export set only when their toolkit can be
|
||||
re-found on a consumer's machine; the configure log records which ones were exported and
|
||||
why.
|
||||
|
||||
## Consuming it
|
||||
|
||||
```cmake
|
||||
find_package(plot 0.1 REQUIRED)
|
||||
target_link_libraries(my_app PRIVATE plot::core)
|
||||
```
|
||||
|
||||
`plot::core` is the alias of the `plot_core` target (`EXPORT_NAME core`, so the installed
|
||||
name matches the build-tree name). It carries the C++20 requirement and the include
|
||||
directory as its public interface and nothing else. The exported adapters are named
|
||||
`plot::glfw`, `plot::qt6`, `plot::gtk4` and (when built) `plot::font_ttf`;
|
||||
`plot::imgui` is a build-tree target only, because ImGui is supplied by `FetchContent` and
|
||||
there is no ImGui package for an installed config file to point at.
|
||||
|
||||
Every public component is reachable through the umbrella header:
|
||||
|
||||
```cpp
|
||||
#include <plot/plot.hpp> // core, math, gl, data, render, plot, input — in layer order
|
||||
```
|
||||
|
||||
Adapters are deliberately **not** in the umbrella: their headers are reached as
|
||||
`<glfw/glfw_host.hpp>`, `<imgui/imgui_plot.hpp>`, `<qt6/qt_plot_widget.hpp>` and
|
||||
`<gtk4/gtk_plot_area.hpp>`, so including the umbrella never drags a toolkit in.
|
||||
|
||||
## A minimal GLFW program
|
||||
|
||||
The program below opens a window, creates a 3.3 core context, loads the GL entry points,
|
||||
and draws one trace. It uses the GLFW toolkit for the window and `plot::core` directly —
|
||||
it does not use the shipped `plot::glfw::Host` adapter, so it also shows the pieces the
|
||||
adapter hides (window, context, loader, renderer, controller, frame loop).
|
||||
|
||||
It was compiled and linked as part of writing this page:
|
||||
|
||||
```sh
|
||||
g++ -std=c++20 -I include main.cpp build/debug/libplot_core.a \
|
||||
$(pkg-config --libs glfw3) -ldl -lpthread -o plot_quickstart
|
||||
```
|
||||
|
||||
```cpp
|
||||
// A minimal plot: one window, one axis pair, one trace.
|
||||
#include <plot/plot.hpp> // the whole public surface, in layer order
|
||||
#include <plot/gl/loader.hpp>
|
||||
|
||||
#define GLFW_INCLUDE_NONE // keep GLFW from including <GL/gl.h> and its macros
|
||||
#include <GLFW/glfw3.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
// GLFW hands back `void (*)(void)`; plot::gl::ProcLoader wants `void*`.
|
||||
void* resolve_gl(const char* name) {
|
||||
GLFWglproc p = glfwGetProcAddress(name);
|
||||
void* out = nullptr;
|
||||
static_assert(sizeof(out) == sizeof(p));
|
||||
std::memcpy(&out, &p, sizeof(out));
|
||||
return out;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
if (glfwInit() != GLFW_TRUE) return 1;
|
||||
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
|
||||
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
|
||||
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
|
||||
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE);
|
||||
GLFWwindow* window = glfwCreateWindow(800, 480, "plot quick-start", nullptr, nullptr);
|
||||
if (window == nullptr) { glfwTerminate(); return 1; }
|
||||
glfwMakeContextCurrent(window);
|
||||
glfwSwapInterval(1);
|
||||
|
||||
plot::Status st;
|
||||
if (!plot::gl::load(&resolve_gl)) { std::fprintf(stderr, "gl::load failed\n"); return 1; }
|
||||
plot::Renderer renderer;
|
||||
if (!renderer.init(&st)) { std::fprintf(stderr, "init failed: %s\n", st.message); return 1; }
|
||||
|
||||
// One trace: 512 samples of a sine, x = sample index.
|
||||
std::vector<plot::f32> y(512);
|
||||
for (plot::usize i = 0; i < y.size(); ++i) {
|
||||
y[i] = static_cast<plot::f32>(std::sin(0.05 * static_cast<double>(i)));
|
||||
}
|
||||
plot::data::ArraySeries series(std::move(y), 0.0, 1.0);
|
||||
|
||||
plot::Plot plot;
|
||||
const plot::u32 x_axis = plot.add_x_axis({"x", {0.0, 511.0}, true});
|
||||
const plot::u32 y_axis =
|
||||
plot.add_y_axis({"signal", {-1.0, 1.0}, true, plot::AxisPlace::Left});
|
||||
plot.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, x_axis, y_axis, "sine"});
|
||||
|
||||
plot::input::Controller controller(plot);
|
||||
|
||||
while (glfwWindowShouldClose(window) == GLFW_FALSE) {
|
||||
glfwPollEvents();
|
||||
int fb_w = 0, fb_h = 0;
|
||||
glfwGetFramebufferSize(window, &fb_w, &fb_h);
|
||||
if (fb_w == 0 || fb_h == 0) continue;
|
||||
const plot::Rect viewport{0.0f, 0.0f, static_cast<plot::f32>(fb_w),
|
||||
static_cast<plot::f32>(fb_h)};
|
||||
controller.set_viewport(viewport);
|
||||
renderer.style().ui_scale = 1.0f; // device pixels per logical unit
|
||||
renderer.begin_frame(static_cast<plot::u32>(fb_w), static_cast<plot::u32>(fb_h), viewport);
|
||||
renderer.draw(plot);
|
||||
renderer.end_frame();
|
||||
glfwSwapBuffers(window);
|
||||
}
|
||||
|
||||
glfwDestroyWindow(window);
|
||||
glfwTerminate();
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
Things worth noticing, because they are the embedding contract in miniature:
|
||||
|
||||
- `plot::gl::load()` must run on the thread that owns the current context, and
|
||||
`Renderer::init()` returns `false` with a `Status` when the load failed or the driver
|
||||
refused a shader/buffer. The renderer never creates, binds or resizes a framebuffer.
|
||||
- `begin_frame(fb_w, fb_h, viewport_px)` takes the framebuffer size (for the projection)
|
||||
and the top-left-origin rectangle the plot occupies; `draw()` fills exactly that region;
|
||||
`end_frame()` restores the GL state it changed.
|
||||
- The `Plot` is pure CPU state. When you draw it through a `Renderer`, set colours and
|
||||
metrics on `renderer.style()`, not on the plot — `draw()` copies the style into the plot
|
||||
and hands it the `ui_scale` before laying out.
|
||||
- `ui_scale` is device pixels per logical unit. Here it is `1.0` because the program uses
|
||||
the framebuffer 1:1; a HiDPI host passes the framebuffer/window ratio instead (see
|
||||
[embedding.md](embedding.md)).
|
||||
|
||||
Next: [architecture.md](architecture.md) for how the pieces fit, [embedding.md](embedding.md)
|
||||
to put a plot into a real host, and [data-and-performance.md](data-and-performance.md) for
|
||||
feeding it a live stream.
|
||||
@@ -0,0 +1,209 @@
|
||||
# Interaction
|
||||
|
||||
Everything in this page is driven by `input::Controller` (`include/plot/input/controller.hpp`,
|
||||
`src/input/controller.cpp`), which turns host events into gestures knowing nothing about Qt,
|
||||
GTK, GLFW or ImGui. It is pure CPU and needs no GL context and no font — everything it reads
|
||||
comes from the `Plot`'s layout, ranges and transforms. It owns no viewport of its own:
|
||||
`Controller::set_viewport(Rect)` forwards to `Plot::set_viewport()` and refreshes the layout,
|
||||
so the two cannot disagree.
|
||||
|
||||
The plot's own state — cursors, regions, the measurement, the legend, the crosshair — is
|
||||
owned by `Plot`; the controller only moves it and publishes what is hot.
|
||||
|
||||
## Cursors
|
||||
|
||||
A cursor is a draggable marker at a data coordinate (`Cursor`, `plot/cursor.hpp`). There are
|
||||
two orientations, one type:
|
||||
|
||||
| orientation | created by | live coordinate | live snap | dead fields |
|
||||
|---|---|---|---|---|
|
||||
| `CursorOrientation::Vertical` | `Plot::add_cursor(f64 x, Color)` | `x` on `axis_x` | `SnapMode` | `y`, `h_snap`, `axis_y` |
|
||||
| `CursorOrientation::Horizontal` | `Plot::add_h_ruler(f64 y, Color)` | `y` on `axis_y` | `HRulerSnap` | `x`, `snap`, `axis_x` |
|
||||
|
||||
A vertical cursor is a marker at `x` spanning the axes' **height**; a horizontal ruler is at
|
||||
`y` spanning their **width**. Dead fields are *ignored*, not honoured: `set_cursor_y()` on a
|
||||
vertical cursor and `set_cursor_x()` on a ruler change nothing, and neither does the other
|
||||
orientation's snap setter.
|
||||
|
||||
Snaps. `SnapMode::NearestSample` pulls a marker onto the nearest sample of the first visible
|
||||
trace on its x axis; `SnapMode::Free` leaves it. `HRulerSnap::NearestTick` pulls a ruler onto
|
||||
the nearest tick of its y axis' tick set (the *last* `layout()`'s set — ticks are generated
|
||||
there and nowhere else); `HRulerSnap::Free` leaves it.
|
||||
|
||||
Grab radius. A pointer within **7 logical pixels** (`kCursorHitRadiusLogical`) of a cursor's
|
||||
line grabs it. The nearest of a vertical and a horizontal cursor wins; neither orientation
|
||||
widens the other's reach. The distance is logical, converted through `Style::device()` at the
|
||||
plot's scale, so the aim is the same physical distance on any display.
|
||||
|
||||
Snap on drop. `set_cursor_x()`/`set_cursor_y()` take a `CursorPlacement`: `Snapped` (the
|
||||
default) applies the cursor's snap rule immediately — the programmatic path; `Free` stores
|
||||
the value verbatim and leaves the snap to `Plot::snap_cursor()`. The controller uses `Free`
|
||||
while dragging, so the marker tracks the pointer under the hand and the snap is committed
|
||||
once, on release. A dragged ruler is additionally clamped to its own y axis' range, because a
|
||||
pointer past the top or bottom of the axes has no data under it.
|
||||
|
||||
## Regions
|
||||
|
||||
A region is a shaded x interval with exact statistics (`Region`, `plot/region.hpp`). Create
|
||||
with `Plot::add_region(const Region&)`; `Region::snap` is `RegionSnap::NearestSample` or
|
||||
`Free`. The bounds are **ordered**: `set_region(id, x0, x1)` stores min/max, so a drag that
|
||||
crosses one bound over the other **swaps** them rather than inverting the region. A pair of
|
||||
bounds that is not both finite is refused (nothing changes).
|
||||
|
||||
Three grab zones (`RegionGrab`): `Left` (the bound at `x0`), `Right` (the bound at `x1`) and
|
||||
`Body` (the interior). The controller resolves them nearest-first against the geometry
|
||||
`Plot::layout()` measured — **a bound within the grab radius beats the body**, and the nearer
|
||||
of the two bounds wins; with no bound in range, the topmost region whose interior holds the
|
||||
pointer is the body. That geometry is the same `RegionLayout` the renderer paints, so what is
|
||||
drawn and what can be grabbed cannot drift apart.
|
||||
|
||||
The bound band is the same 7-logical-pixel radius, cut back so a narrow region keeps a
|
||||
middle a body drag can reach (`min(radius, (width − 2)/2)`, with a minimum interior of
|
||||
**2 logical pixels**, `kMinRegionBodyLogical`). A region narrower than **3 logical pixels**
|
||||
(`kMinRegionEdgeGrabLogical`) is grabbed by its body instead, widened to that same minimum —
|
||||
otherwise its two bounds would be one target and it could never be moved again. `Body` drag
|
||||
translates both bounds by one delta, preserving the width exactly; a bound drag moves one end
|
||||
and holds the other. A snapping region is put on the sample grid once, on release
|
||||
(`snap_region()`), not on every motion. `remove_region(id)` drops the region and shifts the
|
||||
ids above it down.
|
||||
|
||||
## The measurement band
|
||||
|
||||
`Plot::add_measure(cursor_a, cursor_b, trace = 0)` measures the interval between two cursors
|
||||
over the series of `trace`. The renderer draws the shaded band the readout belongs to from
|
||||
`Plot::measure_span(id)` (which reports the cursor pair), and the statistics come from
|
||||
`Plot::measurement(id)`: `dx`, `dy`, `slope`, `mean`, `rms`, `ymin`, `ymax` and the
|
||||
left-Riemann `integral`. The band's edges *are* those cursors, so it is resized by dragging
|
||||
either of them. A measurement is re-evaluated only when a cursor moved, when it was added, or
|
||||
when the series underneath changed — never once per frame; `const` because recomputing fills
|
||||
a cache.
|
||||
|
||||
## The legend
|
||||
|
||||
The legend is on by default (`Legend::visible = true`) and free until a trace is named: with
|
||||
no named visible trace — or no text backend to measure a name with — `Plot::layout()` produces
|
||||
an empty layout and nothing is drawn. A trace earns an entry by being given a `name`
|
||||
(`TraceDesc::name`/`Trace::name`). Configure it with `Plot::set_legend(const Legend&)`;
|
||||
`Legend::place` anchors the panel to a corner of the axes (`TopLeft`, `TopRight`,
|
||||
`BottomLeft`, `BottomRight`) and `Legend::margin` is both the anchor's inset and the budget
|
||||
the panel has to fit in, so a panel that does not fit drops entries and ends with `...` rather
|
||||
than overhanging. See [styling.md](styling.md) for the knobs and their defaults.
|
||||
|
||||
## The crosshair
|
||||
|
||||
The crosshair is a **hover** instrument, not a cursor: it follows the pointer and exists only
|
||||
while the pointer is over the data area (`Crosshair`, `plot/crosshair.hpp`). There is at most
|
||||
one per plot.
|
||||
|
||||
- `Plot::set_crosshair(f64 x, bool active)` places a **vertical-only** crosshair: the column
|
||||
of `x`, a marker on every visible trace at that x, and the readout panel beside it. This is
|
||||
the form for a caller that has a coordinate and not a pointer.
|
||||
- `Plot::set_crosshair(f64 x, f64 y, bool active)` adds the **horizontal** line at `y`. This is
|
||||
the form the controller uses, because a pointer over the axes has both coordinates.
|
||||
- `Plot::clear_crosshair()` hides it, leaving it where it was. The controller calls this when
|
||||
the pointer leaves the axes; a press, wheel or key that moves the data under the pointer
|
||||
*re-anchors* the crosshair rather than clearing it.
|
||||
- `set_crosshair_readout(bool)` switches the panel off — or back on — without moving the
|
||||
crosshair. `set_crosshair_style(const CrosshairStyle&)` overrides this plot's stroke and
|
||||
dash.
|
||||
|
||||
In a `Figure`, the crosshair is applied to **every row** (one shared `x`): each row's readout
|
||||
lists its own traces at that instant. `Figure::set_crosshair(x, active)` is vertical-only
|
||||
across the figure, and `Figure::set_crosshair(x, hovered_row, y_in_row, active)` adds the
|
||||
horizontal line **only in the row the pointer is in** — a figure's rows share x and not y, so
|
||||
a figure-wide horizontal line would be a value that means something different in each row.
|
||||
|
||||
Readout values are **interpolated**, not interval statistics (see
|
||||
[data-and-performance.md](data-and-performance.md)); a row whose trace has no sample at the
|
||||
crosshair's x is listed with the em dash `"—"` and no marker, rather than a fabricated value.
|
||||
|
||||
## The gesture table (exactly as implemented)
|
||||
|
||||
A press opens the first of these that applies, in this order (from `Controller::begin_drag`
|
||||
and `Controller::begin_handle_drag`):
|
||||
|
||||
| gesture | drag mode | effect |
|
||||
|---|---|---|
|
||||
| **shift + left drag** | `BoxZoom` | both ranges from the selection rect, applied on release |
|
||||
| **middle drag on the x label strip** | `AxisZoomX` | x-only zoom |
|
||||
| **middle drag on the y label strip** | `AxisZoomY` | y-only zoom |
|
||||
| middle drag anywhere else | — | nothing |
|
||||
| **left or right drag on a handle** | `MoveCursor` / `MoveRegion` / `ResizeRegionLeft` / `ResizeRegionRight` | move the grabbed handle |
|
||||
| **left or right drag elsewhere** | `Pan` | translate both ranges |
|
||||
| **wheel** | — | zoom about the pointer (shift: x only, ctrl: y only; neither or both: both axes) |
|
||||
| **`+` / `-`** | — | zoom about the plot centre |
|
||||
| **`f`** (or `F`) | — | `auto_fit_x()` / `auto_fit_y()` every axis |
|
||||
|
||||
Precedence for a press that is not shift+left and not middle: **cursor marker → region bound
|
||||
→ region body → pan**. Shift is checked first of all, so a selection box is what shift+left
|
||||
means even when it starts over a handle. The right button **pans and grabs** exactly as the
|
||||
left does — both are buttons a host drives drags with, so neither is singled out for handles.
|
||||
`Ctrl` is not needed for a cursor move and does not change what a press on one does.
|
||||
|
||||
Values behind the gestures:
|
||||
|
||||
- one wheel notch (or one `+`/`-` press) changes the span by `exp(-0.15)` (~14%,
|
||||
`kZoomPerNotch`); positive = zoom in;
|
||||
- a middle-drag axis zoom is one decade per **200 logical pixels**
|
||||
(`kZoomPerPixel = ln10 / 200`), with the wheel's sign convention (dragging down zooms in);
|
||||
- a box-zoom selection under **4 logical pixels** on either side (`kMinBoxLogical`) is a
|
||||
click and leaves the ranges alone.
|
||||
|
||||
Events are sanitised before they act: a non-finite coordinate or delta becomes the last known
|
||||
position and no movement, but the buttons still flow, so a drag whose release arrives with a
|
||||
garbage position still ends. A wheel amount that is not a number is refused outright (a
|
||||
zeroed notch would still run the range setters, which clear auto scale). A gesture that sets
|
||||
a range also clears that axis' `auto_scale` — one place, in `Plot`'s range setters.
|
||||
|
||||
What the pointer is on is published to the plot (`Plot::set_hot_handles()`) after every event
|
||||
that can move the geometry under the pointer, so the renderer draws hovered/grab highlights
|
||||
without knowing what a pointer is. `hovered_cursor()`, `grabbed_cursor()`, `hovered_region()`,
|
||||
`grabbed_region()` and `region_grab(u32)` expose the same answers to a host (a resize cursor, a
|
||||
tooltip, a status line).
|
||||
|
||||
## Live follow
|
||||
|
||||
An x axis has a follow state, `XFollow::Off` (the default) or `XFollow::Live`
|
||||
(`Plot::set_x_follow(axis, f)`). `Live` makes the visible window a view from the live edge:
|
||||
every `layout()` places it at `[newest − offset − span, newest − offset]`, where `newest` is
|
||||
the newest x any visible streaming trace bound to the axis holds (`Plot::x_live_edge()`).
|
||||
`Off` means the range is exactly what a caller set or an auto-fit produced.
|
||||
|
||||
**`set_x_range()` on a live axis is not an absolute window.** A range handed to
|
||||
`Plot::set_x_range(axis, r)` while the axis is `Live` *and* has a live edge is read as the
|
||||
**span** the window should have and the **offset** it implies — `offset = newest − r.max`,
|
||||
clamped to what the traces still retain — and the visible window follows from those at the
|
||||
live edge of the moment. `XFollow::Off` stores the range verbatim. The point of the
|
||||
reinterpretation is that every gesture (pan, wheel, box zoom, axis zoom) reaches the x range
|
||||
through this one setter, so they all keep following for free.
|
||||
|
||||
The offset is clamped to `[0, retained span]` (`Plot::set_x_offset()`, read back with
|
||||
`Plot::x_offset()`): the upper limit is what the traces still hold, so the window's right
|
||||
edge never goes behind the oldest retained sample (and the view is left showing the dropped
|
||||
data rather than inventing samples that are gone); the lower limit is why dragging toward the
|
||||
live edge stops instead of turning the window inside out. A non-finite offset is refused. The
|
||||
clamp is applied again by `layout()` every frame.
|
||||
|
||||
Consequences while an axis is `Live` *and* has a live edge:
|
||||
|
||||
- `auto_scale` is **inert** on that axis: `layout()` does not fit it, because a fit would
|
||||
overwrite the span (the user's zoom) on every frame. The follow state wins; `auto_fit_x()`
|
||||
is how such an axis is framed (it fits, sets the span to the retained window's, and returns
|
||||
the offset to 0 without turning following off).
|
||||
- Nothing but `set_x_follow()` disarms following. No gesture and no `set_x_range()` reaches
|
||||
the follow state.
|
||||
- **Zoom is relative (multiplicative)**: the wheel multiplies the span, so notches compound,
|
||||
and the anchor is kept still by adjusting the offset. **Pan** shifts the offset by the
|
||||
drag's data delta and leaves the span alone, so a user can walk back through the retained
|
||||
window and stops at the live edge.
|
||||
|
||||
Arming `Live` on an axis with a live edge places its window at the live edge at once, from the
|
||||
span it already had. Turning `Live` off leaves the axis where it is: the last live window
|
||||
becomes an ordinary stored range.
|
||||
|
||||
**Figures.** In a `Figure`, the follow state belongs to the link **group**, not the row: a
|
||||
group pushes one window to many rows, so the state has to be the group's or the same range
|
||||
would mean a different window in each member. Use `Figure::add_x_link()`, `link_x(row, group)`,
|
||||
`set_x_link_range(group, r)` and `set_x_link_follow(group, XFollow::Live)`; a group derives
|
||||
its window once per frame from the newest x across all its members' traces. `link_x()` refuses
|
||||
a member whose own axis is `Live`.
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
# Styling
|
||||
|
||||
Every colour and metric the renderer draws with lives in `render::Style`
|
||||
(`include/plot/render/style.hpp`). A caller sets fields on `Renderer::style()`; the next frame
|
||||
picks them up, because nothing here is baked into a shader.
|
||||
|
||||
Set colours and metrics on the **renderer**, not on the plot: `Renderer::draw()` copies its
|
||||
`Style` into the plot and hands it the `ui_scale` *before* it lays out, so the strips, tick
|
||||
densities and strokes the plot measures with are the values the renderer draws with. A plot
|
||||
drawn without a renderer lays out with whatever `Plot::set_style()` was given.
|
||||
|
||||
## The unit rule: logical versus device
|
||||
|
||||
A metric a user perceives as a **size** — a font, a stroke width, a padding, a marker, a
|
||||
swatch, the minimum spacing between ticks — is written in **logical** units, so a 12 px label
|
||||
is the same physical size on a 1x and a 2x panel. The host states its display with `ui_scale`
|
||||
(device pixels per logical unit); every consumer converts exactly once, through `device()`:
|
||||
|
||||
```cpp
|
||||
f32 device(f32 logical) const noexcept {
|
||||
return ui_scale > 0.0f ? logical * ui_scale : logical; // non-positive scale converts 1:1
|
||||
}
|
||||
```
|
||||
|
||||
`Plot::layout()` uses it to size the label strips, title strips and legend chrome; the renderer
|
||||
uses it to convert widths and font px (the font is reset at `device(axis_font_px)`).
|
||||
|
||||
The one exception is `dense_threshold_spp`, which is a **density** (samples per *rasterised*
|
||||
pixel), compared against a ratio computed from the framebuffer's own width. Scaling it would
|
||||
make the chosen LOD level depend on the display rather than on the data, so it is used raw.
|
||||
|
||||
## `Style` fields
|
||||
|
||||
Colours (neither logical nor device — nothing to scale):
|
||||
|
||||
| field | default | meaning |
|
||||
|---|---|---|
|
||||
| `background` | `0x101418` | frame clear colour and legend/readout panel base |
|
||||
| `grid_major` | `0x2A3038` | major (labelled-tick) grid lines |
|
||||
| `grid_minor` | `0x1C2126` | minor (subdivision) grid lines |
|
||||
| `axis_line` | `0x8A94A6` | axis lines |
|
||||
| `axis_text` | `0xC8D0DC` | tick labels and axis titles |
|
||||
| `cursor` | `0xFFB020` | cursor markers; also the hovered/grab highlight |
|
||||
| `measure_fill` | `0xFFB020` at α 0.15 | the measurement band |
|
||||
| `crosshair` | `0xE6EBF2` | crosshair lines and the readout panel's border (hue-neutral by design; trace markers keep each trace's own colour) |
|
||||
|
||||
The scale:
|
||||
|
||||
| field | default | unit |
|
||||
|---|---|---|
|
||||
| `ui_scale` | `1.0` | device pixels per logical unit; supplied by the host |
|
||||
|
||||
Logical metrics (scaled by `device()`):
|
||||
|
||||
| field | default | meaning |
|
||||
|---|---|---|
|
||||
| `axis_font_px` | `12.0` | logical em the axis text is drawn at |
|
||||
| `grid_label_pad` | `4.0` | gap between a tick label and the axes, and the inset inside a title strip |
|
||||
| `grid_major_width` | `1.0` | width of a major grid line |
|
||||
| `grid_minor_width` | `1.0` | width of a minor grid line |
|
||||
| `grid_major_visible` | `true` | stage the major grid at all |
|
||||
| `grid_minor_visible` | `true` | stage the minor grid at all |
|
||||
| `line_width_px` | `1.5` | width of the strokes framing the data (axis lines, cursor markers) |
|
||||
| `handle_width_px` | `5.0` | width of a highlighted draggable handle (a hovered/grabbed cursor tab, a region's hot bound) |
|
||||
| `tick_min_px` | `60.0` | minimum spacing between major ticks |
|
||||
|
||||
Crosshair stroke and dash (logical):
|
||||
|
||||
| field | default | meaning |
|
||||
|---|---|---|
|
||||
| `crosshair_width` | `0.75` | the crosshair's stroke width (deliberately thinner than a trace's `1.5`) |
|
||||
| `crosshair_dash_on` | `4.0` | ink length of the dash |
|
||||
| `crosshair_dash_off` | `4.0` | gap length of the dash; **`0` means solid** |
|
||||
|
||||
Device metric (never scaled):
|
||||
|
||||
| field | default | meaning |
|
||||
|---|---|---|
|
||||
| `dense_threshold_spp` | `1.5` | samples per device pixel at or above which a trace is gathered from the LOD pyramid instead of from raw samples |
|
||||
|
||||
## The grid
|
||||
|
||||
The grid's **positions** come from the tick set alone: `Grid::build()` emits one `GridLine`
|
||||
per tick, spanning the perpendicular axis' full range, tagged `major` (a labelled tick) or
|
||||
not. `Style` decides only how each class is drawn — its width and visibility — and the two
|
||||
colours above.
|
||||
|
||||
A class switched off stages **no geometry at all**: the renderer skips it before the first
|
||||
segment is staged, so a dense plot's minor grid costs nothing rather than being drawn
|
||||
transparent. `Grid` therefore carries both classes' geometry whatever the style says; a
|
||||
caller that draws the grid itself applies the same two switches. `Grid::vertical` and
|
||||
`Grid::horizontal` (both `true`) control which directions emit lines at all.
|
||||
|
||||
## The crosshair's dash
|
||||
|
||||
`crosshair_dash_on`/`crosshair_dash_off` are the fragment shader's discard pattern, so no
|
||||
geometry is added. `dash_off == 0` draws a solid line — the value every other line in the
|
||||
library uses, so the one pipeline serves them unchanged. A single plot may override all three
|
||||
knobs without touching the global `Style` through `CrosshairStyle` (below).
|
||||
|
||||
## `Legend`
|
||||
|
||||
`Legend` (`include/plot/legend.hpp`), set with `Plot::set_legend()`. All sizes are **logical**,
|
||||
converted through `Style::device()` when `Plot::layout()` measures the panel.
|
||||
|
||||
| field | default | meaning |
|
||||
|---|---|---|
|
||||
| `visible` | `true` | draw the legend at all (free when nothing is named) |
|
||||
| `place` | `LegendPlace::TopRight` | corner of `axes` the panel is anchored to |
|
||||
| `frame` | `true` | paint the panel (background at partial alpha plus a 1-px border) |
|
||||
| `pad` | `6.0` | inset from the panel edge to its first row, and the swatch↔name gutter (one knob for both) |
|
||||
| `swatch_w` | `14.0` | swatch width |
|
||||
| `swatch_h` | `8.0` | swatch height; the row height is `max(swatch_h, line height)` |
|
||||
| `row_gap` | `3.0` | blank space between rows |
|
||||
| `margin` | `8.0` | inset from the axes edge, *and* the budget the panel must fit in (so it drops entries rather than overhanging) |
|
||||
|
||||
There is no "outside the plot" placement: the strips around `axes` belong to the tick labels
|
||||
and axis titles, and a legend that reserved space would resize the plot the moment a trace was
|
||||
named. A truncated legend ends with the constant `kLegendEllipsis` (`"..."`).
|
||||
|
||||
## `CrosshairStyle`
|
||||
|
||||
A per-`Crosshair` override of the three crosshair knobs, in logical pixels
|
||||
(`include/plot/crosshair.hpp`), set with `Plot::set_crosshair_style()`. Every field defaults to
|
||||
`-1.0`, which means "not overridden": the field falls back to the matching `Style::crosshair_*`
|
||||
metric, so a default-constructed `CrosshairStyle` is exactly the global style.
|
||||
|
||||
| field | default | meaning |
|
||||
|---|---|---|
|
||||
| `width` | `-1.0` | logical stroke width; `< 0` takes `Style::crosshair_width` |
|
||||
| `dash_on` | `-1.0` | logical ink length; `< 0` takes `Style::crosshair_dash_on` |
|
||||
| `dash_off` | `-1.0` | logical gap; `< 0` takes `Style::crosshair_dash_off`, and `0` (once resolved) means solid |
|
||||
|
||||
## Traces
|
||||
|
||||
A trace's own `width` (`TraceDesc::width`, `Trace::width`) is **logical** too — a stroke like
|
||||
`line_width_px`, converted by the same `device()` call. Its default colour is `0x4FA3FF` and
|
||||
its default width `1.5`. Region fill/edge colours are the region's own (`Region::fill`
|
||||
`0xFFB020` at α 0.15, `Region::edge` `0xFFB020`); a cursor's colour is per-cursor
|
||||
(`0xFFB020` by default).
|
||||
|
||||
## Installing a text backend
|
||||
|
||||
The font a plot measures with and the renderer draws with is a `render::TextBackend`
|
||||
(`include/plot/render/text.hpp`): `measure()`, `height()`, `ascent()`, `em_px()`, `glyph()`,
|
||||
`cell_uv()`, `atlas_pixels()`, `atlas_width()`, `atlas_height()` and `revision()`.
|
||||
|
||||
- `Plot::set_text_backend(const render::TextBackend*)` sets the backend a plot lays its labels
|
||||
out with. It is **borrowed**, not owned, and may be null (then the label areas fall back to
|
||||
`PlotLayout`'s defaults and no string is measured).
|
||||
- `Renderer::set_text_backend(const render::TextBackend*)` sets the face the renderer draws
|
||||
with. It is also borrowed and must outlive the frames.
|
||||
|
||||
**Note the interaction:** `Renderer::init()` binds `render::EmbeddedMonoFont`, and every
|
||||
`Renderer::draw()` installs that binding on the plot it is given — replacing whatever a host
|
||||
set with `Plot::set_text_backend()` (a debug build names the one it dropped). So installing a
|
||||
backend on the *plot* only takes effect for a plot no renderer draws. To draw with your own
|
||||
face (say a `plot::text::TrueTypeFont` from the optional `plot::font_ttf` target), install it
|
||||
with `Renderer::set_text_backend()`; the swap takes effect on the next frame, which re-reads
|
||||
the backend's metrics at `style().device(axis_font_px)` and re-uploads its atlas when the
|
||||
backend's `revision()` moved. Passing `nullptr` restores the embedded face.
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
// The reference application: the whole library running as a real program, fed by
|
||||
// a 1 MS/s producer thread through the GLFW adapter.
|
||||
//
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
// The GTK4 application: the same 1 MS/s stream the other demos draw, as the
|
||||
// content of a GtkApplicationWindow whose header bar carries its own "Fit"
|
||||
// button, with GTK drawing the window chrome around a plot the area renders into
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
// The Dear ImGui application: the same stream the GLFW demo draws, embedded as a
|
||||
// widget inside an ImGui window that also owns a few controls, with ImGui drawing
|
||||
// its own chrome into the same framebuffer.
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
// The multi-plot application: three plots stacked in one `Figure`, sharing one x
|
||||
// axis, each carrying its own signals and its own legend.
|
||||
//
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
// The Qt application: the same 1 MS/s stream the other demos draw, as the central
|
||||
// widget of a QMainWindow that also carries its own toolbar, with Qt drawing the
|
||||
// window chrome around a plot the widget renders into its own framebuffer.
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <plot/core/types.hpp>
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <plot/core/types.hpp>
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <cstddef>
|
||||
#include <span>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <plot/core/types.hpp>
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <vector>
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
|
||||
#ifdef PLOT_PYRAMID_COUNT_COVERS
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
|
||||
#ifdef PLOT_PYRAMID_COUNT_MERGES
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <deque>
|
||||
#include <vector>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <atomic>
|
||||
#include <bit>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <vector>
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <vector>
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <plot/core/types.hpp>
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <plot/gl/api.hpp>
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <plot/core/color.hpp>
|
||||
#include <plot/core/status.hpp>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <plot/gl/api.hpp>
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <plot/core/rect.hpp>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <plot/core/types.hpp>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <plot/core/types.hpp>
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <cmath>
|
||||
#include <plot/core/types.hpp>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <cmath>
|
||||
#include <plot/core/types.hpp>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
|
||||
/// Umbrella header: pulls in every public component of the plot library, in
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <string>
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -76,6 +79,13 @@ struct Axis {
|
||||
/// group stops following or the row leaves it.
|
||||
bool live_owner = false;
|
||||
f64 live_owner_newest = 0.0;
|
||||
/// Set while this plot is a member of a `Figure` x link group (`Figure::link_x()`
|
||||
/// writes it, `unlink_row()` clears it). The group owns the shared x window *and*
|
||||
/// its follow state -- `Figure::link_x()` refuses a `Live` member joiner -- so
|
||||
/// `Plot::set_x_follow(Live)` on this axis is refused after linking too, rather
|
||||
/// than letting a later call put the member's axis back into a state the group
|
||||
/// cannot drive (see `Plot::set_x_follow()`).
|
||||
bool link_member = false;
|
||||
/// Ticks for the last `refresh()`, ascending, majors and (when asked) minors.
|
||||
std::vector<Tick> ticks;
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -19,7 +22,9 @@ namespace plot {
|
||||
/// and `dash_off` are independent, so a caller may override only the dash, or only the
|
||||
/// width, and leave the rest to the style.
|
||||
struct CrosshairStyle {
|
||||
f32 width = -1.0f; ///< logical stroke width; `< 0` takes `Style::crosshair_width`.
|
||||
f32 width = -1.0f; ///< logical stroke width; `< 0` takes `Style::crosshair_width`
|
||||
///< (the default). Only a *negative* value defers: `0` is used
|
||||
///< literally and draws an invisible line.
|
||||
f32 dash_on = -1.0f; ///< logical ink length; `< 0` takes `Style::crosshair_dash_on`.
|
||||
f32 dash_off = -1.0f; ///< logical gap; `< 0` takes `Style::crosshair_dash_off`, and `0`
|
||||
///< (once resolved) means solid.
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <string>
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
|
||||
#ifdef PLOT_COUNT_MEASURE_RECOMPUTES
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <span>
|
||||
#include <vector>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <vector>
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <vector>
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <plot/core/types.hpp>
|
||||
#include <plot/data/series.hpp>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <vector>
|
||||
|
||||
@@ -326,6 +329,11 @@ class Plot {
|
||||
/// newest sample* rather than a pair of numbers. With no live edge -- no trace on
|
||||
/// the axis, or none of them streaming -- nothing is recomputed and the axis
|
||||
/// keeps its stored range, `auto_scale` included, exactly as an `Off` axis does.
|
||||
///
|
||||
/// On an axis that is a member of a `Figure` x link group, arming `Live` is
|
||||
/// **refused** (the state is left as it was): the group owns the shared window and
|
||||
/// its follow state, exactly as `Figure::link_x()` refuses a `Live` member joiner.
|
||||
/// See `set_x_link_member()`.
|
||||
void set_x_follow(u32 axis, XFollow f);
|
||||
/// The follow state of one x axis. `Off` for an axis that does not exist.
|
||||
XFollow x_follow(u32 axis) const;
|
||||
@@ -360,6 +368,14 @@ class Plot {
|
||||
/// axis no owner has claimed, and for an axis that does not exist.
|
||||
bool x_live_owner(u32 axis, f64& newest) const noexcept;
|
||||
|
||||
/// Publishes -- or clears -- that this axis is a member of a `Figure` x link group.
|
||||
/// `Figure::link_x()` sets it when a row joins and `unlink_row()` clears it when one
|
||||
/// leaves; it is what makes `set_x_follow(Live)` refused on a linked axis, because
|
||||
/// the group owns the shared window and the follow state. A no-op for an axis that
|
||||
/// does not exist. Not for callers outside the figure: a `Plot` that is not in a
|
||||
/// figure never has a linked axis.
|
||||
void set_x_link_member(u32 axis, bool linked) noexcept;
|
||||
|
||||
/// Data -> pixels for one axis, built from the axis' range, log flag and the
|
||||
/// current layout. Always invertible: a collapsed range or a collapsed axes
|
||||
/// rect is widened by math::Transform, so to_data_*(to_screen_*(v)) == v.
|
||||
@@ -551,9 +567,11 @@ class Plot {
|
||||
|
||||
/// A floor on the left gutter `layout()` reserves, in device pixels: the label
|
||||
/// strip and the title column together are never narrower than this, so `axes`
|
||||
/// never starts left of `viewport().x + min_y_gutter()`. 0.0 -- the default --
|
||||
/// imposes nothing and the plot sizes its strip from its own widest tick label,
|
||||
/// which is what a plot standing on its own has always done.
|
||||
/// starts no further left than `viewport().x + min_y_gutter()` **up to the cap
|
||||
/// below** -- a floor wider than half the viewport is clamped exactly as the
|
||||
/// measured strip is, so the guarantee is `min(min_y_gutter(), y_budget)`. 0.0
|
||||
/// -- the default -- imposes nothing and the plot sizes its strip from its own
|
||||
/// widest tick label, which is what a plot standing on its own has always done.
|
||||
///
|
||||
/// It exists for `Figure`, which is the only thing that can know the answer: a row
|
||||
/// cannot see its siblings' tick labels, so the figure lays each row out once to
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <string>
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <plot/core/color.hpp>
|
||||
#include <plot/core/types.hpp>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <plot/core/status.hpp>
|
||||
#include <plot/core/types.hpp>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
|
||||
#ifdef PLOT_RING_STATS
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <plot/core/types.hpp>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <plot/core/types.hpp>
|
||||
#include <plot/math/vec2.hpp>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <plot/core/types.hpp>
|
||||
#include <plot/math/vec2.hpp>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <plot/core/color.hpp>
|
||||
#include <plot/core/status.hpp>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <span>
|
||||
#include <vector>
|
||||
@@ -281,15 +284,14 @@ class Renderer {
|
||||
/// Writes the framebuffer's viewport region as a binary PPM (P6, 8 bits per
|
||||
/// channel, rows top-down) so a headless render can be looked at without a
|
||||
/// window. Reads the framebuffer that is bound now, which is the one the frame
|
||||
/// was drawn into. Returns false, with `st` filled in, on a bad argument or an
|
||||
/// unreadable pixel.
|
||||
///
|
||||
/// The one entry point here that is not `noexcept`: it sizes its readback hole
|
||||
/// and its output row, so an allocation failure propagates `std::bad_alloc`
|
||||
/// rather than terminating a process that could have carried on without a
|
||||
/// screenshot. A dump is a diagnostic, and no `Status` code could say more than
|
||||
/// the exception already does.
|
||||
bool dump_frame_ppm(const char* path, Status* st = nullptr) const;
|
||||
/// was drawn into. Returns false, with `st` filled in, on a bad argument, an
|
||||
/// unreadable pixel, or an allocation the readback could not make -- which is
|
||||
/// reported as `ErrorCode::OutOfMemory`, exactly as `TrueTypeFont::from_file()`
|
||||
/// reports the same failure. `noexcept` like every other entry point here: the
|
||||
/// buffers are sized before anything is on disk, so the two failures that can
|
||||
/// arise from sizing them are a `Status`, not a `std::bad_alloc` across the API
|
||||
/// boundary and a half-written file.
|
||||
bool dump_frame_ppm(const char* path, Status* st = nullptr) const noexcept;
|
||||
|
||||
private:
|
||||
/// The backend the plot layer sees, at the size the text pipeline draws it:
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace plot::render {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <plot/core/color.hpp>
|
||||
|
||||
@@ -50,8 +53,12 @@ struct Style {
|
||||
/// (`Trace`'s default, 0x40C0FF) that a hue-based reading cannot tell from a cyan crosshair --
|
||||
/// only lightness would separate them, which no reader can rely on. A near-white can never be a
|
||||
/// saturated trace of *any* hue, and it is still plainly not `cursor`'s orange, which is what
|
||||
/// the spec asks for. The 1 px stroke is kept: form (a full-height thin line over a polyline) is
|
||||
/// already distinct, so no dashes.
|
||||
/// the spec asks for.
|
||||
///
|
||||
/// The stroke is thin and dashed by default -- see `crosshair_width`, `crosshair_dash_on` and
|
||||
/// `crosshair_dash_off` below for the values: thinner than a trace so the instrument reads as
|
||||
/// chrome rather than data, and dashed so it is distinguishable by *form* as well as by
|
||||
/// lightness.
|
||||
Color crosshair = Color::hex(0xE6EBF2);
|
||||
|
||||
/// The crosshair's stroke and dash, in logical pixels (spec §17). The crosshair is a
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <cmath>
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <plot/core/status.hpp>
|
||||
#include <plot/core/types.hpp>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <plot/core/status.hpp>
|
||||
#include <plot/core/types.hpp>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <memory>
|
||||
#include <span>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include <plot/core/status.hpp>
|
||||
|
||||
namespace plot {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include <plot/data/array_series.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include <plot/data/detail/cover_count.hpp>
|
||||
#include <plot/data/detail/merge_count.hpp>
|
||||
#include <plot/data/minmax_pyramid.hpp>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include <plot/data/series.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include <plot/data/stream_series.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include <plot/gl/handle.hpp>
|
||||
|
||||
#include <plot/gl/loader.hpp>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include <plot/gl/loader.hpp>
|
||||
|
||||
#include <plot/gl/caps.hpp>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include <plot/input/controller.hpp>
|
||||
|
||||
#include <plot/math/transform.hpp>
|
||||
@@ -605,10 +608,18 @@ void Controller::axis_zoom(const PointerEvent& e) {
|
||||
// A pointer drag reports pixels, not wheel notches, so it uses its own
|
||||
// sensitivity: 200 px is one decade. The gain is a physical distance the hand
|
||||
// moves, so the device-pixel delta is converted to logical pixels first -- the
|
||||
// same drag must zoom the same amount on a 2x display. A scale no host should
|
||||
// ever set (0, or negative) makes the factor non-positive, and the guard below
|
||||
// turns that into no zoom at all rather than an axis re-centred on infinity.
|
||||
const f64 logical_dy = static_cast<f64>(e.dy) / static_cast<f64>(plot_->ui_scale());
|
||||
// same drag must zoom the same amount on a 2x display.
|
||||
//
|
||||
// A scale that is not a positive number is not a scale -- a host that has not
|
||||
// measured its display, a field nobody filled in -- the same state
|
||||
// `Style::device()` tolerates. Here it means *no zoom*: dividing by it would make
|
||||
// `logical_dy` infinite and the factor infinite (or NaN), and that factor reaches
|
||||
// `set_x_range()`/`set_y_range()`, which store it verbatim and clear `auto_scale`
|
||||
// -- an axis left non-finite until an auto-fit. A negative scale would silently
|
||||
// invert the zoom direction too, so the whole class is refused here.
|
||||
const f64 scale = static_cast<f64>(plot_->ui_scale());
|
||||
if (!(scale > 0.0) || !std::isfinite(scale)) return;
|
||||
const f64 logical_dy = static_cast<f64>(e.dy) / scale;
|
||||
const f64 factor = std::exp(-logical_dy * kZoomPerPixel);
|
||||
if (!(factor > 0.0)) return;
|
||||
if (mode_ == DragMode::AxisZoomX) {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include <plot/math/transform.hpp>
|
||||
|
||||
namespace plot::math {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include <plot/plot/axis.hpp>
|
||||
|
||||
namespace plot {
|
||||
|
||||
+32
-12
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include <plot/plot/figure.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
@@ -7,16 +10,17 @@
|
||||
namespace plot {
|
||||
namespace {
|
||||
|
||||
/// The height a row never drops below, in device pixels: the floor that keeps a
|
||||
/// plot's chrome from erasing it in a figure with many rows or a small viewport. It
|
||||
/// yields to the figure -- it is capped at an equal share of the height there is --
|
||||
/// rather than pushing rows past the viewport.
|
||||
/// The height a row never drops below, in **logical** pixels -- the same unit as every other
|
||||
/// chrome size, converted to device pixels by the row's own `ui_scale()` where the floor is
|
||||
/// applied. The floor that keeps a plot's chrome from erasing it in a figure with many rows
|
||||
/// or a small viewport. It yields to the figure -- it is capped at an equal share of the
|
||||
/// height there is -- rather than pushing rows past the viewport.
|
||||
///
|
||||
/// Device pixels, like every rect a figure computes: a `Figure` carries no `Style`
|
||||
/// -- the renderer's is the one its rows are laid out with -- so there is no logical
|
||||
/// unit here to convert from. A host that wants a taller floor gives the figure a
|
||||
/// taller viewport, or gives the rows weights.
|
||||
constexpr f32 kMinRowHeightPx = 24.0f;
|
||||
/// A `Figure` carries no `Style` -- the renderer's is the one its rows are laid out with --
|
||||
/// but every row's `Plot` has the `ui_scale` the renderer installed on it, so the conversion
|
||||
/// is the row's own and the guarantee is the same *physical* height at any DPI. A host that
|
||||
/// wants a taller floor gives the figure a taller viewport, or gives the rows weights.
|
||||
constexpr f32 kMinRowHeightLogical = 24.0f;
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -69,8 +73,12 @@ u32 Figure::x_link(u32 row) const noexcept {
|
||||
void Figure::unlink_row(u32 row) noexcept {
|
||||
// A row that leaves its group stops following anything, so the hint a following group
|
||||
// published to it goes with the membership: leaving `has_live` set behind would leave a
|
||||
// former member anchoring a zoom on an edge it no longer follows.
|
||||
if (row < plots_.size()) plots_[row].set_x_live_owner(0u, false, 0.0);
|
||||
// former member anchoring a zoom on an edge it no longer follows. Its linkage flag goes
|
||||
// with it too: an unlinked row is an ordinary plot again, free to arm `Live` itself.
|
||||
if (row < plots_.size()) {
|
||||
plots_[row].set_x_live_owner(0u, false, 0.0);
|
||||
plots_[row].set_x_link_member(0u, false);
|
||||
}
|
||||
for (XLink& group : links_) {
|
||||
for (std::size_t i = 0; i < group.rows.size(); ++i) {
|
||||
if (group.rows[i] != row) continue;
|
||||
@@ -111,6 +119,10 @@ Status Figure::link_x(u32 row, u32 group) {
|
||||
const std::size_t index = static_cast<std::size_t>(at - g.rows.begin());
|
||||
g.rows.insert(at, row);
|
||||
g.pushed.insert(g.pushed.begin() + static_cast<std::ptrdiff_t>(index), Range{});
|
||||
// A member's axis 0 is now owned by the group (its window and its follow state), so
|
||||
// `set_x_follow(Live)` on it is refused from here on -- the same rule the refusal above
|
||||
// applies at the door.
|
||||
p.set_x_link_member(0u, true);
|
||||
if (!g.has_range) {
|
||||
// The first row into a group is the one that frames it: there is no shared range
|
||||
// to apply yet, and adopting this row's is what makes the next joiner land on a
|
||||
@@ -353,7 +365,15 @@ const FigureLayout& Figure::layout() {
|
||||
// heights still sum to exactly the usable height, so the rows tile the figure and
|
||||
// the last one ends on its bottom edge.
|
||||
const bool equal = row_weights_.empty();
|
||||
const f32 floor_px = std::min(kMinRowHeightPx, usable / static_cast<f32>(rows));
|
||||
// The floor is a logical size like every other chrome metric, so it is converted through
|
||||
// the row's own `ui_scale()` -- the one the renderer installed on every row -- which is
|
||||
// what makes the guarantee the same *physical* height at any DPI instead of half as strong
|
||||
// at 2x exactly where the chrome it protects is twice as large. A scale that is not
|
||||
// positive is not a scale and converts 1:1, exactly as `Style::device()` documents.
|
||||
const f32 floor_scale = plots_[0].ui_scale();
|
||||
const f32 floor_device = floor_scale > 0.0f ? kMinRowHeightLogical * floor_scale
|
||||
: kMinRowHeightLogical;
|
||||
const f32 floor_px = std::min(floor_device, usable / static_cast<f32>(rows));
|
||||
const f32 extra = usable - floor_px * static_cast<f32>(rows);
|
||||
f32 total_w = 0.0f;
|
||||
for (const f32 w : row_weights_) total_w += w;
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include <plot/plot/grid.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include <plot/plot/measure.hpp>
|
||||
|
||||
#include <cmath>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include <plot/plot/plot.hpp>
|
||||
#include <plot/plot/detail/measure_count.hpp>
|
||||
|
||||
@@ -391,6 +394,13 @@ void Plot::set_y_auto_scale(u32 axis, bool on) {
|
||||
|
||||
void Plot::set_x_follow(u32 axis, XFollow f) {
|
||||
if (axis >= x_axes_.size()) return;
|
||||
// A figure link group owns this axis' shared window *and* its follow state:
|
||||
// `Figure::link_x()` refuses a `Live` member joiner, and arming `Live` here *after*
|
||||
// linking would be the same state reached by the back door -- `push_link()` would
|
||||
// write the group's range into an axis that reinterprets it against its own live
|
||||
// edge, `adopt_member_changes()` would read it straight back as an external change,
|
||||
// and the group would track one member instead of the union of them. Refused.
|
||||
if (f == XFollow::Live && x_axes_[axis].link_member) return;
|
||||
x_axes_[axis].follow = f;
|
||||
// Arming a live axis places its window at once, so x_range() reports the window
|
||||
// the next layout produces rather than the one the axis held while it was Off.
|
||||
@@ -399,6 +409,11 @@ void Plot::set_x_follow(u32 axis, XFollow f) {
|
||||
place_live_x_window(axis);
|
||||
}
|
||||
|
||||
void Plot::set_x_link_member(u32 axis, bool linked) noexcept {
|
||||
if (axis >= x_axes_.size()) return;
|
||||
x_axes_[axis].link_member = linked;
|
||||
}
|
||||
|
||||
XFollow Plot::x_follow(u32 axis) const {
|
||||
return axis < x_axes_.size() ? x_axes_[axis].follow : XFollow::Off;
|
||||
}
|
||||
@@ -703,6 +718,24 @@ void Plot::snap_region(u32 id) {
|
||||
void Plot::remove_region(u32 id) {
|
||||
if (id >= regions_.size()) return;
|
||||
regions_.erase(regions_.begin() + static_cast<std::ptrdiff_t>(id));
|
||||
// The published hot handles name a region by index and are re-indexed with everything
|
||||
// else: the removed region's own handle is cleared (nothing is hot there any more, and a
|
||||
// frame drawn before the next event would otherwise highlight the region that slid into
|
||||
// its slot), and a handle naming a region above the hole follows it down. The `zone` is
|
||||
// the grab that went with that index, so it is cleared with it -- a stale zone describing
|
||||
// a region that is no longer there would colour the next region's bound.
|
||||
//
|
||||
// A host must not remove a region *mid-drag*: the controller holds the id it grabbed, and
|
||||
// this setter cannot re-index a controller's private `drag_region_`. The gesture's release
|
||||
// would then edit whichever region now carries that id. Remove regions between gestures.
|
||||
if (hot_.region != kInvalid) {
|
||||
if (hot_.region == id) {
|
||||
hot_.region = kInvalid;
|
||||
hot_.zone = RegionGrab::None;
|
||||
} else if (hot_.region > id) {
|
||||
hot_.region -= 1u;
|
||||
}
|
||||
}
|
||||
// The cache is keyed by region index, so the removed region's entries name nothing
|
||||
// and every entry above the hole names a region one slot further down. The
|
||||
// surviving regions' own intervals are untouched -- erase keeps their order -- so
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include <plot/plot/ticks.hpp>
|
||||
|
||||
#include <cmath>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include <plot/render/buffer.hpp>
|
||||
|
||||
#include <plot/gl/api.hpp>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
//
|
||||
// GENERATED FILE -- do not edit by hand.
|
||||
//
|
||||
// Regenerate with: tools/fonts/gen_atlas.py
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include <plot/render/primitives.hpp>
|
||||
|
||||
#include <cassert>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include <plot/render/program.hpp>
|
||||
|
||||
#include <plot/render/shaders.hpp>
|
||||
|
||||
+31
-6
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include <plot/render/renderer.hpp>
|
||||
|
||||
#include <plot/gl/api.hpp>
|
||||
@@ -9,6 +12,7 @@
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <new>
|
||||
#include <vector>
|
||||
|
||||
namespace plot {
|
||||
@@ -1120,6 +1124,10 @@ void Renderer::draw_measures(const Plot& plot, const PlotLayout& layout) {
|
||||
|
||||
void Renderer::draw_annotations(const Plot& plot) {
|
||||
const f32 line_h = font_backend_.height();
|
||||
// The clip every other overlay is drawn under, read here because the caption is staged
|
||||
// into the frame's single text batch -- flushed after `clear_clip()` -- so the scissor that
|
||||
// keeps the rest of this pass inside the axes does not reach it.
|
||||
const Rect& axes = plot.layout().axes;
|
||||
for (u32 i = 0; i < plot.annotation_count(); ++i) {
|
||||
const Annotation& annotation = plot.annotation(i);
|
||||
if (!annotation.visible) continue;
|
||||
@@ -1131,7 +1139,14 @@ void Renderer::draw_annotations(const Plot& plot) {
|
||||
lines_.add_segment({px - style_.device(3.0f), py}, {px + style_.device(3.0f), py});
|
||||
lines_.add_segment({px, py - style_.device(3.0f)}, {px, py + style_.device(3.0f)});
|
||||
flush_lines(pixel_mapping(), annotation.color, style_.device(1.0f));
|
||||
if (!annotation.text.empty()) {
|
||||
// A caption whose anchor is outside the axes is skipped with its cross, which the clip
|
||||
// has already removed: a caption with no cross is a label for a point the frame does not
|
||||
// show, and -- because the text batch is drawn *after* the clip is released -- it would
|
||||
// otherwise paint outside the axes it is measured in, into the neighbouring row of a
|
||||
// `Figure` whose rows tile the figure. Splitting the frame's one text draw call to clip
|
||||
// it instead would cost a draw call per row and mis-clip the tick labels, which are
|
||||
// staged outside `axes` on purpose and share the batch after this one.
|
||||
if (!annotation.text.empty() && axes.contains(px, py)) {
|
||||
stage_text({px + style_.device(5.0f), py - 0.5f * line_h}, annotation.text.c_str());
|
||||
}
|
||||
}
|
||||
@@ -1259,7 +1274,7 @@ void Renderer::draw_crosshair_readout(const Plot& plot, const PlotLayout& layout
|
||||
|
||||
// -------------------------------------------------------------------- dump
|
||||
|
||||
bool Renderer::dump_frame_ppm(const char* path, Status* st) const {
|
||||
bool Renderer::dump_frame_ppm(const char* path, Status* st) const noexcept {
|
||||
if (path == nullptr || path[0] == '\0') {
|
||||
return fail(st, ErrorCode::InvalidArgument, "PPM path is empty");
|
||||
}
|
||||
@@ -1272,9 +1287,20 @@ bool Renderer::dump_frame_ppm(const char* path, Status* st) const {
|
||||
return fail(st, ErrorCode::InvalidArgument, "no frame has been drawn yet");
|
||||
}
|
||||
|
||||
// RGBA, not RGB: 4-byte pixels have no row padding whatever the pack alignment,
|
||||
// so a host's GL_PACK_ALIGNMENT cannot make the read overrun the buffer.
|
||||
readback_.assign(usize(width) * height * 4u, 0u);
|
||||
// The two buffers are sized -- and can fail to size -- before anything is on disk, so an
|
||||
// allocation failure is reported through the `Status` rather than throwing `std::bad_alloc`
|
||||
// across the public boundary (which is `noexcept`) and leaving a half-written file behind.
|
||||
// The readback hole is RGBA, not RGB: 4-byte pixels have no row padding whatever the pack
|
||||
// alignment, so a host's GL_PACK_ALIGNMENT cannot make the read overrun the buffer. The row
|
||||
// is the PPM's own 3-byte stride, one line at a time.
|
||||
std::vector<u8> row;
|
||||
try {
|
||||
readback_.assign(usize(width) * height * 4u, 0u);
|
||||
row.assign(usize(width) * 3u, 0u);
|
||||
} catch (const std::bad_alloc&) {
|
||||
return fail(st, ErrorCode::OutOfMemory, "out of memory sizing the PPM readback");
|
||||
}
|
||||
|
||||
gl::glPixelStorei(gl::GL_PACK_ALIGNMENT, 4);
|
||||
gl::glReadPixels(static_cast<gl::GLint>(gl_viewport_.x), static_cast<gl::GLint>(gl_viewport_.y),
|
||||
static_cast<gl::GLsizei>(width), static_cast<gl::GLsizei>(height), gl::GL_RGBA,
|
||||
@@ -1285,7 +1311,6 @@ bool Renderer::dump_frame_ppm(const char* path, Status* st) const {
|
||||
return fail(st, ErrorCode::InvalidArgument, "cannot open the PPM path for writing");
|
||||
}
|
||||
std::fprintf(file, "P6\n%u %u\n255\n", width, height);
|
||||
std::vector<u8> row(usize(width) * 3u);
|
||||
bool ok = true;
|
||||
for (u32 y = 0; y < height && ok; ++y) {
|
||||
// glReadPixels hands back rows bottom-up; a PPM's rows run top-down.
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include <plot/render/shaders.hpp>
|
||||
|
||||
// Both pipelines are `#version 330 core` and stay untouched by the upload
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include <plot/render/text.hpp>
|
||||
|
||||
namespace plot::render {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include <plot/render/text_atlas.hpp>
|
||||
|
||||
#include <plot/gl/api.hpp>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include <plot/render/vertex_array.hpp>
|
||||
|
||||
#include <plot/gl/api.hpp>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
// The vendored rasteriser's single implementation unit: the one translation unit
|
||||
// in this project that defines STB_TRUETYPE_IMPLEMENTATION, so the ~5000-line
|
||||
// stb_truetype.h is compiled exactly once, into the optional plot_font_ttf
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
// The optional TrueType backend: the one place in the project that parses a font
|
||||
// file at run time, and the only consumer of the vendored third_party/stb_truetype.h
|
||||
// (see src/text/stb_truetype_impl.cpp for its single implementation unit, and
|
||||
|
||||
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user