Files
plotter/docs/getting-started.md
T
Martino Ferrari 38ef459897
CI / debug (tests + all adapters) (push) Canceled after 0s
CI / coverage (floor + measured table) (push) Canceled after 0s
CI / asan (AddressSanitizer) (push) Canceled after 0s
feat(plot)!: harden pipeline, XY series, dash/markers, touch, packaging
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.
2026-09-30 17:32:06 +02:00

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.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 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, 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).

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.