docs: design spec and implementation plan for the plot library
This commit is contained in:
commit
e4051bc6b5
2 files changed
+1769
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,322 @@
|
|||||||
|
# `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:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
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.
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
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.
|
||||||
Reference in new issue
Block a user