Files
plotter/docs/superpowers/specs/2026-09-27-plot-library-design.md
T

14 KiB
Raw Blame History

plot — embeddable OpenGL time-series plotting library

Date: 2026-09-27 Status: approved (design reviewed section-by-section with the requester)

Goal

A C++20 library that renders real-time, high-sample-rate time-series plots with grid, axes, cursors and measurements, and that can be embedded into an existing Qt6, GTK4, Dear ImGui, or raw GLFW application without owning the window, the GL context, or the event loop.

Non-goals (this cut)

  • 3D surfaces, scatter/bar/pie charts, polar plots.
  • Data I/O (CSV/HDF5 readers), file formats, network sources.
  • Font rendering beyond the built-in bitmap atlas.
  • Any host API not listed above.

These are explicitly out of scope so the first cut is a coherent vertical slice. The layering (§3) leaves room for them without redesign.

Constraints (fixed by the requester)

Constraint Value
Core dependencies zero. No GLM, glad, GLEW, or bundled Khronos headers.
GL baseline 3.3 core, with a runtime-selected 4.3+ fast path.
Reference host GLFW, plus ImGui and Qt6 and GTK4 adapters in the same cut.
First deliverable buildable end-to-end skeleton: no stub bodies on the demo path.
Language C++20, GCC/Clang, Linux first (no portability blockers introduced).

1. Environment (verified on the development machine)

GCC 16.2.1, CMake 4.4.3, Ninja 1.13.2. Qt6 6.11.2 with Qt6OpenGLWidgets, GTK4 4.22.5 with gtkglarea.h, GLFW 3.5.1, EGL 1.5 + libEGL_mesa (surfaceless headless contexts available), glm and GL/glew.h installed but unused. Dear ImGui is absent and is provided via FetchContent (network verified).

2. The embeddability contract

The library never creates a window, never creates or shares a GL context, and never runs an event loop. Its render entry point is:

plot::Renderer r;              // constructed with the caller's context current
r.init();
r.begin_frame(fb_w, fb_h, viewport_rect_in_pixels);   // in the caller's FBO
r.draw(plot);
r.end_frame();

Input arrives as plain value types (PointerEvent, WheelEvent, KeyEvent) that each adapter translates from its host toolkit. Nothing in plot::core names a toolkit, so a consumer links only the adapter they need.

3. Layering

adapters/{glfw,imgui,qt6,gtk4}   own the surface; translate host events
        ↓
plot/     Plot · Axis · Trace · Grid · Cursor · Measure · Annotation
        ↓
render/   Renderer · Style · TextBackend · primitive batches
        ↓
data/     Series · ArraySeries · StreamSeries · MinMaxPyramid
        ↓
gl/       runtime loader · RAII handles · capability probe
        ↓
core/ + math/   types · Color · Rect · vec2 · mat3 · Transform

Each layer depends only downward. adapters/* and examples/* are optional CMake targets guarded by PLOT_BUILD_* options and by dependency discovery.

4. Performance architecture

These four decisions are what make 1M+ samples/frame viable. Each replaces an approach that fails at high sample rates.

4.1 Data-space vertices, uniform-only pan and zoom

Vertex buffers hold raw data coordinates. Screen mapping is x' = x * sx + tx, y' = y * sy + ty evaluated in the vertex shader from a Transform uniform. Panning and zooming therefore touch one uniform and zero vertex data. Only new samples or an LOD-level change cause an upload.

Log axes are handled inside the same transform (log10 before scaling) so no separate code path or pre-transformed buffer is needed.

4.2 Min/max LOD pyramid

Every series owns a mip chain: level 0 buckets kBucket = 64 consecutive samples and stores one (min, max) pair per bucket; each subsequent level halves the bucket count. Memory cost is n/8 bytes at level 0 (2 floats per 64 samples) times the geometric sum 1 + ½ + ¼ + …, giving n/4 bytes total — ≈6% of the 4n bytes of raw samples, not 2×.

  • Built incrementally: append() updates only the last bucket and the O(log n) chain above it — amortised O(1) per sample.
  • The renderer picks the level where samples-per-pixel ≈ 2, so a 100M-sample series rasterises ≈ 2 · viewport_width vertices instead of 100M.
  • Buckets preserve extremes, so spikes are never dropped by decimation.

4.3 Hybrid line rasterisation

Chosen per trace, per frame, from observed samples-per-pixel:

samples/px path
< 1.5 GL_LINE_STRIP, 1px, no AA — dense data hides aliasing; ~10× cheaper
>= 1.5 instanced anti-aliased quads: one instance per segment, 4 verts expanded in the vertex shader, fragment shader does distance-to-segment SDF for smooth edges and joins

A geometry-shader expansion path was rejected: poor performance and it is the one stage macOS cannot be relied on for.

4.4 Interval reduction reuses the pyramid

Cursor measurements (Δx, Δy, slope, RMS, mean, min/max, integral over a dragged region) walk the same min/max chain and return in O(log n); the series is never scanned linearly. pyramid().reduce(a, b) is the single implementation used both by measurement and by auto-scale.

4.5 Decimation approach — decision

Approach Verdict
A CPU min/max pyramid, incremental chosen — works on GL 3.3, deterministic, unit-testable with no GPU, O(1) amortised per sample, and it doubles as the measurement index. Cost: one CPU pass per sample, plus ≈6% memory.
B GPU compute decimation every frame rejected — re-reads the whole series each frame (real bandwidth at 1M samples/frame), no 3.3 fallback, untestable headlessly.
C no decimation rejected — dies above ~500k samples.

The 4.3+ fast path therefore applies to transfer, not to decimation: a persistent-mapped, glFenceSync-guarded triple-buffered ring replaces the staging-upload path. Both paths share identical semantics and are selected by the capability probe.

5. Zero-dependency mechanisms

  • Math — hand-written vec2, mat3, Transform: ~250 lines, no templates in public headers, no consumer-visible dependency.
  • GL loader — a hand-maintained X-macro list PLOT_GL_FUNCS(X) covering only the ≈60 entry points actually used, with our own typedefs and enums. No glcorearb.h bundling and no code-generation step. Resolution order: context-provided getProcAddress (glX / EGL / wgl) → dlsym/GetProcAddress on the system GL library.
  • Text — a TextBackend interface plus a built-in 8×16 bitmap-glyph atlas. Axis labelling needs no third-party code; a FreeType or stb_truetype backend can be added behind the same interface without touching callers.

There is no third_party/ directory. Adapters use find_package first and FetchContent only as a fallback (ImGui; GLFW when absent).

6. Public API sketch

Signatures here are the contract the implementation plan and adapters rely on.

namespace plot {

using f32 = float;  using f64 = double;
using u32 = std::uint32_t;  using u64 = std::uint64_t;
constexpr u64 kInvalid = ~u64{0};

struct Range { f64 min, max; };
struct Rect  { f32 x, y, w, h; };            // pixels, origin top-left
struct Color { f32 r, g, b, a; static Color hex(u32 rgb, f32 a = 1.0f); };

enum class ErrorCode { Ok, InvalidArgument, OutOfMemory, GLFailure, Unsupported, NotImplemented };
struct Status { ErrorCode code = ErrorCode::Ok; const char* message = ""; explicit operator bool() const; };

// math/transform.hpp
struct Transform {
  f64 sx, tx, sy, ty; bool log_x = false, log_y = false;
  f64 to_screen_x(f64) const; f64 to_data_x(f64) const;
  f64 to_screen_y(f64) const; f64 to_data_y(f64) const;
};

namespace gl {
  using ProcLoader = void* (*)(const char*);
  bool load(ProcLoader);                 // nullptr → platform default
  struct Caps { int major, minor; bool compute, persistent_map, buffer_storage; };
  const Caps& caps();
  class Buffer; class VertexArray; class Program; class Texture; class Framebuffer; class Fence;
}

namespace data {
  struct MinMax { f32 min, max; };
  constexpr u32 kBucket = 64;

  class MinMaxPyramid {
   public:
    void reserve(u64 n); void append(f32 v); void clear();
    u64 size() const;
    Range reduce(u64 begin, u64 end) const;                 // O(log n)
    u32 level_for(u64 n, f64 samples_per_pixel) const;
    std::span<const MinMax> level(u32 l) const;
    u64 snap(f64 x, f64 x0, f64 dx) const;                   // nearest sample index
  };

  class Series {                                             // abstract
   public:
    virtual ~Series() = default;
    virtual u64 size() const = 0;
    virtual Range x_bounds() const = 0;
    virtual Range y_bounds() const = 0;
    virtual const MinMaxPyramid& pyramid() const = 0;
    virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const = 0;
  };

  class ArraySeries final : public Series {                  // own storage, uniform dx
   public:
    ArraySeries(std::vector<f32> y, f64 x0, f64 dx);
    void reset(std::vector<f32> y, f64 x0, f64 dx);
    void commit();                                           // rebuild the pyramid
  };

  class StreamSeries final : public Series {                 // SPSC, producer + consumer
   public:
    explicit StreamSeries(u64 capacity, f64 x0, f64 dx);
    void push(f32 y);                                        // producer thread only
    void push(const f32* ys, u64 n);                         // producer thread only
    u64 produced() const;                                    // atomic, monotonic
    void acquire();                                          // consumer: snapshot + extend pyramid
  };
}

enum class AxisPlace { Bottom, Top, Left, Right };

struct AxisDesc { std::string label; Range range; bool auto_scale = true; AxisPlace place; bool log = false; };
struct TraceDesc { const data::Series* series; Color color; f32 width = 1.5f; u32 axis_x = 0, axis_y = 0; };

class Plot {
 public:
  u32 add_x_axis(AxisDesc); u32 add_y_axis(AxisDesc);
  u32 add_trace(TraceDesc);
  u32 add_cursor(f64 x, Color);
  u32 add_measure(u32 cursor_a, u32 cursor_b);
  void set_viewport(Rect px);
  void set_x_range(u32 axis, Range); void set_y_range(u32 axis, Range);
  void auto_fit_x(); void auto_fit_y(u32 axis);
  struct Measurement { f64 dx, dy, slope, rms, mean, ymin, ymax, integral; bool valid; };
  const Measurement& measurement(u32 id) const;              // recomputed on cursor move
};

class Renderer {
 public:
  bool init(Status* = nullptr);                              // context must be current
  void begin_frame(u32 fb_w, u32 fb_h, Rect viewport_px);
  void draw(Plot&);
  void end_frame();
  Style& style();
};

namespace input {
  enum class Button { Left, Middle, Right };
  enum class Mod { None = 0, Shift = 1, Ctrl = 2, Alt = 4, Super = 8 };
  struct PointerEvent { f32 x, y, dx, dy; Button button; u32 mods; bool down, pressed, released; };
  struct WheelEvent   { f32 x, y, dx, dy; u32 mods; };
  struct KeyEvent     { int key; u32 mods; bool down; };

  enum class DragMode { None, Pan, BoxZoom, MoveCursor, AxisZoomX, AxisZoomY };
  class Controller {
   public:
    explicit Controller(Plot&);
    void set_viewport(Rect);
    void on_pointer(const PointerEvent&);
    void on_wheel(const WheelEvent&);
    void on_key(const KeyEvent&);
    DragMode drag_mode() const;
    Rect selection_rect() const;                             // valid while BoxZoom
    u64 hovered_cursor() const;                              // kInvalid when none
  };
}
}  // namespace plot

7. Threading model

  • All GL calls happen on the thread that has the context current; Renderer is not thread-safe and does not need to be.
  • Producers write to data::StreamSeries from an acquisition thread through a lock-free SPSC ring. No mutex on the sample path.
  • A frame begins with StreamSeries::acquire(), which publishes a consistent [0, produced) snapshot and extends the pyramid by exactly the newly produced samples. Readers use the snapshot; the producer never blocks.
  • Renderer, Plot, and Controller are confined to the render thread.

8. Error handling

  • No exceptions cross the public boundary. Fallible operations return Status or bool plus an out-parameter Status*.
  • GL failures are surfaced as ErrorCode::GLFailure from Renderer::init and recorded on the first occurrence per frame; PLOT_GL_CHECK() is a no-op in release builds.
  • Unsupported-but-optional capabilities (compute, persistent mapping) degrade silently by selecting the 3.3 path; unsupported-and-required ones return ErrorCode::Unsupported from init.

9. Verification

Layer How it is verified
core/math, data, plot/ticks, input CTest unit tests, no GL required: ring-buffer wrap/overwrite/tearing, pyramid build and reduce() vs. brute force, level selection, tick generation, transform round-trip, zoom-anchor invariance, pan invariance.
render, plot headless EGL surfaceless context + FBO, rasterise a known sine, glReadPixels and assert ink present in expected columns and background elsewhere; assert the trace covers ≥95% of viewport width at 1M samples.
adapters Launch each demo (glfw, imgui, qt, gtk) on the development display, screenshot, confirm grid + axes + trace + cursor + measurement readout render.

Headless-EGL risk: if Mesa surfaceless context creation fails on this machine the fallback is GLX on a hidden window; this is validated in the first GL task, before three of the four adapters depend on it.

10. Milestones

  1. Repo skeleton, CMake, core+math with tests.
  2. GL loader, handles, capability probe; headless EGL harness proven.
  3. Data layer: ring buffer, pyramid, series.
  4. Render layer: programs, buffers, batching, bitmap text.
  5. Plot layer: axes, ticks, grid, trace, cursor, measurement.
  6. Input layer: events, controller.
  7. Renderer + headless render smoke test (pixel assertions).
  8. GLFW adapter + 1M-sample demo.
  9. ImGui adapter + demo.
  10. Qt6 adapter + demo.
  11. GTK4 adapter + demo.
  12. Install/export, full verification pass, screenshots.