Files
plotter/docs/superpowers/plans/2026-09-27-plot-library-scaffold.md
T

71 KiB
Raw Blame History

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_core MUST NOT link or include any third-party library: no GLM, no glad, no GLEW, no GL/glcorearb.h, no third_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 -Wshadow on plot targets; warnings MUST be clean, never suppressed per-file.
  • Namespace plot, sub-namespaces plot::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 with ctest --output-on-failure from 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::Transform with to_screen_x/to_data_x/to_screen_y/to_data_y, and the PLOT_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 (add src/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 helper plottest::HeadlessContext with bool 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::StreamSeries exactly as declared in spec §6, plus plot::data::MinMax{min,max} and plot::data::kBucket == 64. Exactness split (do not get this wrong): a min/max pyramid stores one aggregate per bucket, so it can only answer exactly for whole buckets. Exact range queries therefore live on Series — reduce, sum, sumsq, non-virtual, implemented once in src/data/series.cpp over the protected copy_raw hook: the bucket-aligned interior comes from the pyramid, and at most two partial edge buckets are copied through copy_raw and scanned. MinMaxPyramid exposes only the bucket-exact reduce_buckets/sum_buckets/sumsq_buckets that back them. Task 5 measurements and auto-scale consume Series::reduce/sum/sumsq and never the *_buckets forms directly.

  • 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::kBucket;
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_buckets(0, 960);        // exactly 15 whole buckets
  PLOT_CHECK_NEAR(r.min, -50.0, 1e-6);
  PLOT_CHECK_NEAR(r.max, 900.0, 1e-6);
}

PLOT_TEST(pyramid_reduce_buckets_matches_bruteforce_on_aligned_ranges) {
  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 += kBucket * 3) {
    for (u64 b = a + kBucket; b <= 5000; b += kBucket * 7) {
      const u64 ahi = (b / kBucket) * kBucket;     // whole buckets only
      if (ahi <= a) continue;
      float lo = v[a], hi = v[a];
      for (u64 i = a; i < ahi; ++i) { lo = std::min(lo, v[i]); hi = std::max(hi, v[i]); }
      const Range r = p.reduce_buckets(a, ahi);
      PLOT_CHECK_NEAR(r.min, lo, 1e-4);
      PLOT_CHECK_NEAR(r.max, hi, 1e-4);
    }
  }
}

PLOT_TEST(pyramid_reduce_buckets_excludes_partial_edges) {
  // Bucket 1 covers samples [64,128). reduce_buckets answers in whole buckets
  // only, so the spike must show up in its own bucket and nowhere else.
  MinMaxPyramid p;
  for (int i = 0; i < 128; ++i) p.append(i == 70 ? 999.0f : 1.0f);
  PLOT_CHECK_NEAR(p.reduce_buckets(64, 128).max, 999.0, 1e-6);
  PLOT_CHECK_NEAR(p.reduce_buckets(0, 64).max, 1.0, 1e-6);
  PLOT_CHECK(p.reduce_buckets(64, 64).span() == 0.0);   // empty -> degenerate
}

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_on_aligned_ranges) {
  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 += kBucket * 5) {
    for (u64 b = a + kBucket; b <= 3000; b += kBucket * 9) {
      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_buckets(a, b), s, 1e-3);
      PLOT_CHECK_NEAR(p.sumsq_buckets(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);
  for (int i = 1; i < int(kBucket); ++i) p.append(1.0f);   // complete bucket 0
  PLOT_CHECK_NEAR(p.reduce_buckets(0, kBucket).max, 7.0, 1e-6);
  PLOT_CHECK_NEAR(p.reduce_buckets(0, kBucket).min, 1.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:

#include "harness.hpp"
#include <plot/data/array_series.hpp>
#include <plot/data/stream_series.hpp>
#include <algorithm>
#include <cmath>
#include <random>
#include <vector>

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.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);
}

PLOT_TEST(array_series_exact_range_queries_match_bruteforce) {
  // The strong test for the exactness split: arbitrary, deliberately
  // unaligned ranges must match a brute-force scan of the raw samples.
  std::mt19937 rng(7);
  std::uniform_real_distribution<float> d(-5.0f, 5.0f);
  std::vector<f32> y(4000);
  for (auto& v : y) v = d(rng);
  plot::data::ArraySeries s(y, 0.0, 1.0);
  for (u64 a = 0; a < 4000; a += 173) {
    for (u64 b = a + 1; b <= 4000; b += 331) {
      float lo = y[a], hi = y[a];
      double sum = 0.0, sq = 0.0;
      for (u64 i = a; i < b; ++i) {
        lo = std::min(lo, y[i]); hi = std::max(hi, y[i]);
        sum += y[i]; sq += double(y[i]) * double(y[i]);
      }
      const plot::Range r = s.reduce(a, b);
      PLOT_CHECK_NEAR(r.min, lo, 1e-4);
      PLOT_CHECK_NEAR(r.max, hi, 1e-4);
      PLOT_CHECK_NEAR(s.sum(a, b), sum, 1e-2);
      PLOT_CHECK_NEAR(s.sumsq(a, b), sq, 1e-1);
    }
  }
}

PLOT_TEST(stream_series_exact_queries_survive_ring_wrap) {
  plot::data::StreamSeries s(1u << 10, 0.0, 1.0);
  for (int i = 0; i < 3000; ++i) s.push(f32(i % 97));   // wraps the ring twice
  s.acquire();
  const u64 n = s.size();
  const plot::Range r = s.reduce(0, n);                 // retained window, wrapped
  PLOT_CHECK_NEAR(r.max, 96.0, 1e-4);
  PLOT_CHECK_NEAR(r.min, 0.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;
  // Whole-bucket only: [begin, end) is intersected with whole level-0 buckets and
  // partial edges are excluded, never approximated. An empty result has span()==0.
  Range reduce_buckets(u64 begin, u64 end) const;
  f64 sum_buckets(u64 begin, u64 end) const;
  f64 sumsq_buckets(u64 begin, u64 end) const;
  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 of levels_[0], then for each level l, if level l gained a complete pair, merge the last two entries of level l into a new entry of level l+1; otherwise stop early. Amortised O(1). The same walk maintains sums_/sumsqs_ (inclusive prefix sums per level).
  • reduce_buckets(begin, end): if (end <= begin) return {0,0}; intersect [begin, end) with whole level-0 buckets, then cover the result by descending to the coarsest level whose buckets lie entirely inside it, finishing the remainder at level 0. It must NEVER scan raw samples — it stores none — and a partial edge bucket is excluded, not approximated. sum_buckets/sumsq_buckets use the same decomposition over sums_/sumsqs_.
  • Series::reduce/sum/sumsq in src/data/series.cpp are the exact entry points every other module uses: they take the bucket-aligned interior from reduce_buckets/sum_buckets/sumsq_buckets and scan the at-most-two partial edge buckets obtained via copy_raw (a stack buffer of at most 2*kBucket floats, so no allocation on the query path).
  • level_for(n, spp): if (spp <= f64(kBucket)) return 0; else u32 l = u32(std::floor(std::log2(spp / f64(kBucket)))); clamp to level_count() - 1, and additionally clamp so that level(l).size() >= 2.
  • snap(x, x0, dx): if (dx <= 0.0 || size_ == 0) return 0; compute i = 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 f64 x0() const and virtual f64 dx() const expose the uniform x grid (measurement and snap need them). virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const = 0; is documented as "appends interleaved (x, y) pairs in data coordinates for bucket-aligned level level; level 0 emits raw samples". reduce/sum/sumsq are non-virtual, implemented once in src/data/series.cpp over the protected pure virtual copy_raw(u64 begin, u64 end, f32* out) const, which copies end - begin raw y values and handles any wraparound.

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). copy_raw is a bounds-checked std::copy over y_.

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. copy_raw must split the copy at the ring's wrap point; reduce/sum/sumsq are documented as operating on the retained window, so index 0 is the oldest sample still held.

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 with begin()/flush() semantics, and the GLSL sources as constexpr 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: inputs vec2 a_p0, a_p1 (per-instance) and a unit-quad corner via gl_VertexID; uniforms vec2 u_scale, u_offset (data→pixel), vec2 u_viewport, float u_width; expands the segment in pixel space by u_width/2 along the segment normal plus 1px of AA fringe, and outputs v_dist (distance to the segment axis in pixels) and v_along.
  • kLineFS: discard when length(v_dist) > u_width * 0.5 + 1.0; alpha = smoothstep over the last pixel of the half-width; outputs u_color * alpha.
  • kTextVS/kTextFS: instanced quads from the glyph atlas; a single-channel GL_R8 texture sampled with GL_NEAREST, tinted by u_color, with u_px_range/u_tex_size uniforms.

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, and plot::Plot with layout(), trace_count(), measurement(u32).

  • Step 1: Write the failing tests

tests/test_ticks.cpp:

#include "harness.hpp"
#include <plot/plot/ticks.hpp>
#include <cmath>
#include <string>
#include <vector>

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:

#include "harness.hpp"
#include <plot/plot/measure.hpp>
#include <plot/data/array_series.hpp>
#include <algorithm>
#include <chrono>
#include <cmath>
#include <vector>

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, bool include_minors = false):
//   if (!(range.span() > 0) || px <= 0 || !isfinite(span) || min > max) { 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)
//   // ROUND UP, not to nearest. Rounding to nearest can exceed target_count and
//   // break the "at most px/min_px + 2 ticks" bound that the tests assert.
//   const f64 step = mag * (norm <= 1.0 ? 1.0 : norm <= 2.0 ? 2.0 : norm <= 5.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, {}});
//   // include_minors defaults to false because minors share this same vector: a
//   // caller that wants only majors must not have to filter, and the "every value
//   // is a multiple of the major step" assertions only hold for the majors-only
//   // form. Grid rendering passes true.
//   // minor ticks: step/5 (step/4 for a step beginning with 2), same loop.
//   // hard guard: 256 entries maximum INCLUDING minors.

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;
  // Every quantity comes from Series, never from the pyramid's *_buckets forms:
  //   const Range r = s.reduce(begin, end);
  //   const f64 total = s.sum(begin, end), sq = s.sumsq(begin, end);
  // Each is O(kBucket) worst case: bucket-aligned interior from the pyramid,
  // plus at most two partial edge buckets read through copy_raw().
  // 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;
}

Take every quantity from Series (Task 3), never from the pyramid directly: s.reduce(begin, end) supplies exact ymin/ymax, and s.sum(begin, end) / s.sumsq(begin, end) supply the sums. Each is O(kBucket) worst case — the bucket-aligned interior comes from the pyramid, and at most two partial edge buckets (<= 2 x kBucket = 128 samples) are scanned through copy_raw — so a measurement never scales with series length, which is exactly what measurement_uses_pyramid_not_scan gates. integral = s.sum(begin, end) * s.dx(), mean = sum/n, rms = sqrt(sumsq/n) with n = end - begin, and dx = (end - 1 - begin) * s.dx() (0 when end - begin < 2). Calling the pyramid's *_buckets forms here would silently drop the edge samples and return wrong extrema — that is the specific mistake this paragraph exists to prevent.

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::Renderer with bool 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, and void dump_frame_ppm(const char* path) (used by the test when PLOT_DUMP_FRAMES is set). Consumes Plot::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 with glBufferStorage(MAP_WRITE|MAP_PERSISTENT|MAP_COHERENT), keep a persistent pointer, and triple-buffer with glFenceSync/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) when PLOT_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) into plot::input events and keeps Plot::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 when find_package fails)

Interfaces:

  • Consumes: plot::Renderer, plot::Plot, plot::input::Controller, ImGui.

  • Produces: plot::imgui::PlotWidget with void draw(const char* id, Plot&, Vec2 size, Renderer&) — allocates a child region, maps ImGui's cursor screen position to a framebuffer rect via ImGui::GetWindowDrawList() clipping and io.DisplayFramebufferScale, and forwards io.MousePos/MouseWheel/MouseDown as plot::input events while ImGui::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 need CMAKE_AUTOMOC)

Interfaces:

  • Consumes: QOpenGLWidget, QOpenGLFunctions, plot::Renderer, plot::Plot, plot::input::Controller.

  • Produces: plot::qt::PlotWidget : public QOpenGLWidget, protected QOpenGLFunctions with:

    • 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 rect
    • mousePressEvent/mouseMoveEvent/mouseReleaseEvent/wheelEvent/keyPressEvent → construct plot::input events in logical pixels and pass them to the controller. The controller works in the same pixel space as Plot::viewport(), so the DPR conversion must be applied consistently in set_viewport/event translation — pick physical pixels for both, or logical for both, and document it in the header.
    • Qt6 requires QSurfaceFormat with setVersion(3,3), setProfile(CoreProfile), setSwapBehavior(DoubleBuffer) set before the widget is created; set it in main() 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 — a GtkWidget subclass (C++ wrapper around a GtkGLArea created 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) and gtk_gl_area_make_current, then plot::gl::load() + renderer_.init()
    • "render" → begin_frame(fb_w, fb_h, rect) … end_frame(), scaling by gtk_widget_get_scale_factor()
    • GtkGestureDrag + GtkEventControllerScroll + GtkEventControllerKey → plot::input events
    • "resize" → update the viewport rect
    • Guard against GtkGLArea with no error set (gtk_gl_area_get_error) and report ErrorCode::GLFailure instead 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::core export set, PLOT_INSTALL option), 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::gtk4 when 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"