66 KiB
plot library scaffold — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a zero-dependency-core C++20 OpenGL plotting library that renders real-time, high-sample-rate time series into a caller-owned framebuffer, with GLFW, Dear ImGui, Qt6 and GTK4 adapters.
Architecture: Strict downward layering (core/math → gl → data → render → plot → input) with adapters on top. Vertex data stays in data space and pan/zoom is a shader uniform, so nothing is re-uploaded on interaction. An incremental min/max LOD pyramid gives O(pixels) rasterisation and O(log n) measurement queries on the same index.
Tech Stack: C++20, CMake ≥3.24, Ninja, OpenGL 3.3 core (runtime 4.3+ fast path), EGL for headless tests, GLFW/ImGui/Qt6/GTK4 only for adapters.
Spec: docs/superpowers/specs/2026-09-27-plot-library-design.md (read it before starting any task).
Global Constraints
- Core target
plot_coreMUST NOT link or include any third-party library: no GLM, no glad, no GLEW, noGL/glcorearb.h, nothird_party/directory. - Public headers live under
include/plot/and MUST be self-contained (each compiles standalone). - OpenGL baseline is 3.3 core. Anything above 3.3 MUST be behind a runtime capability check in
plot::gl::caps(). - Every GL vertex/instance buffer holds data-space coordinates. Pan/zoom MUST NOT re-upload vertex data.
- No exceptions cross the public API boundary; fallible operations return
bool/Status. - C++20. Warnings:
-Wall -Wextra -Wpedantic -Wconversion -Wshadowon plot targets; warnings MUST be clean, never suppressed per-file. - Namespace
plot, sub-namespacesplot::gl,plot::data,plot::input,plot::math. - Commit after every task with the message given in the task. Do not run project-wide test suites inside a task; run only the commands listed.
tests/must pass withctest --output-on-failurefrom the build directory at the end of every task from Task 1 onward.
File Structure
plotter/
├── CMakeLists.txt plot_core, options, install/export
├── CMakePresets.json linux-debug, linux-release, linux-asan
├── .clang-format
├── cmake/plot_options.cmake version floor, warnings, option defaults
├── include/plot/
│ ├── plot.hpp umbrella include
│ ├── core/{types,color,rect,span,status}.hpp
│ ├── math/{vec2,mat3,transform}.hpp
│ ├── gl/{api,loader,handle,caps}.hpp
│ ├── render/{style,text,bitmap_font,program,buffer,vertex_array,primitives,renderer}.hpp
│ ├── data/{ring_buffer,minmax_pyramid,series,array_series,stream_series}.hpp
│ ├── plot/{ticks,axis,grid,trace,cursor,measure,annotation,plot}.hpp
│ └── input/{events,controller}.hpp
├── src/ one .cpp per non-inline component
├── adapters/{glfw,imgui,qt6,gtk4}/ each: CMakeLists.txt + header + impl
├── examples/{glfw_demo,imgui_demo,qt_demo,gtk_demo}.cpp
└── tests/ harness.hpp, egl_headless.{hpp,cpp}, test_*.cpp
Task 1: Repository skeleton, core types, math
Files:
- Create:
CMakeLists.txt,CMakePresets.json,.clang-format,cmake/plot_options.cmake,.gitignore - Create:
include/plot/core/{types,color,rect,status,span}.hpp - Create:
include/plot/math/{vec2,mat3,transform}.hpp - Create:
src/math/transform.cpp,src/core/status.cpp - Create:
include/plot/plot.hpp - Create:
tests/CMakeLists.txt,tests/harness.hpp,tests/test_transform.cpp
Interfaces:
-
Consumes: nothing.
-
Produces:
plot::f32/f64/u32/u64/kInvalid,plot::Range,plot::Rect,plot::Color,plot::Status,plot::ErrorCode,plot::Transformwithto_screen_x/to_data_x/to_screen_y/to_data_y, and thePLOT_CHECK(cond)test macro,PLOT_TEST(name)test registration. -
Step 1: Write the failing test
tests/harness.hpp — a zero-dependency CTest harness (no gtest):
#pragma once
#include <cstdio>
#include <cmath>
#include <chrono>
#include <algorithm>
#include <vector>
#include <string>
#include <functional>
using plot::f32;
using plot::f64;
using plot::u32;
using plot::u64;
namespace plottest {
struct Case { const char* name; std::function<void()> fn; };
inline std::vector<Case>& cases() { static std::vector<Case> c; return c; }
struct Reg { Reg(const char* n, std::function<void()> f) { cases().push_back({n, std::move(f)}); } };
struct Failure { std::string msg; };
inline int run_all() {
int failed = 0;
for (auto& c : cases()) {
try { c.fn(); std::printf("[ ok ] %s\n", c.name); }
catch (const Failure& f) { std::printf("[FAIL] %s: %s\n", c.name, f.msg.c_str()); ++failed; }
}
std::printf("%zu cases, %d failed\n", cases().size(), failed);
return failed == 0 ? 0 : 1;
}
} // namespace plottest
#define PLOT_TEST(name) \
static void name(); \
static ::plottest::Reg reg_##name(#name, name); \
static void name()
#define PLOT_CHECK(cond) \
do { if (!(cond)) throw ::plottest::Failure{std::string(__FILE__) + ":" + \
std::to_string(__LINE__) + " CHECK(" #cond ")"}; } while (0)
#define PLOT_CHECK_NEAR(a, b, eps) \
do { const double _a = (a), _b = (b); \
if (!(std::fabs(_a - _b) <= (eps))) throw ::plottest::Failure{ \
std::string(__FILE__) + ":" + std::to_string(__LINE__) + \
" CHECK_NEAR(" #a ", " #b ") got " + std::to_string(_a) + \
" vs " + std::to_string(_b)}; } while (0)
tests/main.cpp:
#include "harness.hpp"
int main() { return plottest::run_all(); }
tests/test_transform.cpp:
#include "harness.hpp"
#include <plot/math/transform.hpp>
using plot::Transform;
PLOT_TEST(linear_round_trip) {
Transform t{2.0, 10.0, 3.0, -1.0, false, false};
PLOT_CHECK_NEAR(t.to_screen_x(0.0), 10.0, 1e-12);
PLOT_CHECK_NEAR(t.to_screen_x(5.0), 20.0, 1e-12);
PLOT_CHECK_NEAR(t.to_data_x(20.0), 5.0, 1e-12);
PLOT_CHECK_NEAR(t.to_data_y(t.to_screen_y(7.5)), 7.5, 1e-12);
}
PLOT_TEST(log_x_round_trip) {
Transform t{100.0, 0.0, 1.0, 0.0, true, false};
PLOT_CHECK_NEAR(t.to_screen_x(1.0), 0.0, 1e-12);
PLOT_CHECK_NEAR(t.to_screen_x(10.0), 100.0, 1e-12);
PLOT_CHECK_NEAR(t.to_data_x(100.0), 10.0, 1e-9);
}
PLOT_TEST(zoom_anchor_is_invariant) {
// Anchoring a zoom at a data coordinate must leave that coordinate's screen
// position unchanged.
Transform t{1.0, 0.0, 1.0, 0.0, false, false};
const double anchor_data = 3.25;
const double before = t.to_screen_x(anchor_data);
t.sx *= 4.0;
t.tx = before - anchor_data * t.sx;
PLOT_CHECK_NEAR(t.to_screen_x(anchor_data), before, 1e-12);
}
- Step 2: Run test to verify it fails
Run: cmake --preset linux-debug && cmake --build build/debug --target plot_tests
Expected: FAIL — plot/math/transform.hpp does not exist.
- Step 3: Write the implementation
include/plot/core/types.hpp:
#pragma once
#include <cstdint>
#include <cstddef>
namespace plot {
using f32 = float;
using f64 = double;
using i32 = std::int32_t;
using u32 = std::uint32_t;
using u64 = std::uint64_t;
using usize = std::size_t;
constexpr u64 kInvalid = ~u64{0};
struct Range {
f64 min = 0.0;
f64 max = 1.0;
constexpr f64 span() const noexcept { return max - min; }
constexpr f64 clamp(f64 v) const noexcept { return v < min ? min : (v > max ? max : v); }
};
} // namespace plot
include/plot/math/vec2.hpp:
#pragma once
#include <plot/core/types.hpp>
namespace plot {
struct Vec2 {
f32 x = 0.0f, y = 0.0f;
};
} // namespace plot
(Implement Vec2{x,y} with + - * /, dot, length.)
include/plot/math/transform.hpp:
#pragma once
#include <plot/core/types.hpp>
#include <cmath>
namespace plot {
/// Affine data→screen mapping: screen = data * s + t, applied after log10 when enabled.
struct Transform {
f64 sx = 1.0, tx = 0.0;
f64 sy = 1.0, ty = 0.0;
bool log_x = false, log_y = false;
f64 to_screen_x(f64 x) const noexcept {
const f64 v = log_x ? log10_positive(x) : x;
return v * sx + tx;
}
f64 to_screen_y(f64 y) const noexcept {
const f64 v = log_y ? log10_positive(y) : y;
return v * sy + ty;
}
f64 to_data_x(f64 sx_screen) const noexcept {
const f64 v = (sx_screen - tx) / sx;
return log_x ? std::pow(10.0, v) : v;
}
f64 to_data_y(f64 sy_screen) const noexcept {
const f64 v = (sy_screen - ty) / sy;
return log_y ? std::pow(10.0, v) : v;
}
/// Set scale/offset so that [lo,hi] data maps onto [pix_lo,pix_hi] screen.
static Transform from_range(Range x, Range y, f64 px0, f64 px1, f64 py0, f64 py1, bool lx, bool ly);
static f64 log10_positive(f64 v) noexcept; // clamps to >= 1e-300 before log10
};
} // namespace plot
Implement from_range and log10_positive in src/math/transform.cpp. from_range for a log axis maps log10(lo)→log10(hi), and for a degenerate span (min == max) expands by ±0.5 so the transform is always invertible.
include/plot/core/{color,rect,status,span}.hpp per spec §6: Color{r,g,b,a} with static Color hex(u32 rgb, f32 a = 1.0f); Rect{x,y,w,h} with contains, right(), bottom(); ErrorCode + Status with explicit operator bool() const { return code == ErrorCode::Ok; }; span.hpp re-exports std::span as plot::Span.
tests/CMakeLists.txt:
add_executable(plot_tests main.cpp test_transform.cpp)
target_link_libraries(plot_tests PRIVATE plot::core)
add_test(NAME plot_tests COMMAND plot_tests)
Every later task adds its new test_*.cpp to the plot_tests source list; there is
exactly one add_test entry, because the harness runs every registered case in one
binary. The top-level CMakeLists.txt calls enable_testing() when PLOT_BUILD_TESTS.
Top-level CMakeLists.txt: cmake_minimum_required(VERSION 3.24), project(plot VERSION 0.1.0 LANGUAGES CXX), include cmake/plot_options.cmake, declare the six PLOT_BUILD_* options from the plan header, build plot_core from src/**/*.cpp (explicit list), add_library(plot::core ALIAS plot_core), target_include_directories(plot_core PUBLIC include), target_compile_features(plot_core PUBLIC cxx_std_20), target_link_libraries(plot_core PRIVATE ${CMAKE_DL_LIBS}), then add_subdirectory(tests) when PLOT_BUILD_TESTS.
- Step 4: Run test to verify it passes
Run: cmake --build build/debug && ctest --test-dir build/debug --output-on-failure
Expected: linear_round_trip, log_x_round_trip, zoom_anchor_is_invariant all [ ok ], 3 cases, 0 failed, ctest reports 100% tests passed.
- Step 5: Commit
git add -A && git commit -m "feat: project skeleton, core types and math with transform tests"
Task 2: GL loader, RAII handles, capability probe, headless EGL harness
Files:
- Create:
include/plot/gl/{api,loader,handle,caps}.hpp,src/gl/loader.cpp,src/gl/handle.cpp - Create:
tests/egl_headless.hpp,tests/egl_headless.cpp,tests/test_gl_context.cpp - Modify:
CMakeLists.txt(addsrc/gl/*.cpp, EGL dependency for tests),tests/CMakeLists.txt
Interfaces:
-
Consumes:
plot::Status,plot::ErrorCode. -
Produces:
plot::gl::load(ProcLoader),plot::gl::caps(),plot::gl::Buffer/VertexArray/Program/Texture/Framebuffer/Fence, and test helperplottest::HeadlessContextwithbool create(Status*),void destroy(),u32 width/height,bool read_pixel(u32 x, u32 y, u8 rgba[4]). -
Step 1: Write the failing test
tests/test_gl_context.cpp:
#include "harness.hpp"
#include "egl_headless.hpp"
#include <plot/gl/loader.hpp>
#include <plot/gl/caps.hpp>
PLOT_TEST(headless_context_provides_gl33) {
plottest::HeadlessContext ctx;
plot::Status st;
PLOT_CHECK(ctx.create(&st)); // fails loudly if surfaceless EGL is unavailable
PLOT_CHECK(plot::gl::load(nullptr));
const plot::gl::Caps& c = plot::gl::caps();
PLOT_CHECK(c.major > 3 || (c.major == 3 && c.minor >= 3));
PLOT_CHECK(c.renderer != nullptr && c.renderer[0] != '\0');
ctx.destroy();
}
- Step 2: Run test to verify it fails
Run: cmake --build build/debug --target plot_tests && ./build/debug/tests/plot_tests
Expected: FAIL to compile — egl_headless.hpp not found.
- Step 3: Write the implementation
include/plot/gl/api.hpp — own typedefs and the function table, no Khronos headers:
#pragma once
#include <plot/core/types.hpp>
namespace plot::gl {
using GLenum = unsigned int; using GLuint = unsigned int;
using GLint = int; using GLsizei = int;
using GLboolean = unsigned char;
using GLbitfield = unsigned int; using GLintptr = std::intptr_t;
using GLsizeiptr = std::ptrdiff_t; using GLchar = char;
// Enums actually used. Values are fixed by the OpenGL specification.
enum : GLenum {
GL_NO_ERROR = 0, GL_TRIANGLES = 0x0004, GL_UNSIGNED_BYTE = 0x1401,
GL_FLOAT = 0x1406, GL_TRIANGLE_STRIP = 0x0005, GL_LINE_STRIP = 0x0003,
GL_ARRAY_BUFFER = 0x8892, GL_STATIC_DRAW = 0x88E4, GL_DYNAMIC_DRAW = 0x88E8,
GL_STREAM_DRAW = 0x88E0, GL_FRAGMENT_SHADER = 0x8B30, GL_VERTEX_SHADER = 0x8B31,
GL_COMPILE_STATUS = 0x8B81, GL_LINK_STATUS = 0x8B82, GL_RGBA8 = 0x8058,
GL_RGBA = 0x1908, GL_TEXTURE_2D = 0x0DE1, GL_FRAMEBUFFER = 0x8D40,
GL_COLOR_ATTACHMENT0 = 0x8CE0, GL_FRAMEBUFFER_COMPLETE = 0x8CD5,
GL_BLEND = 0x0BE2, GL_SRC_ALPHA = 0x0302, GL_ONE_MINUS_SRC_ALPHA = 0x0303,
GL_TEXTURE0 = 0x84C0, GL_LINEAR = 0x2601, GL_CLAMP_TO_EDGE = 0x812F,
GL_MAP_WRITE_BIT = 0x0002, GL_MAP_PERSISTENT_BIT = 0x0040,
GL_MAP_COHERENT_BIT = 0x0080, GL_SYNC_GPU_COMMANDS_COMPLETE = 0x9117,
/* … remaining enums added as used … */
};
// X-macro list: (return type, name, parameter list)
#define PLOT_GL_FUNCS(X) \
X(const GLubyte*, glGetString, (GLenum)) \
X(GLenum, glGetError, (void)) \
X(void, glViewport, (GLint, GLint, GLsizei, GLsizei)) \
X(void, glClearColor, (float, float, float, float)) \
X(void, glClear, (GLbitfield)) \
X(void, glEnable, (GLenum)) \
X(void, glBlendFunc, (GLenum, GLenum)) \
X(GLuint, glCreateShader, (GLenum)) \
X(void, glShaderSource, (GLuint, GLsizei, const GLchar* const*, const GLint*))\
X(void, glCompileShader, (GLuint)) \
X(void, glGetShaderiv, (GLuint, GLenum, GLint*)) \
X(void, glGetShaderInfoLog, (GLuint, GLsizei, GLsizei*, GLchar*)) \
X(GLuint, glCreateProgram, (void)) \
X(void, glAttachShader, (GLuint, GLuint)) \
X(void, glLinkProgram, (GLuint)) \
X(void, glGetProgramiv, (GLuint, GLenum, GLint*)) \
X(void, glGetProgramInfoLog, (GLuint, GLsizei, GLsizei*, GLchar*)) \
X(void, glUseProgram, (GLuint)) \
X(GLint, glGetUniformLocation, (GLuint, const GLchar*)) \
X(void, glUniform1f, (GLint, float)) \
X(void, glUniform2f, (GLint, float, float)) \
X(void, glUniform1i, (GLint, GLint)) \
X(void, glUniform2fv, (GLint, GLsizei, const float*)) \
X(void, glUniform1fv, (GLint, GLsizei, const float*)) \
X(void, glGenBuffers, (GLsizei, GLuint*)) \
X(void, glDeleteBuffers, (GLsizei, const GLuint*)) \
X(void, glBindBuffer, (GLenum, GLuint)) \
X(void, glBufferData, (GLenum, GLsizeiptr, const void*, GLenum)) \
X(void, glBufferSubData, (GLenum, GLintptr, GLsizeiptr, const void*)) \
X(void, glGenVertexArrays, (GLsizei, GLuint*)) \
X(void, glDeleteVertexArrays, (GLsizei, const GLuint*)) \
X(void, glBindVertexArray, (GLuint)) \
X(void, glEnableVertexAttribArray, (GLuint)) \
X(void, glVertexAttribPointer, (GLuint, GLint, GLenum, GLboolean, GLsizei, const void*))\
X(void, glDrawArrays, (GLenum, GLint, GLsizei)) \
X(void, glDrawArraysInstanced, (GLenum, GLint, GLsizei, GLsizei)) \
X(void, glGenTextures, (GLsizei, GLuint*)) \
X(void, glDeleteTextures, (GLsizei, const GLuint*)) \
X(void, glBindTexture, (GLenum, GLuint)) \
X(void, glTexImage2D, (GLenum, GLint, GLint, GLsizei, GLsizei, GLint, GLenum, GLenum, const void*))\
X(void, glTexParameteri, (GLenum, GLenum, GLint)) \
X(void, glGenFramebuffers, (GLsizei, GLuint*)) \
X(void, glDeleteFramebuffers, (GLsizei, const GLuint*)) \
X(void, glBindFramebuffer, (GLenum, GLuint)) \
X(void, glFramebufferTexture2D, (GLenum, GLenum, GLenum, GLuint, GLint)) \
X(GLenum, glCheckFramebufferStatus, (GLenum)) \
X(void, glReadPixels, (GLint, GLint, GLsizei, GLsizei, GLenum, GLenum, void*))\
X(void, glGenRenderbuffers, (GLsizei, GLuint*)) \
X(void, glDeleteRenderbuffers, (GLsizei, const GLuint*)) \
X(void, glBindRenderbuffer, (GLenum, GLuint)) \
X(void, glRenderbufferStorage, (GLenum, GLenum, GLsizei, GLsizei)) \
X(void, glFramebufferRenderbuffer, (GLenum, GLenum, GLenum, GLuint))
#define PLOT_GL_DECL(ret, name, args) extern ret (*name) args;
PLOT_GL_FUNCS(PLOT_GL_DECL)
#undef PLOT_GL_DECL
} // namespace plot::gl
include/plot/gl/loader.hpp:
#pragma once
#include <plot/core/status.hpp>
namespace plot::gl {
using ProcLoader = void* (*)(const char*);
/// Resolves every PLOT_GL_FUNCS entry. loader == nullptr selects the platform
/// default: glXGetProcAddressARB / eglGetProcAddress / wglGetProcAddress, then
/// dlsym / GetProcAddress on the system GL library. Returns false if any
/// required entry point is missing.
bool load(ProcLoader loader = nullptr);
} // namespace plot::gl
src/gl/loader.cpp resolves each name through the supplied loader, falling back to dlsym(RTLD_DEFAULT, name) and then dlopen("libGL.so.1") + dlsym. Report the first missing symbol via PLOT_ASSERT in debug and return false.
include/plot/gl/caps.hpp:
#pragma once
#include <plot/gl/api.hpp>
namespace plot::gl {
struct Caps {
int major = 0, minor = 0;
const char* renderer = "";
const char* vendor = "";
bool compute = false; // GL 4.3+
bool buffer_storage = false; // GL 4.4+ / ARB_buffer_storage
bool persistent_map = false; // 4.4+ or ARB_buffer_storage
bool core_profile = false;
};
const Caps& caps(); // valid after load(); queries the current context once
} // namespace plot::gl
include/plot/gl/handle.hpp + src/gl/handle.cpp: move-only RAII wrappers. Buffer with void upload(const void*, usize bytes, GLenum usage), void upload_sub(const void*, usize bytes, usize offset); VertexArray with void attrib(u32 index, i32 components, usize stride, usize offset) using a GL_FLOAT type; Program with static Program from_source(const char* vs, const char* fs, Status*) and void set_uniform(const char* name, f32) / (const char*, Vec2); Texture, Framebuffer (void attach_color(Texture&), bool complete()), Fence. All destructors are no-ops when id() == 0, so a Renderer destroyed after context loss is safe.
tests/egl_headless.cpp — surfaceless EGL, no window:
// 1. eglGetPlatformDisplayEXT(EGL_PLATFORM_SURFACELESS_MESA, EGL_DEFAULT_DISPLAY, nullptr)
// with fallback to eglGetDisplay(EGL_DEFAULT_DISPLAY).
// 2. eglInitialize, eglBindAPI(EGL_OPENGL_API).
// 3. choose a config with EGL_SURFACE_TYPE = EGL_PBUFFER_BIT, EGL_RENDERABLE_TYPE = EGL_OPENGL_BIT,
// EGL_RED/GREEN/BLUE/ALPHA_SIZE >= 8, EGL_DEPTH_SIZE = 0.
// 4. eglCreateContext(cfg, EGL_NO_CONTEXT, {EGL_CONTEXT_MAJOR_VERSION, 3,
// EGL_CONTEXT_MINOR_VERSION, 3, EGL_CONTEXT_OPENGL_PROFILE_MASK,
// EGL_CONTEXT_OPENGL_CORE_PROFILE_BIT_KHR, EGL_NONE}).
// 5. eglMakeCurrent(dpy, EGL_NO_SURFACE, EGL_NO_SURFACE, ctx) // requires EGL_KHR_surfaceless_context
// 6. create a color texture + framebuffer of (w,h), bind it.
// read_pixel() binds the FBO, glReadPixels(x, y, 1, 1, ...), returns true on success.
Link tests against EGL via find_package(EGL REQUIRED) or pkg-config --libs egl; if either is missing, PLOT_BUILD_TESTS MUST fail configuration with a clear message rather than silently skipping the GL tests.
- Step 4: Run test to verify it passes
Run: cmake --build build/debug && ctest --test-dir build/debug --output-on-failure
Expected: 4 tests pass. headless_context_provides_gl33 prints [ ok ]. If EGL surfaceless fails here, stop and switch egl_headless.cpp to the GLX hidden-window fallback before proceeding — Tasks 7–11 depend on this.
Quick manual confirmation of the GPU path:
./build/debug/tests/plot_tests # includes gl_context
- Step 5: Commit
git add -A && git commit -m "feat(gl): dependency-free GL loader, RAII handles, caps probe, headless EGL harness"
Task 3: Data layer — ring buffer, min/max pyramid, series
Files:
- Create:
include/plot/data/{ring_buffer,minmax_pyramid,series,array_series,stream_series}.hpp - Create:
src/data/{minmax_pyramid,array_series,stream_series}.cpp - Create:
tests/test_ring_buffer.cpp,tests/test_minmax_pyramid.cpp,tests/test_series.cpp - Modify:
CMakeLists.txt,tests/CMakeLists.txt
Interfaces:
-
Consumes:
plot::Range,plot::f32/f64/u64,plot::kInvalid. -
Produces:
plot::data::RingBuffer,plot::data::MinMaxPyramid,plot::data::Series,plot::data::ArraySeries,plot::data::StreamSeriesexactly as declared in spec §6, plusplot::data::MinMax{min,max},plot::data::kBucket == 64, andMinMaxPyramid::sum_range/sumsq_range(exact interval sums, O(kBucket) worst case — consumed by Task 5 measurements and by auto-scale). -
Step 1: Write the failing tests
tests/test_minmax_pyramid.cpp — the pyramid is the performance core, so it gets brute-force cross-checks:
#include "harness.hpp"
#include <plot/data/minmax_pyramid.hpp>
#include <random>
using plot::data::MinMaxPyramid;
using plot::Range;
PLOT_TEST(pyramid_bucket_extremes_preserved) {
MinMaxPyramid p;
p.reserve(1000);
for (int i = 0; i < 1000; ++i) p.append(i == 617 ? -50.0f : (i == 42 ? 900.0f : 1.0f));
PLOT_CHECK(p.size() == 1000);
const Range r = p.reduce(0, 1000);
PLOT_CHECK_NEAR(r.min, -50.0, 1e-6);
PLOT_CHECK_NEAR(r.max, 900.0, 1e-6);
}
PLOT_TEST(pyramid_reduce_matches_bruteforce) {
std::mt19937 rng(1234);
std::uniform_real_distribution<float> d(-100.0f, 100.0f);
std::vector<float> v(5000);
MinMaxPyramid p;
for (auto& x : v) { x = d(rng); p.append(x); }
for (u64 a = 0; a < 5000; a += 137) {
for (u64 b = a + 1; b <= 5000; b += 511) {
float lo = v[a], hi = v[a];
for (u64 i = a; i < b; ++i) { lo = std::min(lo, v[i]); hi = std::max(hi, v[i]); }
const Range r = p.reduce(a, b);
PLOT_CHECK_NEAR(r.min, lo, 1e-4);
PLOT_CHECK_NEAR(r.max, hi, 1e-4);
}
}
}
PLOT_TEST(pyramid_level_selection) {
MinMaxPyramid p;
p.reserve(1u << 20);
for (u64 i = 0; i < (1u << 20); ++i) p.append(0.0f);
// <= kBucket samples per pixel must use level 0 (raw path handles it anyway).
PLOT_CHECK(p.level_for(1u << 20, 1.0) == 0);
// 512 samples per pixel with kBucket == 64 → bucket size 512 → level 3.
PLOT_CHECK(p.level_for(1u << 20, 512.0) == 3);
// Level selection must never exceed the deepest level that has data.
PLOT_CHECK(p.level_for(1000, 1e9) <= p.level_count() - 1);
}
PLOT_TEST(pyramid_snap_returns_nearest_sample) {
MinMaxPyramid p;
for (int i = 0; i < 100; ++i) p.append(float(i));
PLOT_CHECK(p.snap(10.4, 0.0, 1.0) == 10);
PLOT_CHECK(p.snap(10.6, 0.0, 1.0) == 11);
PLOT_CHECK(p.snap(-5.0, 0.0, 1.0) == 0); // clamped
PLOT_CHECK(p.snap(1e9, 0.0, 1.0) == 99); // clamped
}
PLOT_TEST(pyramid_sums_match_bruteforce) {
std::mt19937 rng(99);
std::uniform_real_distribution<float> d(-3.0f, 7.0f);
std::vector<float> v(3000);
MinMaxPyramid p;
for (auto& x : v) { x = d(rng); p.append(x); }
for (u64 a = 0; a < 3000; a += 311) {
for (u64 b = a + 1; b <= 3000; b += 419) {
double s = 0.0, sq = 0.0;
for (u64 i = a; i < b; ++i) { s += v[i]; sq += double(v[i]) * double(v[i]); }
PLOT_CHECK_NEAR(p.sum_range(a, b), s, 1e-3);
PLOT_CHECK_NEAR(p.sumsq_range(a, b), sq, 1e-2);
}
}
}
PLOT_TEST(pyramid_clear_and_rebuild) {
MinMaxPyramid p;
for (int i = 0; i < 500; ++i) p.append(1.0f);
p.clear();
PLOT_CHECK(p.size() == 0);
PLOT_CHECK(p.level_count() <= 1);
p.append(7.0f);
PLOT_CHECK(p.size() == 1);
PLOT_CHECK_NEAR(p.reduce(0, 1).max, 7.0, 1e-6);
}
tests/test_ring_buffer.cpp:
PLOT_TEST(ring_wraps_and_keeps_latest) {
plot::data::RingBuffer<f32> r(8); // capacity rounded up to a power of two
for (int i = 0; i < 20; ++i) r.push(f32(i));
PLOT_CHECK(r.size() == 8);
PLOT_CHECK_NEAR(r[0], 12.0); // oldest retained
PLOT_CHECK_NEAR(r[7], 19.0); // newest
}
PLOT_TEST(ring_clear_resets) {
plot::data::RingBuffer<f32> r(8);
for (int i = 0; i < 5; ++i) r.push(f32(i));
r.clear();
PLOT_CHECK(r.size() == 0);
r.push(42.0f);
PLOT_CHECK(r.size() == 1);
PLOT_CHECK_NEAR(r[0], 42.0);
}
tests/test_series.cpp:
PLOT_TEST(array_series_bounds_and_gather) {
std::vector<f32> y{1, 5, -2, 3};
plot::data::ArraySeries s(std::move(y), /*x0=*/10.0, /*dx=*/0.5);
PLOT_CHECK(s.size() == 4);
PLOT_CHECK_NEAR(s.x_bounds().min, 10.0, 1e-9);
PLOT_CHECK_NEAR(s.x_bounds().max, 11.5, 1e-9);
PLOT_CHECK_NEAR(s.y_bounds().min, -2.0, 1e-6);
std::vector<f32> out;
s.gather(0, 0, 4, out);
PLOT_CHECK(out.size() == 8); // interleaved x,y
PLOT_CHECK_NEAR(out[0], 10.0, 1e-6);
PLOT_CHECK_NEAR(out[1], 1.0, 1e-6);
PLOT_CHECK_NEAR(out[6], 11.5, 1e-6);
}
PLOT_TEST(stream_series_acquire_publishes_snapshot) {
plot::data::StreamSeries s(1 << 12, /*x0=*/0.0, /*dx=*/1e-6);
for (int i = 0; i < 100; ++i) s.push(f32(i));
PLOT_CHECK(s.produced() == 100);
s.acquire();
PLOT_CHECK(s.size() == 100);
// Pyramid must track the published samples.
PLOT_CHECK_NEAR(s.pyramid().reduce(0, 100).max, 99.0, 1e-4);
for (int i = 100; i < 250; ++i) s.push(f32(i));
s.acquire();
PLOT_CHECK(s.size() == 250);
}
PLOT_TEST(stream_series_drops_oldest_when_full) {
plot::data::StreamSeries s(256, 0.0, 1.0);
for (int i = 0; i < 1000; ++i) s.push(f32(i));
s.acquire();
PLOT_CHECK(s.size() <= 256);
PLOT_CHECK_NEAR(s.y_bounds().max, 999.0, 1e-4);
}
- Step 2: Run tests to verify they fail
Run: cmake --build build/debug --target plot_tests
Expected: FAIL to compile — plot/data/minmax_pyramid.hpp not found.
- Step 3: Write the implementation
include/plot/data/minmax_pyramid.hpp:
#pragma once
#include <plot/core/types.hpp>
#include <span>
#include <vector>
namespace plot::data {
struct MinMax { f32 min = 0.0f; f32 max = 0.0f; };
inline constexpr u32 kBucket = 64;
class MinMaxPyramid {
public:
void reserve(u64 n);
void append(f32 v);
void clear();
u64 size() const noexcept { return size_; }
u32 level_count() const noexcept { return u32(levels_.size()); }
std::span<const MinMax> level(u32 l) const;
Range reduce(u64 begin, u64 end) const; // half-open [begin, end)
f64 sum_range(u64 begin, u64 end) const; // exact sum over [begin, end)
f64 sumsq_range(u64 begin, u64 end) const; // exact sum of squares
u32 level_for(u64 n, f64 samples_per_pixel) const;
u64 snap(f64 x, f64 x0, f64 dx) const;
private:
u64 size_ = 0;
std::vector<std::vector<MinMax>> levels_; // levels_[0] = kBucket granularity
std::vector<std::vector<f64>> sums_, sumsqs_; // inclusive prefix sums per level
};
} // namespace plot::data
Implementation rules for src/data/minmax_pyramid.cpp:
append(v): write/merge into the last bucket oflevels_[0], then for each levell, if levellgained a complete pair, merge the last two entries of levellinto a new entry of levell+1; otherwise stop early. Amortised O(1).reduce(begin, end):if (end <= begin) return {0,0};walk the range: handle the partial head bucket and partial tail bucket by consultinglevels_[0], and cover the aligned middle by descending to the coarsest level whose buckets are fully inside the range. Never scan raw samples — the pyramid stores no raw samples.level_for(n, spp):if (spp <= f64(kBucket)) return 0;elseu32 l = u32(std::floor(std::log2(spp / f64(kBucket))));clamp tolevel_count() - 1, and additionally clamp so thatlevel(l).size() >= 2.snap(x, x0, dx):if (dx <= 0.0 || size_ == 0) return 0;computei = llround((x - x0) / dx)and clamp to[0, size_ - 1].
include/plot/data/ring_buffer.hpp: RingBuffer<T> with power-of-two capacity, atomic producer index, push(T) overwriting the oldest entry when full, size(), operator[](u64) indexing oldest→newest, clear().
include/plot/data/series.hpp: the abstract Series from spec §6, virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const = 0; documented as "appends interleaved (x, y) pairs in data coordinates for bucket-aligned level level; level 0 emits raw samples".
ArraySeries: holds std::vector<f32> y_, f64 x0_, f64 dx_, a MinMaxPyramid, and cached Range y_bounds_/Range x_bounds_. Constructor and reset() fill the pyramid and bounds; commit() rebuilds them (for when the caller mutated the vector out of band — document that the vector is owned and mutated only via reset).
StreamSeries: RingBuffer<f32> of rounded-up capacity, atomic produced_, f64 x0_, f64 dx_; push is the only producer entry point and MUST NOT touch the pyramid; acquire() reads produced_ once, grows the pyramid by the newly produced samples, and updates bounds. x_bounds() derives from the oldest retained index and dx_, so a wrap shifts the window forward.
Add test_ring_buffer.cpp, test_minmax_pyramid.cpp and test_series.cpp to the existing plot_tests target's source list in tests/CMakeLists.txt. Do not add new add_test entries — one add_test(NAME plot_tests ...) runs every registered case.
- Step 4: Run tests to verify they pass
Run: cmake --build build/debug && ctest --test-dir build/debug --output-on-failure
Expected: pyramid_reduce_matches_bruteforce (the 1300-odd range comparisons) passes, along with all series and ring tests. 0 failed.
- Step 5: Commit
git add -A && git commit -m "feat(data): SPSC ring buffer, incremental min/max LOD pyramid, series types"
Task 4: Render layer — programs, buffers, batching, bitmap text
Files:
- Create:
include/plot/render/{style,text,bitmap_font,program,buffer,vertex_array,primitives}.hpp - Create:
src/render/{program,buffer,vertex_array,bitmap_font,primitives}.cpp - Create:
src/render/shaders/*.glsl(embedded at build time via a generated header, or as raw string literals in.cpp— pick the raw-string approach to avoid a build step) - Create:
tests/test_primitives.cpp
Interfaces:
-
Consumes:
plot::gl::{Buffer,VertexArray,Program},plot::gl::caps(),plot::Color,plot::Rect,plot::Transform. -
Produces:
plot::render::Style,plot::render::TextBackend(abstract),plot::render::BitmapFont,plot::render::LineBatch,plot::render::TextBatch, each withbegin()/flush()semantics, and the GLSL sources asconstexpr const char* kLineVS/kLineFS/kTextVS/kTextFS. -
Step 1: Write the failing test
tests/test_primitives.cpp (CPU-side geometry, no GL needed):
#include "harness.hpp"
#include <plot/render/primitives.hpp>
PLOT_TEST(line_batch_emits_two_verts_per_segment) {
plot::render::LineBatch b;
b.begin();
const plot::Vec2 pts[3] = {{0, 0}, {1, 1}, {2, 0}};
b.add_polyline(pts, 3); // 2 segments
PLOT_CHECK(b.vertex_count() == 4);
b.clear();
PLOT_CHECK(b.vertex_count() == 0);
PLOT_CHECK(b.data().capacity() >= 4); // clear() must not free capacity
b.add_segment({0, 0}, {1, 1});
PLOT_CHECK(b.vertex_count() == 2);
}
PLOT_TEST(text_batch_measures_ascii) {
plot::render::BitmapFont f;
const f32 w = f.measure("12.5 ms");
PLOT_CHECK(w > 0.0f);
PLOT_CHECK_NEAR(f.measure(""), 0.0, 1e-6);
PLOT_CHECK(f.measure("ab") < f.measure("abc")); // monotonic in length
PLOT_CHECK(f.measure("iiii") < f.measure("WWWW")); // proportional-ish, not fixed-width-blank
}
- Step 2: Run test to verify it fails
Run: cmake --build build/debug --target plot_tests
Expected: FAIL to compile — plot/render/primitives.hpp not found.
- Step 3: Write the implementation
include/plot/render/style.hpp:
#pragma once
#include <plot/core/color.hpp>
namespace plot::render {
struct Style {
Color background = Color::hex(0x101418);
Color grid_major = Color::hex(0x2A3038);
Color grid_minor = Color::hex(0x1C2126);
Color axis_line = Color::hex(0x8A94A6);
Color axis_text = Color::hex(0xC8D0DC);
Color cursor = Color::hex(0xFFB020);
Color measure_fill = Color::hex(0xFFB020, 0.15f);
f32 axis_font_px = 12.0f;
f32 grid_label_pad = 4.0f;
f32 line_width_px = 1.5f;
f32 dense_threshold_spp = 1.5f; // spec §4.3
f32 tick_min_px = 60.0f; // minimum spacing between major ticks
};
} // namespace plot::render
include/plot/render/text.hpp: class TextBackend { public: virtual ~TextBackend(); virtual f32 measure(const char*) const = 0; virtual f32 height() const = 0; virtual f32 ascent() const = 0; }.
include/plot/render/bitmap_font.hpp: class BitmapFont final : public TextBackend — owns a 95-glyph (ASCII 32..126) 8×16 1-bit atlas as a static constexpr u8 kAtlas[95 * 16] table in the .cpp, plus f32 measure, f32 advance(char), and u32 glyph_rows(char) returning the 16 rows. measure("") == 0.
include/plot/render/primitives.hpp:
#pragma once
#include <plot/core/color.hpp>
#include <plot/math/vec2.hpp>
#include <vector>
namespace plot::render {
/// CPU-side staging for one instanced-AA line draw call.
class LineBatch {
public:
void begin();
void clear();
void add_segment(Vec2 a, Vec2 b); // one instance
void add_polyline(const Vec2* pts, u32 n); // n-1 instances
u32 vertex_count() const noexcept; // 2 vertices per staged segment
const std::vector<f32>& data() const noexcept; // interleaved x0,y0,x1,y1
};
} // namespace plot::render
src/render/primitives.cpp implements the staging buffers with reserve/resize reuse — clear() MUST NOT free capacity, and begin() MUST NOT reallocate when the frame's workload matches the previous frame.
GLSL sources live as raw string literals in src/render/program.cpp:
kLineVS: inputsvec2 a_p0, a_p1(per-instance) and a unit-quad corner viagl_VertexID; uniformsvec2 u_scale, u_offset(data→pixel),vec2 u_viewport,float u_width; expands the segment in pixel space byu_width/2along the segment normal plus 1px of AA fringe, and outputsv_dist(distance to the segment axis in pixels) andv_along.kLineFS:discardwhenlength(v_dist) > u_width * 0.5 + 1.0; alpha =smoothstepover the last pixel of the half-width; outputsu_color * alpha.kTextVS/kTextFS: instanced quads from the glyph atlas; a single-channelGL_R8texture sampled withGL_NEAREST, tinted byu_color, withu_px_range/u_tex_sizeuniforms.
Both shaders declare #version 330 core. The 4.3+ fast path in Task 7 changes only the buffer upload strategy, never the shader source, so these stay valid on both paths.
include/plot/render/program.hpp / buffer.hpp / vertex_array.hpp are thin owning wrappers over plot::gl::{Program,Buffer,VertexArray} that know the attribute layouts for the line and text pipelines (offsetof-based, no magic numbers in callers).
- Step 4: Run test to verify it passes
Run: cmake --build build/debug && ctest --test-dir build/debug --output-on-failure
Expected: line_batch_emits_two_verts_per_segment, text_batch_measures_ascii [ ok ], 0 failed.
Then confirm the shaders compile on the real driver (this catches GLSL syntax errors that no CPU test can). Append this permanent test to tests/test_gl_context.cpp:
PLOT_TEST(shaders_compile_and_link) {
plottest::HeadlessContext ctx; plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Status s0, s1;
plot::gl::Program line = plot::gl::Program::from_source(kLineVS, kLineFS, &s0);
plot::gl::Program text = plot::gl::Program::from_source(kTextVS, kTextFS, &s1);
PLOT_CHECK(bool(s0)); PLOT_CHECK(bool(s1));
PLOT_CHECK(line.id() != 0); PLOT_CHECK(text.id() != 0);
ctx.destroy();
}
- Step 5: Commit
git add -A && git commit -m "feat(render): style, GLSL line/text pipelines, staging batches, bitmap font"
Task 5: Plot layer — ticks, axes, grid, trace, cursor, measurement
Files:
- Create:
include/plot/plot/{ticks,axis,grid,trace,cursor,measure,annotation,plot}.hpp - Create:
src/plot/{ticks,axis,grid,plot,measure}.cpp - Create:
tests/test_ticks.cpp,tests/test_measure.cpp
Interfaces:
-
Consumes:
plot::data::{Series,MinMaxPyramid},plot::Range,plot::Transform,plot::render::{Style,TextBackend}. -
Produces:
plot::Tick{ f64 value; bool major; std::string label; },plot::generate_ticks(Range, f32 px, f32 min_px, std::vector<Tick>&),plot::format_si(f64, char* buf, usize n),plot::format_time(f64 seconds, char*, usize),plot::AxisPlace,plot::Axis,plot::Grid,plot::Trace,plot::Cursor,plot::Measurement{ dx, dy, slope, rms, mean, ymin, ymax, integral, valid },plot::Measure::evaluate(const data::Series&, u64 begin, u64 end) -> Measurement, andplot::Plotwithlayout(),trace_count(),measurement(u32). -
Step 1: Write the failing tests
tests/test_ticks.cpp:
PLOT_TEST(nice_ticks_are_powers_of_ten_times_125) {
std::vector<plot::Tick> t;
plot::generate_ticks({0.0, 1.0}, /*px=*/500.0f, /*min_px=*/60.0f, t);
PLOT_CHECK(!t.empty());
for (auto& tick : t) PLOT_CHECK(std::fabs(tick.value * 5.0 - std::round(tick.value * 5.0)) < 1e-9);
// spacing of major ticks >= min_px
PLOT_CHECK(t.size() <= 500.0 / 60.0 + 2.0);
}
PLOT_TEST(ticks_are_monotonic_and_cover_range) {
std::vector<plot::Tick> t;
plot::generate_ticks({-3.7, 91.2}, 800.0f, 60.0f, t);
for (size_t i = 1; i < t.size(); ++i) PLOT_CHECK(t[i].value > t[i-1].value);
PLOT_CHECK(t.front().value <= 0.0);
PLOT_CHECK(t.back().value >= 90.0);
}
PLOT_TEST(si_formatting) {
char b[32];
plot::format_si(0.0000015, b, sizeof b); PLOT_CHECK(std::string(b) == "1.5\xC2\xB5s" || std::string(b).find("1.5") == 0);
plot::format_si(2.5e9, b, sizeof b); PLOT_CHECK(std::string(b).find("2.5") == 0);
plot::format_si(1234.0, b, sizeof b); PLOT_CHECK(std::string(b).find("1.23") == 0);
plot::format_si(0.0, b, sizeof b); PLOT_CHECK(std::string(b) == "0");
}
PLOT_TEST(degenerate_ranges_do_not_hang) {
std::vector<plot::Tick> t;
plot::generate_ticks({5.0, 5.0}, 500.0f, 60.0f, t); // must not loop forever
PLOT_CHECK(t.size() <= 64);
plot::generate_ticks({0.0, 0.0}, 0.0f, 60.0f, t);
plot::generate_ticks({1e300, 1e300}, 500.0f, 60.0f, t);
}
tests/test_measure.cpp:
PLOT_TEST(measurement_over_interval_matches_direct_computation) {
std::vector<f32> y; for (int i = 0; i < 1000; ++i) y.push_back(f32(std::sin(i * 0.01)));
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
const auto m = plot::Measure::evaluate(s, 100, 800);
double sum = 0, sumsq = 0, integral = 0; float lo = 1e30f, hi = -1e30f;
for (u64 i = 100; i < 800; ++i) {
const double v = std::sin(i * 0.01);
sum += v; sumsq += v * v; integral += v; // dx == 1
lo = std::min(lo, float(v)); hi = std::max(hi, float(v));
}
const double n = 700;
PLOT_CHECK_NEAR(m.mean, sum / n, 1e-5);
PLOT_CHECK_NEAR(m.rms, std::sqrt(sumsq / n), 1e-5);
PLOT_CHECK_NEAR(m.integral, integral, 1e-3);
PLOT_CHECK_NEAR(m.ymin, lo, 1e-6);
PLOT_CHECK_NEAR(m.ymax, hi, 1e-6);
PLOT_CHECK_NEAR(m.dx, 699.0, 1e-9);
}
PLOT_TEST(measurement_uses_pyramid_not_scan) {
// Guard against a future regression that linear-scans the series: a huge
// series must evaluate a 1-sample-wide interval in well under a millisecond.
std::vector<f32> y(1u << 22, 1.0f); // 4M samples
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
const auto t0 = std::chrono::steady_clock::now();
for (int i = 0; i < 1000; ++i) (void)plot::Measure::evaluate(s, 1'000'000, 1'000'001);
const auto us = std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - t0).count();
PLOT_CHECK(us < 50'000); // 1000 evals in < 50 ms
}
PLOT_TEST(empty_and_single_sample_intervals_are_valid_false) {
std::vector<f32> y{3.0f};
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
PLOT_CHECK(!plot::Measure::evaluate(s, 0, 0).valid);
PLOT_CHECK(!plot::Measure::evaluate(s, 1, 1).valid);
}
- Step 2: Run tests to verify they fail
Run: cmake --build build/debug --target plot_tests
Expected: FAIL to compile — plot/plot/ticks.hpp not found.
- Step 3: Write the implementation
src/plot/ticks.cpp — the nice-number algorithm:
// generate_ticks(range, px, min_px, out):
// if (!(range.span() > 0) || px <= 0) { out.clear(); return; }
// const f64 target_count = std::max(1.0, f64(px) / f64(min_px));
// const f64 raw_step = range.span() / target_count;
// const f64 mag = std::pow(10.0, std::floor(std::log10(raw_step)));
// const f64 norm = raw_step / mag; // in [1, 10)
// const f64 step = mag * (norm <= 1.5 ? 1.0 : norm <= 3.0 ? 2.0 : norm <= 7.0 ? 5.0 : 10.0);
// f64 first = std::ceil(range.min / step) * step;
// for (f64 v = first; v <= range.max + step * 1e-9; v += step) out.push_back({v, true, {}});
// // minor ticks: step/5 (or step/4 when step starts with 2, step/5 when with 5), same loop.
// // hard guard: if (out.size() > 256) out.resize(256);
Labels are assigned by format_si unless the axis is a time axis, in which case format_time is used. Ticks whose label would be non-finite or absurdly long (> 12 chars) fall back to %.3g.
src/plot/measure.cpp:
Measurement Measure::evaluate(const data::Series& s, u64 begin, u64 end) {
Measurement r{};
if (end <= begin || end > s.size()) return r;
// Sums (mean, rms, integral): O(1) interior from level-0 prefix sums plus a
// scan of at most two partial edge buckets (<= 2*kBucket samples).
// Extrema: pyramid().reduce(begin, end), O(log n), never scans.
// dx = (end - 1 - begin) * dx_of_series;
// slope = (y[end-1] - y[begin]) / dx (0 when dx == 0);
// dy = y[end-1] - y[begin];
// valid = true.
return r;
}
reduce() supplies ymin/ymax in O(log n) and never scans the series. mean/rms/integral use MinMaxPyramid::sum_range/sumsq_range (Task 3): O(1) over bucket-aligned interiors from the level-0 prefix sums, plus a direct scan of at most two partial edge buckets (<= 2 x kBucket = 128 samples). A measurement therefore never scales with series length, which is exactly what measurement_uses_pyramid_not_scan gates. integral = sum_range(begin,end) * dx, mean = sum_range/n, rms = sqrt(sumsq_range/n), where n = end - begin.
include/plot/plot/plot.hpp — the public façade, exactly the API in spec §6 plus:
struct PlotLayout {
Rect axes; // plot area (excludes tick labels)
Rect y_label_area, x_label_area;
f32 y_axis_width = 56.0f;
f32 x_axis_height = 22.0f;
};
Plot owns vectors of axes/traces/cursors/measures, computes PlotLayout from viewport() and the text backend metrics, and recomputes measurement results for every measure whose cursors moved (dirty flag, not every frame).
Grid produces tick-aligned line segments in data space; Trace binds a Series to a color/width/axis pair; Cursor carries f64 x, SnapMode { Free, NearestSample }, color and an optional label; Annotation is a pinned point with text.
- Step 4: Run tests to verify they pass
Run: cmake --build build/debug && ctest --test-dir build/debug --output-on-failure
Expected: all tick and measure tests [ ok ], including measurement_uses_pyramid_not_scan. 0 failed.
- Step 5: Commit
git add -A && git commit -m "feat(plot): nice-number ticks, axes, grid, traces, cursors, measurement queries"
Task 6: Input layer — events and controller
Files:
- Create:
include/plot/input/{events,controller}.hpp,src/input/controller.cpp - Create:
tests/test_controller.cpp
Interfaces:
-
Consumes:
plot::{Plot,Rect,Range,Transform}. -
Produces:
plot::input::{Button,Mod,PointerEvent,WheelEvent,KeyEvent,DragMode,Controller}exactly as in spec §6. -
Step 1: Write the failing test
PLOT_TEST(wheel_zoom_keeps_data_under_cursor_fixed) {
plot::Plot p; p.set_viewport({0, 0, 800, 400});
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, true, plot::AxisPlace::Bottom, false});
plot::input::Controller c(p);
c.set_viewport({0, 0, 800, 400});
const f32 cursor_x = 200.0f;
const f64 before = p.x_transform(ax).to_data_x(cursor_x);
plot::input::WheelEvent w{cursor_x, 100.0f, 0.0f, 1.0f, 0};
c.on_wheel(w);
const f64 after = p.x_transform(ax).to_data_x(cursor_x);
PLOT_CHECK_NEAR(before, after, 1e-6);
PLOT_CHECK(plot::Range{p.x_range(ax)}.span() < 100.0); // zoomed in
}
PLOT_TEST(left_drag_pans_without_changing_span) {
plot::Plot p; p.set_viewport({0, 0, 800, 400});
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, true, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
plot::input::Controller c(p); c.set_viewport({0, 0, 800, 400});
const double span_before = p.x_range(ax).span();
c.on_pointer({100, 100, 0, 0, plot::input::Button::Left, 0, true, true, false});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::Pan);
c.on_pointer({180, 130, 80, 30, plot::input::Button::Left, 0, true, false, false});
c.on_pointer({180, 130, 0, 0, plot::input::Button::Left, 0, false, false, true});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None);
PLOT_CHECK_NEAR(p.x_range(ax).span(), span_before, 1e-9); // span unchanged
PLOT_CHECK_NEAR(p.x_range(ax).min, 10.0, 1e-6); // 80/800 * 100 data units
PLOT_CHECK(p.y_range(ay).max > 1.0); // moving down raises y range
}
PLOT_TEST(shift_drag_box_zooms_to_selection) {
plot::Plot p; p.set_viewport({0, 0, 800, 400});
const u32 ax = p.add_x_axis({"t", {0.0, 800.0}, true, plot::AxisPlace::Bottom, false});
plot::input::Controller c(p); c.set_viewport({0, 0, 800, 400});
const u32 e_shift = 1u << 0; // Mod::Shift
c.on_pointer({100, 50, 0, 0, plot::input::Button::Left, e_shift, true, true, false});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::BoxZoom);
c.on_pointer({300, 150, 200, 100, plot::input::Button::Left, e_shift, true, false, false});
PLOT_CHECK_NEAR(c.selection_rect().x, 100.0, 1e-6);
PLOT_CHECK_NEAR(c.selection_rect().w, 200.0, 1e-6);
c.on_pointer({300, 150, 0, 0, plot::input::Button::Left, e_shift, false, false, true});
PLOT_CHECK_NEAR(p.x_range(ax).min, 100.0, 1e-6);
PLOT_CHECK_NEAR(p.x_range(ax).max, 300.0, 1e-6);
}
PLOT_TEST(auto_scale_disabled_by_user_interaction) {
// A wheel zoom or pan MUST clear auto_scale on the affected axis so the range
// the user chose is not snapped back by the next autoscale.
plot::Plot p; p.set_viewport({0, 0, 800, 400});
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, true, plot::AxisPlace::Bottom, false});
plot::input::Controller c(p); c.set_viewport({0, 0, 800, 400});
c.on_wheel({400.0f, 200.0f, 0.0f, 1.0f, 0});
PLOT_CHECK(!p.x_axis(ax).auto_scale);
}
- Step 2: Run test to verify it fails
Run: cmake --build build/debug --target plot_tests
Expected: FAIL to compile — plot/input/controller.hpp not found.
- Step 3: Write the implementation
src/input/controller.cpp gesture table:
| gesture | mode | effect |
|---|---|---|
| left drag | Pan |
translate both ranges by -dx_px / plot_width * x_span (and y likewise, inverted) |
| shift + left drag | BoxZoom |
record selection_rect(); on release set both ranges from the rect (ignore sub-4px rects — treat as a click) |
| ctrl + left drag near a cursor | MoveCursor |
move that cursor, re-snapping and re-evaluating its measures |
| wheel at (x,y) | — | zoom both axes about the pointer's data coordinate by exp(-dy * k); hold shift to zoom x only, ctrl to zoom y only |
| middle drag on the x-axis strip | AxisZoomX |
x-only zoom |
| middle drag on the y-axis strip | AxisZoomY |
y-only zoom |
| right drag | Pan |
same as left, for hosts that reserve left-drag |
key +/− |
— | zoom about the plot centre |
key F |
— | auto_fit_x() + auto_fit_y on all y axes |
Every gesture that changes a range MUST set auto_scale = false on the affected axis. Zoom must be implemented by recomputing Transform from the anchored data coordinate (the same arithmetic the Task 1 zoom_anchor_is_invariant test pins), never by scaling the range around its midpoint.
Add Plot::x_transform(u32)/y_transform(u32), x_range(u32)/y_range(u32), x_axis(u32) accessors as needed by the tests and by the renderer.
- Step 4: Run test to verify it passes
Run: cmake --build build/debug && ctest --test-dir build/debug --output-on-failure
Expected: all four controller tests [ ok ]. 0 failed.
- Step 5: Commit
git add -A && git commit -m "feat(input): host-agnostic events and pan/zoom/cursor controller"
Task 7: Renderer and headless render smoke test
Files:
- Create:
include/plot/render/renderer.hpp,src/render/renderer.cpp - Create:
tests/test_render_smoke.cpp - Modify:
CMakeLists.txt,tests/CMakeLists.txt
Interfaces:
-
Consumes: everything below it.
-
Produces:
plot::Rendererwithbool init(Status*),void begin_frame(u32 fb_w, u32 fb_h, Rect viewport_px),void draw(Plot&),void end_frame(),Style& style(),bool last_frame_had_gl_error() const, andvoid dump_frame_ppm(const char* path)(used by the test whenPLOT_DUMP_FRAMESis set). ConsumesPlot::layout(),Plot::x_transform(...),Plot::x_range(...),Plot::add_trace(...)from Tasks 5 and 6. -
Step 1: Write the failing test
PLOT_TEST(renders_visible_trace_across_viewport) {
plottest::HeadlessContext ctx; plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
const u32 W = 640, H = 360;
std::vector<f32> y(1'000'000);
for (u64 i = 0; i < y.size(); ++i) y[i] = f32(std::sin(double(i) * 0.0001));
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
plot::Plot p;
p.set_viewport({0, 0, f32(W), f32(H)});
const u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
const u32 tr = p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
r.begin_frame(W, H, {0, 0, f32(W), f32(H)});
r.draw(p);
r.end_frame();
// Trace colour must appear in every one of 16 sampled columns across the
// plot area; background must dominate the corners.
const plot::Rect a = p.layout().axes;
u32 hit_columns = 0;
for (u32 c = 0; c < 16; ++c) {
const u32 x = u32(a.x + a.w * (f64(c) + 0.5) / 16.0);
bool hit = false;
for (u32 yy = u32(a.y); yy < u32(a.y + a.h) && !hit; ++yy) {
u8 px[4]; PLOT_CHECK(ctx.read_pixel(x, yy, px));
if (px[2] > 120 && px[3] > 0 && px[0] < 160) hit = true; // blue-ish
}
if (hit) ++hit_columns;
}
PLOT_CHECK(hit_columns >= 15); // >= 95% of columns covered
u8 corner[4]; PLOT_CHECK(ctx.read_pixel(2, 2, corner));
PLOT_CHECK(corner[0] + corner[1] + corner[2] < 200); // dark background
PLOT_CHECK(!r.last_frame_had_gl_error());
ctx.destroy();
}
PLOT_TEST(dense_and_sparse_paths_agree_on_extent) {
plottest::HeadlessContext ctx; plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r; PLOT_CHECK(r.init(&st));
const u32 W = 640, H = 360;
std::vector<f32> y(1'000'000);
for (u64 i = 0; i < y.size(); ++i) y[i] = f32(std::sin(double(i) * 0.0001));
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
plot::Plot p;
p.set_viewport({0, 0, f32(W), f32(H)});
const u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
auto covered_columns = [&](plot::Range xr) -> u32 {
p.set_x_range(ax, xr);
r.begin_frame(W, H, {0, 0, f32(W), f32(H)});
r.draw(p);
r.end_frame();
const plot::Rect a = p.layout().axes;
u32 cols = 0;
for (u32 c = 0; c < 32; ++c) {
const u32 x = u32(a.x + a.w * (f64(c) + 0.5) / 32.0);
for (u32 yy = u32(a.y); yy < u32(a.y + a.h); ++yy) {
u8 px[4]; PLOT_CHECK(ctx.read_pixel(x, yy, px));
if (px[2] > 120 && px[0] < 160) { ++cols; break; }
}
}
return cols;
};
// Dense: 1M samples over 1M x-units -> ~1562 samples/pixel -> LOD buckets.
const u32 coarse = covered_columns({0.0, 1'000'000.0});
// Sparse: 640 samples over 640 x-units -> 1 sample/pixel -> instanced quads.
const u32 fine = covered_columns({0.0, 640.0});
PLOT_CHECK(coarse >= 30); // >= 95% of 32 columns
PLOT_CHECK(fine >= 30);
PLOT_CHECK(!r.last_frame_had_gl_error());
ctx.destroy();
}
- Step 2: Run test to verify it fails
Run: cmake --build build/debug --target plot_tests
Expected: FAIL to compile — plot/render/renderer.hpp not found.
- Step 3: Write the implementation
src/render/renderer.cpp frame sequence:
begin_frame(fb_w, fb_h, viewport_px):
glViewport(fb_w, fb_h, viewport_px); // origin flipped: Rect is top-left, GL is bottom-left
clear the viewport to style.background
reset all batches
draw(plot):
for each axis: compute ticks; stage grid segments; stage tick labels
for each visible trace:
spp = x_span_samples / plot_width_px
if (spp < style.dense_threshold_spp) stage raw polyline (level 0, instanced path)
else level = pyramid.level_for(n, spp);
stage bucket min/max vertical strokes
flush grids, then line batches, then text last (text is texture-bound, draw once)
end_frame():
unbind everything; record glGetError into last_frame_had_gl_error()
Upload strategy selected once in init():
caps().buffer_storage && caps().persistent_map→ allocate each VBO withglBufferStorage(MAP_WRITE|MAP_PERSISTENT|MAP_COHERENT), keep a persistent pointer, and triple-buffer withglFenceSync/glClientWaitSync(0)before reusing a slot.- otherwise →
glBufferData(size, nullptr, GL_STREAM_DRAW)orphan-and-refill each frame.
Both paths go through the same upload_vertices(span) helper so no caller sees the difference.
Coordinate note to get right the first time: Rect is top-left-origin in pixels, GL is bottom-left. Convert once in begin_frame and store the flipped viewport in the renders; never flip per-primitive.
- Step 4: Run test to verify it passes
Run: cmake --build build/debug && ctest --test-dir build/debug --output-on-failure
Expected: renders_visible_trace_across_viewport [ ok ] with hit_columns >= 15, dense_and_sparse_paths_agree_on_extent [ ok ]. 0 failed.
Save one rendered frame as a PPM to build/debug/smoke.ppm from the test when PLOT_DUMP_FRAMES is set in the environment, so the render can be eyeballed without a window.
- Step 5: Commit
git add -A && git commit -m "feat(render): Renderer with LOD selection, 3.3 and 4.3+ upload paths, headless smoke test"
Task 8: GLFW adapter and 1M-sample demo
Files:
- Create:
adapters/glfw/{CMakeLists.txt,glfw_host.hpp,glfw_host.cpp} - Create:
examples/glfw_demo.cpp - Modify:
CMakeLists.txt(add_subdirectory(adapters/glfw)whenPLOT_BUILD_GLFW),examples/CMakeLists.txt
Interfaces:
-
Consumes:
plot::Renderer,plot::Plot,plot::input::Controller. -
Produces:
plot::glfw::Host— owns the window/context/loop. Types:struct RunConfig { int width = 1280; int height = 720; const char* title = "plot"; bool vsync = true; }; using FrameFn = std::function<void(plot::Plot&, f64 dt)>; bool run(const RunConfig&, const FrameFn&, plot::Status* = nullptr);Translates GLFW callbacks (
glfwSetCursorPosCallback,glfwSetMouseButtonCallback,glfwSetScrollCallback,glfwSetKeyCallback,glfwSetFramebufferSizeCallback,glfwGetFramebufferSize,glfwGetWindowContentScale) intoplot::inputevents and keepsPlot::viewport()in framebuffer pixels. -
Step 1: Write the demo
examples/glfw_demo.cpp: creates a StreamSeries(1u << 20, 0.0, 1e-6) fed by a background thread at 1 MS/s (std::this_thread::sleep_for paced, sine + noise + a periodic spike so decimation correctness is visible), two axes with SI labels on the time axis, a grid, one trace, and two cursors with a measurement readout. An ImGui-free on-screen HUD is out of scope — the measurement is drawn as plot annotations.
The time axis label must be format_time based and the title must show live samples/second and frame time.
- Step 2: Build and run it
Run: cmake --preset linux-release && cmake --build build/release --target plot_glfw_demo && ./build/release/examples/plot_glfw_demo
Expected: a window with a scrolling sine trace, grid, labelled axes, two draggable cursors and a Δ/RMS readout. Pan, wheel-zoom, shift-box-zoom and cursor drag all work. Screenshot it.
- Step 3: Commit
git add -A && git commit -m "feat(glfw): adapter and 1 MS/s streaming demo"
Task 9: Dear ImGui adapter and demo
Files:
- Create:
adapters/imgui/{CMakeLists.txt,imgui_plot.hpp,imgui_plot.cpp} - Create:
examples/imgui_demo.cpp - Modify:
CMakeLists.txt,cmake/plot_options.cmake(FetchContent fallback for ImGui whenfind_packagefails)
Interfaces:
-
Consumes:
plot::Renderer,plot::Plot,plot::input::Controller, ImGui. -
Produces:
plot::imgui::PlotWidgetwithvoid draw(const char* id, Plot&, Vec2 size, Renderer&)— allocates a child region, maps ImGui's cursor screen position to a framebuffer rect viaImGui::GetWindowDrawList()clipping andio.DisplayFramebufferScale, and forwardsio.MousePos/MouseWheel/MouseDownasplot::inputevents whileImGui::IsWindowHovered(). -
Step 1: Write the demo
examples/imgui_demo.cpp: GLFW + ImGui docking-free single window, a slider controlling sample rate, a button that pushes a spike into the StreamSeries, and a checkbox toggling the LOD statistics overlay.
- Step 2: Build and run it
Run: cmake --build build/release --target plot_imgui_demo && ./build/release/examples/plot_imgui_demo
Expected: ImGui window showing the live plot; dragging inside the child region pans only when hovering it. Screenshot it.
- Step 3: Commit
git add -A && git commit -m "feat(imgui): embeddable plot widget and demo"
Task 10: Qt6 adapter and demo
Files:
- Create:
adapters/qt6/{CMakeLists.txt,qt_plot_widget.hpp,qt_plot_widget.cpp} - Create:
examples/qt_demo.cpp - Modify:
CMakeLists.txt,examples/CMakeLists.txt(Qt targets needCMAKE_AUTOMOC)
Interfaces:
-
Consumes:
QOpenGLWidget,QOpenGLFunctions,plot::Renderer,plot::Plot,plot::input::Controller. -
Produces:
plot::qt::PlotWidget : public QOpenGLWidget, protected QOpenGLFunctionswith:initializeGL()→initializeOpenGLFunctions(); plot::gl::load(); renderer_.init();paintGL()→begin_frame(width() * devicePixelRatioF(), height() * devicePixelRatioF(), rect); renderer_.draw(plot_); renderer_.end_frame();resizeGL(int,int)→ update viewport rectmousePressEvent/mouseMoveEvent/mouseReleaseEvent/wheelEvent/keyPressEvent→ constructplot::inputevents in logical pixels and pass them to the controller. The controller works in the same pixel space asPlot::viewport(), so the DPR conversion must be applied consistently inset_viewport/event translation — pick physical pixels for both, or logical for both, and document it in the header.- Qt6 requires
QSurfaceFormatwithsetVersion(3,3),setProfile(CoreProfile),setSwapBehavior(DoubleBuffer)set before the widget is created; set it inmain()for the demo and document that an embedding application must have done the same.
-
Step 1: Write the demo
examples/qt_demo.cpp: QApplication, a QMainWindow with a toolbar (Fit, Autoscale, Add cursor), the PlotWidget as central widget, fed by a QTimer at 60 Hz pushing samples from a producer thread.
- Step 2: Build and run it
Run: cmake --build build/release --target plot_qt_demo && ./build/release/examples/plot_qt_demo
Expected: a Qt window with the live plot; resizing reflows the grid and labels; DPR scaling on this display renders crisp, not stretched. Screenshot it.
- Step 3: Commit
git add -A && git commit -m "feat(qt6): QOpenGLWidget-based plot widget and demo"
Task 11: GTK4 adapter and demo
Files:
- Create:
adapters/gtk4/{CMakeLists.txt,gtk_plot_area.hpp,gtk_plot_area.cpp} - Create:
examples/gtk_demo.cpp - Modify:
CMakeLists.txt,examples/CMakeLists.txt
Interfaces:
-
Consumes:
GtkGLArea,plot::Renderer,plot::Plot,plot::input::Controller. -
Produces:
plot::gtk4::PlotArea— aGtkWidgetsubclass (C++ wrapper around aGtkGLAreacreated in a factory function, since GTK4 forbids deriving new GObject types in C++ without G_DEFINE_TYPE) with:"realize"→gtk_gl_area_set_required_version(area, 3, 3)andgtk_gl_area_make_current, thenplot::gl::load()+renderer_.init()"render"→begin_frame(fb_w, fb_h, rect)…end_frame(), scaling bygtk_widget_get_scale_factor()GtkGestureDrag+GtkEventControllerScroll+GtkEventControllerKey→plot::inputevents"resize"→ update the viewport rect- Guard against
GtkGLAreawith no error set (gtk_gl_area_get_error) and reportErrorCode::GLFailureinstead of crashing.
-
Step 1: Write the demo
examples/gtk_demo.cpp: gtk_application_new + GtkApplicationWindow containing the plot area and a header bar with a "Fit" button; a g_timeout_add pump feeding the series.
- Step 2: Build and run it
Run: cmake --build build/release --target plot_gtk_demo && ./build/release/examples/plot_gtk_demo
Expected: a GTK4 window with the live plot; scroll-to-zoom and drag-to-pan work; window resize reflows. Screenshot it.
- Step 3: Commit
git add -A && git commit -m "feat(gtk4): GtkGLArea-based plot area and demo"
Task 12: Install/export, umbrella header, full verification
Files:
- Create:
cmake/plotConfig.cmake.in - Modify:
CMakeLists.txt(install rules,plot::coreexport set,PLOT_INSTALLoption),include/plot/plot.hpp(umbrella include of every public header)
Interfaces:
-
Consumes: all previous tasks.
-
Produces:
find_package(plot)→plot::core(+plot::glfw,plot::imgui,plot::qt6,plot::gtk4when their deps resolve), working from an installed prefix. -
Step 1: Add install and export rules
install(TARGETS plot_core EXPORT plotTargets ...) with INCLUDES DESTINATION include; install(DIRECTORY include/plot DESTINATION include); install(EXPORT plotTargets NAMESPACE plot:: FILE plotTargets.cmake DESTINATION lib/cmake/plot); configure_package_config_file + write_basic_package_version_file for plotConfig.cmake/plotConfigVersion.cmake. Adapters export only if their dependency was found, and MUST NOT be exported with a hard dependency on a package that was only found via FetchContent — in that case skip the export and say so in the configure log.
- Step 2: Verify the install is usable
cmake --install build/release --prefix /tmp/plot-install
mkdir -p /tmp/plot-consumer
cat > /tmp/plot-consumer/CMakeLists.txt <<'EOF'
cmake_minimum_required(VERSION 3.24)
project(consumer LANGUAGES CXX)
find_package(plot 0.1 REQUIRED)
add_executable(consumer main.cpp)
target_link_libraries(consumer PRIVATE plot::core)
EOF
cat > /tmp/plot-consumer/main.cpp <<'EOF'
#include <plot/plot.hpp>
int main() {
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
return int(p.trace_count());
}
EOF
cmake -S /tmp/plot-consumer -B /tmp/plot-consumer/build -DCMAKE_PREFIX_PATH=/tmp/plot-install
cmake --build /tmp/plot-consumer/build
/tmp/plot-consumer/build/consumer; echo "exit=$?"
Expected: configure finds plot 0.1.0, the consumer compiles and links plot::core with no -I or -L flags of its own.
- Step 3: Full verification pass
cmake --preset linux-debug && cmake --build build/debug && ctest --test-dir build/debug --output-on-failure
cmake --preset linux-asan && cmake --build build/asan && ctest --test-dir build/asan --output-on-failure
cmake --preset linux-release && cmake --build build/release
Expected: warnings clean with -Wall -Wextra -Wpedantic -Wconversion -Wshadow; all ctest cases pass on both debug and asan; release builds all four demos.
Then launch all four demos and screenshot each: plot_glfw_demo, plot_imgui_demo, plot_qt_demo, plot_gtk_demo.
- Step 4: Commit
git add -A && git commit -m "feat(build): install/export rules, umbrella header, verified consumer"