Breaking: Series::gather emits x relative to x0() and takes a GatherMode; consumers reconstruct absolute x in f64 before mapping (series.hpp documents the contract). Bugs, each pinned by a fail-before regression test: - render: f32 staging quantised epoch-time x into 128 px columns; every data-space draw now stages to_space(x) - to_space(origin) with the origin folded into the offset uniform in f64 (epoch/zero-origin twins rasterise identically) - ticks: major test was ULP-fragile and blanked epoch axes; majors are an integer-index test and are never truncated by the minor cap - gather: LOD envelope emitted buckets straddling [begin,end); ceil/floor bucket edges + raw lead/trail spans - live follow: log x axes held a linear span and zoomed every frame; span and offset now live in axis space (plot + both figure link-group paths) - input: a zero-notch wheel disarmed auto_scale on every axis; dy==0 pans x, factor == 1 / inf refused in wheel and drag zoom - render: the single text flush painted legend names over the crosshair readout panel; two batches, under flushed before the panel - data: NaN dropped from min/max unless it seeded the fold; non-finite samples are now excluded from every aggregate and from cached bounds Robustness: viewport normalisation (no clamp UB), non-finite range refusal, pyramid level cap at 57 (no shift/div UB through public gather), rounded ring capacity documented + pre-sized, GL blend func and UNPACK_ALIGNMENT save/restore, Renderer::release_gl() teardown, u64 buffer epochs, annotation captions clamped, button-aware GLFW row capture, cached figure row rects (no per-event layout), ruler-pair measurement refused, span-nonfinite guards in axis_ends. Features: XY series (sorted x, binary-search neighbours/snap, LOD over sample index); StreamSeries::freeze(); ArraySeries::push(); per-trace dash and markers (scatter at width 0) with auto or literal stride; GatherMode Envelope/Nearest/Mean via bucket_sum/sumsq; Style::line_aa_px; per-glyph text colour (readout values in trace colour); wheel-dx x pan; arrow-key pan; Escape cancels a drag and restores captured ranges; modifier-masked box zoom (Shift both, Ctrl x, Alt y); double-click reset via PointerEvent::time_s; ImGui key repeat unified; pinch gestures on Qt and GTK; pixelDelta wheel path and DevicePixelRatioChange handling; GNUInstallDirs, SOVERSION, SameMinorVersion, generated plot/version.hpp, relocatable plot.pc, licence install, FILES_MATCHING, GitHub Actions CI (debug/coverage/asan). Suite 283 -> 350 cases, 0 failed; coverage 93.9% (3730/3971) over 24 files, floor 80/90 held; ASAN preset suite green; all four demos run headless (the GTK scripted-resize check needs a window manager and fails identically on the parent commit); readme_main.cpp committed and compiles with the README command; pkg-config and find_package consumption verified from a relocated install prefix.
8.2 KiB
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).
Prerequisites
- A C++20 compiler.
- CMake 3.24 or newer.
cmake/plot_options.cmakefails the configure with aFATAL_ERRORbelow that, and the presets inCMakePresets.jsondeclarecmakeMinimumRequired3.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 throughplot::gl::caps()and only used when the driver offers it. - To build the test suite you also need EGL:
tests/CMakeLists.txtfails 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 for the options.
Build and install
The presets live in CMakePresets.json:
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
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.
The version you ask for follows the package's own compatibility rule, SameMinorVersion:
find_package(plot 0.1 REQUIRED) accepts 0.1.x and refuses 0.2+, because at 0.x the minor
component is what moves when the surface breaks. What the surface is — the version this
header set was generated from — is in <plot/version.hpp>, installed with the other public
headers: plot::version::major, ::minor, ::patch and ::string (plus the
PLOT_VERSION_* macros) for a compile-time answer, no pkg-config --modversion round-trip
needed:
#include <plot/version.hpp>
static_assert(plot::version::major == 0, "plot 0.x only");
A build that is not CMake can use the installed pkg-config file instead:
pkg-config --cflags --libs plot # -I<includedir> -L<libdir> -lplot_core
Both grants travel with the install: LICENSE (MIT) and the embedded font's
licences/OFL-1.1.txt (SIL OFL 1.1) are under share/licenses/plot/ in the prefix.
Every public component is reachable through the umbrella header:
#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 (-I build/debug/include is where
CMake generates plot/version.hpp, which the umbrella pulls in):
g++ -std=c++20 -I include -I build/debug/include main.cpp build/debug/libplot_core.a \
$(pkg-config --libs glfw3) -ldl -lpthread -o plot_quickstart
// 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, andRenderer::init()returnsfalsewith aStatuswhen 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
Plotis pure CPU state. When you draw it through aRenderer, set colours and metrics onrenderer.style(), not on the plot —draw()copies the style into the plot and hands it theui_scalebefore laying out. ui_scaleis device pixels per logical unit. Here it is1.0because the program uses the framebuffer 1:1; a HiDPI host passes the framebuffer/window ratio instead (see embedding.md).
Next: architecture.md for how the pieces fit, embedding.md to put a plot into a real host, and data-and-performance.md for feeding it a live stream.