# `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 clear(); u64 size() const noexcept { return size_; } u32 level_count() const noexcept { return u32(levels_.size()); } std::span 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> levels_; // levels_[0] = kBucket granularity std::vector> 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` 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 "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 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) 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** ```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" ```