# `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): ```cpp #pragma once #include #include #include #include #include #include #include using plot::f32; using plot::f64; using plot::u32; using plot::u64; namespace plottest { struct Case { const char* name; std::function fn; }; inline std::vector& cases() { static std::vector c; return c; } struct Reg { Reg(const char* n, std::function 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`: ```cpp #include "harness.hpp" int main() { return plottest::run_all(); } ``` `tests/test_transform.cpp`: ```cpp #include "harness.hpp" #include 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`: ```cpp #pragma once #include #include 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`: ```cpp #pragma once #include 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`: ```cpp #pragma once #include #include 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`: ```cmake 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** ```bash 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`: ```cpp #include "harness.hpp" #include "egl_headless.hpp" #include #include 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: ```cpp #pragma once #include 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`: ```cpp #pragma once #include 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`: ```cpp #pragma once #include 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: ```cpp // 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: ```bash ./build/debug/tests/plot_tests # includes gl_context ``` - [ ] **Step 5: Commit** ```bash 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: ```cpp #include "harness.hpp" #include #include 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 d(-100.0f, 100.0f); std::vector 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 d(-3.0f, 7.0f); std::vector 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`: ```cpp PLOT_TEST(ring_wraps_and_keeps_latest) { plot::data::RingBuffer 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 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`: ```cpp #include "harness.hpp" #include #include #include #include #include #include PLOT_TEST(array_series_bounds_and_gather) { std::vector 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 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 d(-5.0f, 5.0f); std::vector 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`: ```cpp #pragma once #include #include #include 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 drop_before(u64 absolute_sample); // slide the retained window to an absolute sample void clear(); u64 size() const noexcept; // retained-window samples u64 window_base() const noexcept; // absolute index of window index 0 u64 window_end() const noexcept; // absolute index one past the last append u32 level_count() const noexcept; u64 level_base(u32 l) const noexcept; // absolute level-l bucket index of entry 0 u64 level_size(u32 l) const noexcept; MinMax bucket(u32 l, u64 i) const; // absolute level-l bucket i // 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. // Window coordinates: the grid is absolute, so the intersection runs through // window_base(). 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: // One bucket: its extremes plus its own exact sum and sum of squares. The two // doubles take a bucket from 8 bytes to 24 -- level 0 costs 0.375n bytes, the // whole pyramid ≈0.75n, ≈19% of 4-byte raw samples -- and are what let // mean/RMS/integral come from whole buckets. Absolute prefix sums cannot: they // grow without bound and cannot survive a sliding front. struct Entry { MinMax mm; f64 sum = 0.0; f64 sumsq = 0.0; }; struct Level { std::deque entries; u64 base = 0; }; // absolute bucket space u64 count_ = 0; // samples ever appended u64 base_ = 0; // absolute index of the window's oldest sample u32 live_levels_ = 1; std::vector levels_{1}; Entry pending_; // the in-progress level-0 bucket }; } // namespace plot::data ``` Implementation rules for `src/data/minmax_pyramid.cpp`: - `append(v)`: fold the sample into `pending_`, the in-progress level-0 bucket; when that bucket completes, push it as an entry and merge upward while `kBucket << l` divides the sample count, stopping at the first level that gained no entry. Amortised O(1) -- `1/kBucket + 1/(2·kBucket) + … = 2/kBucket` merges per sample, independent of the number of live levels -- and no entry is ever rewritten, which is what lets a window slide over the pyramid instead of forcing a rebuild. - `drop_before(u)`: set the window base and discard, per level, the leading entries whose span starts before `u`; level 0 also drops its in-progress bucket. Memory then tracks the window, not the samples ever pushed. - `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, folding each covered entry's own `sum`/`sumsq`. - `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_size(l) >= 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` 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& out_xy) const = 0;` is documented as "level 0 emits the raw samples of [begin, end); level >= 1 emits one (min, max) envelope pair per complete retained bucket, bracketed by the partial edge buckets drawn as raw samples", so the geometry spans the requested range exactly without the pyramid storing a partial bucket. `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 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` 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** ```bash 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): ```cpp #include "harness.hpp" #include 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`: ```cpp #pragma once #include 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`: ```cpp #pragma once #include #include #include 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& 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`: ```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** ```bash 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&)`, `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`: ```cpp #include "harness.hpp" #include #include #include #include PLOT_TEST(nice_ticks_are_powers_of_ten_times_125) { std::vector 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 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 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`: ```cpp #include "harness.hpp" #include #include #include #include #include #include PLOT_TEST(measurement_over_interval_matches_direct_computation) { std::vector 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 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::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 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: ```cpp // 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`: ```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: ```cpp struct PlotLayout { Rect axes; // plot area: excludes tick labels AND titles Rect y_label_area, x_label_area; // one text line each, for tick labels Rect y_title_area, x_title_area; // one text line each, for axis titles f32 y_axis_width = 56.0f; f32 x_axis_height = 22.0f; f32 y_title_width = 0.0f; // set from the title's measured extent f32 x_title_height = 0.0f; }; ``` `layout()` reserves one text line for tick labels and a second for the axis title on each occupied edge, and insets `axes` by all of them. A title drawn inside `axes` sits on top of the data and is a defect — the reserved strips exist to make that impossible rather than merely unlikely. An axis with no title reserves nothing. `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** ```bash 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** ```cpp 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(); const f64 grabbed = p.x_transform(ax).to_data_x(100.0f); // data under the press point 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 // Assert the INVARIANT, not a pixel fraction. Grab-and-drag means the data // grabbed at the press point is under the pointer afterwards. Pixels map to data // through layout().axes (744 px wide for an 800 px viewport given the default // y_axis_width of 56), NOT through the whole viewport: the viewport model makes // dragged data lag the cursor by the label-strip fraction (56/800 = 7%), which // is a defect no pixel-fraction assertion can see because it certifies its own // arithmetic. PLOT_CHECK_NEAR(p.x_transform(ax).to_data_x(180.0f), grabbed, 1e-9); PLOT_CHECK(p.y_range(ay).max > 1.0); // moving down reveals higher y } 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 const f64 want_lo = p.x_transform(ax).to_data_x(100.0f); const f64 want_hi = p.x_transform(ax).to_data_x(300.0f); 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}); // The box frames exactly the data it encloses: the ranges come from the axes // transform, and the selection is clamped to layout().axes, not the viewport. PLOT_CHECK_NEAR(p.x_range(ax).min, want_lo, 1e-6); PLOT_CHECK_NEAR(p.x_range(ax).max, want_hi, 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 so the grabbed data point stays under the pointer: the pixel-to-data scale is `span / layout().axes.w` for x and `span / layout().axes.h` for y (inverted), i.e. translate by `-dx_px / axes.w * x_span`. Using the whole-viewport width here makes dragged data lag the cursor by the label-strip fraction. | | shift + left drag | `BoxZoom` | record `selection_rect()`; on release set both ranges from the rect. Clamp the rect to `layout().axes`, map it with `x_transform(i).to_data_x(l/r)` and `y_transform(i).to_data_y(bottom/top)`, and ignore sub-4px rects (treat as a click). The **release event's coordinates are authoritative** for the gesture end: refresh the selection from them before applying, or a host that reports only press and release leaves the box empty and the zoom silently degrades to 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** ```bash 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** ```cpp 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 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 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** ```bash 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: ```cpp struct RunConfig { int width = 1280; int height = 720; const char* title = "plot"; bool vsync = true; }; using FrameFn = std::function; 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** ```bash 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** ```bash 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** ```bash 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** ```bash 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** ```bash 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 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** ```bash 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** ```bash git add -A && git commit -m "feat(build): install/export rules, umbrella header, verified consumer" ```