Files
plotter/docs/superpowers/plans/2026-09-27-plot-library-scaffold.md
T
Martino Ferrari e0cdd95a3b fix(input): pan and box zoom through layout().axes, not the viewport
Every pixel-to-data conversion in the controller now maps through the
same rectangle Plot::x_transform/y_transform are built from:

- pan() derives the scale from layout().axes.w/.h, so a drag moves the
  grabbed data point by exactly the drag distance (the viewport model
  stranded it behind the pointer by the label-strip fraction).
- apply_box_zoom() clamps the selection to layout().axes and sets each
  range from the transforms (x_transform().to_data_x(l/r), y_transform()
  .to_data_y(bottom/top)); the selection's bottom edge is the y minimum.
- end_drag() takes the release event and refreshes the selection from its
  coordinates before applying, so a host that reports only press and
  release still frames the box instead of degrading it to a click.
- axis_zoom() uses a per-pixel sensitivity (200 px == one decade) rather
  than reusing the wheel's per-notch constant, so a pixel drag is no
  longer treated as a wheel notch.

Tests now assert the grab-and-drag invariant through the transform (the
old pixel-fraction assertions certified the viewport defect), add a
press+release-only box zoom, and pin the axis-zoom magnitude.
2026-09-27 22:41:42 +02:00

1547 lines
73 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# `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 <cstdio>
#include <cmath>
#include <chrono>
#include <algorithm>
#include <vector>
#include <string>
#include <functional>
using plot::f32;
using plot::f64;
using plot::u32;
using plot::u64;
namespace plottest {
struct Case { const char* name; std::function<void()> fn; };
inline std::vector<Case>& cases() { static std::vector<Case> c; return c; }
struct Reg { Reg(const char* n, std::function<void()> f) { cases().push_back({n, std::move(f)}); } };
struct Failure { std::string msg; };
inline int run_all() {
int failed = 0;
for (auto& c : cases()) {
try { c.fn(); std::printf("[ ok ] %s\n", c.name); }
catch (const Failure& f) { std::printf("[FAIL] %s: %s\n", c.name, f.msg.c_str()); ++failed; }
}
std::printf("%zu cases, %d failed\n", cases().size(), failed);
return failed == 0 ? 0 : 1;
}
} // namespace plottest
#define PLOT_TEST(name) \
static void name(); \
static ::plottest::Reg reg_##name(#name, name); \
static void name()
#define PLOT_CHECK(cond) \
do { if (!(cond)) throw ::plottest::Failure{std::string(__FILE__) + ":" + \
std::to_string(__LINE__) + " CHECK(" #cond ")"}; } while (0)
#define PLOT_CHECK_NEAR(a, b, eps) \
do { const double _a = (a), _b = (b); \
if (!(std::fabs(_a - _b) <= (eps))) throw ::plottest::Failure{ \
std::string(__FILE__) + ":" + std::to_string(__LINE__) + \
" CHECK_NEAR(" #a ", " #b ") got " + std::to_string(_a) + \
" vs " + std::to_string(_b)}; } while (0)
```
`tests/main.cpp`:
```cpp
#include "harness.hpp"
int main() { return plottest::run_all(); }
```
`tests/test_transform.cpp`:
```cpp
#include "harness.hpp"
#include <plot/math/transform.hpp>
using plot::Transform;
PLOT_TEST(linear_round_trip) {
Transform t{2.0, 10.0, 3.0, -1.0, false, false};
PLOT_CHECK_NEAR(t.to_screen_x(0.0), 10.0, 1e-12);
PLOT_CHECK_NEAR(t.to_screen_x(5.0), 20.0, 1e-12);
PLOT_CHECK_NEAR(t.to_data_x(20.0), 5.0, 1e-12);
PLOT_CHECK_NEAR(t.to_data_y(t.to_screen_y(7.5)), 7.5, 1e-12);
}
PLOT_TEST(log_x_round_trip) {
Transform t{100.0, 0.0, 1.0, 0.0, true, false};
PLOT_CHECK_NEAR(t.to_screen_x(1.0), 0.0, 1e-12);
PLOT_CHECK_NEAR(t.to_screen_x(10.0), 100.0, 1e-12);
PLOT_CHECK_NEAR(t.to_data_x(100.0), 10.0, 1e-9);
}
PLOT_TEST(zoom_anchor_is_invariant) {
// Anchoring a zoom at a data coordinate must leave that coordinate's screen
// position unchanged.
Transform t{1.0, 0.0, 1.0, 0.0, false, false};
const double anchor_data = 3.25;
const double before = t.to_screen_x(anchor_data);
t.sx *= 4.0;
t.tx = before - anchor_data * t.sx;
PLOT_CHECK_NEAR(t.to_screen_x(anchor_data), before, 1e-12);
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cmake --preset linux-debug && cmake --build build/debug --target plot_tests`
Expected: FAIL — `plot/math/transform.hpp` does not exist.
- [ ] **Step 3: Write the implementation**
`include/plot/core/types.hpp`:
```cpp
#pragma once
#include <cstdint>
#include <cstddef>
namespace plot {
using f32 = float;
using f64 = double;
using i32 = std::int32_t;
using u32 = std::uint32_t;
using u64 = std::uint64_t;
using usize = std::size_t;
constexpr u64 kInvalid = ~u64{0};
struct Range {
f64 min = 0.0;
f64 max = 1.0;
constexpr f64 span() const noexcept { return max - min; }
constexpr f64 clamp(f64 v) const noexcept { return v < min ? min : (v > max ? max : v); }
};
} // namespace plot
```
`include/plot/math/vec2.hpp`:
```cpp
#pragma once
#include <plot/core/types.hpp>
namespace plot {
struct Vec2 {
f32 x = 0.0f, y = 0.0f;
};
} // namespace plot
```
(Implement `Vec2{x,y}` with `+ - * /`, `dot`, `length`.)
`include/plot/math/transform.hpp`:
```cpp
#pragma once
#include <plot/core/types.hpp>
#include <cmath>
namespace plot {
/// Affine data→screen mapping: screen = data * s + t, applied after log10 when enabled.
struct Transform {
f64 sx = 1.0, tx = 0.0;
f64 sy = 1.0, ty = 0.0;
bool log_x = false, log_y = false;
f64 to_screen_x(f64 x) const noexcept {
const f64 v = log_x ? log10_positive(x) : x;
return v * sx + tx;
}
f64 to_screen_y(f64 y) const noexcept {
const f64 v = log_y ? log10_positive(y) : y;
return v * sy + ty;
}
f64 to_data_x(f64 sx_screen) const noexcept {
const f64 v = (sx_screen - tx) / sx;
return log_x ? std::pow(10.0, v) : v;
}
f64 to_data_y(f64 sy_screen) const noexcept {
const f64 v = (sy_screen - ty) / sy;
return log_y ? std::pow(10.0, v) : v;
}
/// Set scale/offset so that [lo,hi] data maps onto [pix_lo,pix_hi] screen.
static Transform from_range(Range x, Range y, f64 px0, f64 px1, f64 py0, f64 py1, bool lx, bool ly);
static f64 log10_positive(f64 v) noexcept; // clamps to >= 1e-300 before log10
};
} // namespace plot
```
Implement `from_range` and `log10_positive` in `src/math/transform.cpp`. `from_range` for a log axis maps `log10(lo)→log10(hi)`, and for a degenerate span (min == max) expands by ±0.5 so the transform is always invertible.
`include/plot/core/{color,rect,status,span}.hpp` per spec §6: `Color{r,g,b,a}` with `static Color hex(u32 rgb, f32 a = 1.0f)`; `Rect{x,y,w,h}` with `contains`, `right()`, `bottom()`; `ErrorCode` + `Status` with `explicit operator bool() const { return code == ErrorCode::Ok; }`; `span.hpp` re-exports `std::span` as `plot::Span`.
`tests/CMakeLists.txt`:
```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 <plot/gl/loader.hpp>
#include <plot/gl/caps.hpp>
PLOT_TEST(headless_context_provides_gl33) {
plottest::HeadlessContext ctx;
plot::Status st;
PLOT_CHECK(ctx.create(&st)); // fails loudly if surfaceless EGL is unavailable
PLOT_CHECK(plot::gl::load(nullptr));
const plot::gl::Caps& c = plot::gl::caps();
PLOT_CHECK(c.major > 3 || (c.major == 3 && c.minor >= 3));
PLOT_CHECK(c.renderer != nullptr && c.renderer[0] != '\0');
ctx.destroy();
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cmake --build build/debug --target plot_tests && ./build/debug/tests/plot_tests`
Expected: FAIL to compile — `egl_headless.hpp` not found.
- [ ] **Step 3: Write the implementation**
`include/plot/gl/api.hpp` — own typedefs and the function table, no Khronos headers:
```cpp
#pragma once
#include <plot/core/types.hpp>
namespace plot::gl {
using GLenum = unsigned int; using GLuint = unsigned int;
using GLint = int; using GLsizei = int;
using GLboolean = unsigned char;
using GLbitfield = unsigned int; using GLintptr = std::intptr_t;
using GLsizeiptr = std::ptrdiff_t; using GLchar = char;
// Enums actually used. Values are fixed by the OpenGL specification.
enum : GLenum {
GL_NO_ERROR = 0, GL_TRIANGLES = 0x0004, GL_UNSIGNED_BYTE = 0x1401,
GL_FLOAT = 0x1406, GL_TRIANGLE_STRIP = 0x0005, GL_LINE_STRIP = 0x0003,
GL_ARRAY_BUFFER = 0x8892, GL_STATIC_DRAW = 0x88E4, GL_DYNAMIC_DRAW = 0x88E8,
GL_STREAM_DRAW = 0x88E0, GL_FRAGMENT_SHADER = 0x8B30, GL_VERTEX_SHADER = 0x8B31,
GL_COMPILE_STATUS = 0x8B81, GL_LINK_STATUS = 0x8B82, GL_RGBA8 = 0x8058,
GL_RGBA = 0x1908, GL_TEXTURE_2D = 0x0DE1, GL_FRAMEBUFFER = 0x8D40,
GL_COLOR_ATTACHMENT0 = 0x8CE0, GL_FRAMEBUFFER_COMPLETE = 0x8CD5,
GL_BLEND = 0x0BE2, GL_SRC_ALPHA = 0x0302, GL_ONE_MINUS_SRC_ALPHA = 0x0303,
GL_TEXTURE0 = 0x84C0, GL_LINEAR = 0x2601, GL_CLAMP_TO_EDGE = 0x812F,
GL_MAP_WRITE_BIT = 0x0002, GL_MAP_PERSISTENT_BIT = 0x0040,
GL_MAP_COHERENT_BIT = 0x0080, GL_SYNC_GPU_COMMANDS_COMPLETE = 0x9117,
/* … remaining enums added as used … */
};
// X-macro list: (return type, name, parameter list)
#define PLOT_GL_FUNCS(X) \
X(const GLubyte*, glGetString, (GLenum)) \
X(GLenum, glGetError, (void)) \
X(void, glViewport, (GLint, GLint, GLsizei, GLsizei)) \
X(void, glClearColor, (float, float, float, float)) \
X(void, glClear, (GLbitfield)) \
X(void, glEnable, (GLenum)) \
X(void, glBlendFunc, (GLenum, GLenum)) \
X(GLuint, glCreateShader, (GLenum)) \
X(void, glShaderSource, (GLuint, GLsizei, const GLchar* const*, const GLint*))\
X(void, glCompileShader, (GLuint)) \
X(void, glGetShaderiv, (GLuint, GLenum, GLint*)) \
X(void, glGetShaderInfoLog, (GLuint, GLsizei, GLsizei*, GLchar*)) \
X(GLuint, glCreateProgram, (void)) \
X(void, glAttachShader, (GLuint, GLuint)) \
X(void, glLinkProgram, (GLuint)) \
X(void, glGetProgramiv, (GLuint, GLenum, GLint*)) \
X(void, glGetProgramInfoLog, (GLuint, GLsizei, GLsizei*, GLchar*)) \
X(void, glUseProgram, (GLuint)) \
X(GLint, glGetUniformLocation, (GLuint, const GLchar*)) \
X(void, glUniform1f, (GLint, float)) \
X(void, glUniform2f, (GLint, float, float)) \
X(void, glUniform1i, (GLint, GLint)) \
X(void, glUniform2fv, (GLint, GLsizei, const float*)) \
X(void, glUniform1fv, (GLint, GLsizei, const float*)) \
X(void, glGenBuffers, (GLsizei, GLuint*)) \
X(void, glDeleteBuffers, (GLsizei, const GLuint*)) \
X(void, glBindBuffer, (GLenum, GLuint)) \
X(void, glBufferData, (GLenum, GLsizeiptr, const void*, GLenum)) \
X(void, glBufferSubData, (GLenum, GLintptr, GLsizeiptr, const void*)) \
X(void, glGenVertexArrays, (GLsizei, GLuint*)) \
X(void, glDeleteVertexArrays, (GLsizei, const GLuint*)) \
X(void, glBindVertexArray, (GLuint)) \
X(void, glEnableVertexAttribArray, (GLuint)) \
X(void, glVertexAttribPointer, (GLuint, GLint, GLenum, GLboolean, GLsizei, const void*))\
X(void, glDrawArrays, (GLenum, GLint, GLsizei)) \
X(void, glDrawArraysInstanced, (GLenum, GLint, GLsizei, GLsizei)) \
X(void, glGenTextures, (GLsizei, GLuint*)) \
X(void, glDeleteTextures, (GLsizei, const GLuint*)) \
X(void, glBindTexture, (GLenum, GLuint)) \
X(void, glTexImage2D, (GLenum, GLint, GLint, GLsizei, GLsizei, GLint, GLenum, GLenum, const void*))\
X(void, glTexParameteri, (GLenum, GLenum, GLint)) \
X(void, glGenFramebuffers, (GLsizei, GLuint*)) \
X(void, glDeleteFramebuffers, (GLsizei, const GLuint*)) \
X(void, glBindFramebuffer, (GLenum, GLuint)) \
X(void, glFramebufferTexture2D, (GLenum, GLenum, GLenum, GLuint, GLint)) \
X(GLenum, glCheckFramebufferStatus, (GLenum)) \
X(void, glReadPixels, (GLint, GLint, GLsizei, GLsizei, GLenum, GLenum, void*))\
X(void, glGenRenderbuffers, (GLsizei, GLuint*)) \
X(void, glDeleteRenderbuffers, (GLsizei, const GLuint*)) \
X(void, glBindRenderbuffer, (GLenum, GLuint)) \
X(void, glRenderbufferStorage, (GLenum, GLenum, GLsizei, GLsizei)) \
X(void, glFramebufferRenderbuffer, (GLenum, GLenum, GLenum, GLuint))
#define PLOT_GL_DECL(ret, name, args) extern ret (*name) args;
PLOT_GL_FUNCS(PLOT_GL_DECL)
#undef PLOT_GL_DECL
} // namespace plot::gl
```
`include/plot/gl/loader.hpp`:
```cpp
#pragma once
#include <plot/core/status.hpp>
namespace plot::gl {
using ProcLoader = void* (*)(const char*);
/// Resolves every PLOT_GL_FUNCS entry. loader == nullptr selects the platform
/// default: glXGetProcAddressARB / eglGetProcAddress / wglGetProcAddress, then
/// dlsym / GetProcAddress on the system GL library. Returns false if any
/// required entry point is missing.
bool load(ProcLoader loader = nullptr);
} // namespace plot::gl
```
`src/gl/loader.cpp` resolves each name through the supplied loader, falling back to `dlsym(RTLD_DEFAULT, name)` and then `dlopen("libGL.so.1")` + `dlsym`. Report the first missing symbol via `PLOT_ASSERT` in debug and return `false`.
`include/plot/gl/caps.hpp`:
```cpp
#pragma once
#include <plot/gl/api.hpp>
namespace plot::gl {
struct Caps {
int major = 0, minor = 0;
const char* renderer = "";
const char* vendor = "";
bool compute = false; // GL 4.3+
bool buffer_storage = false; // GL 4.4+ / ARB_buffer_storage
bool persistent_map = false; // 4.4+ or ARB_buffer_storage
bool core_profile = false;
};
const Caps& caps(); // valid after load(); queries the current context once
} // namespace plot::gl
```
`include/plot/gl/handle.hpp` + `src/gl/handle.cpp`: move-only RAII wrappers. `Buffer` with `void upload(const void*, usize bytes, GLenum usage)`, `void upload_sub(const void*, usize bytes, usize offset)`; `VertexArray` with `void attrib(u32 index, i32 components, usize stride, usize offset)` using a `GL_FLOAT` type; `Program` with `static Program from_source(const char* vs, const char* fs, Status*)` and `void set_uniform(const char* name, f32)` / `(const char*, Vec2)`; `Texture`, `Framebuffer` (`void attach_color(Texture&)`, `bool complete()`), `Fence`. All destructors are no-ops when `id() == 0`, so a `Renderer` destroyed after context loss is safe.
`tests/egl_headless.cpp` — surfaceless EGL, no window:
```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 <plot/data/minmax_pyramid.hpp>
#include <random>
using plot::data::kBucket;
using plot::data::MinMaxPyramid;
using plot::Range;
PLOT_TEST(pyramid_bucket_extremes_preserved) {
MinMaxPyramid p;
p.reserve(1000);
for (int i = 0; i < 1000; ++i) p.append(i == 617 ? -50.0f : (i == 42 ? 900.0f : 1.0f));
PLOT_CHECK(p.size() == 1000);
const Range r = p.reduce_buckets(0, 960); // exactly 15 whole buckets
PLOT_CHECK_NEAR(r.min, -50.0, 1e-6);
PLOT_CHECK_NEAR(r.max, 900.0, 1e-6);
}
PLOT_TEST(pyramid_reduce_buckets_matches_bruteforce_on_aligned_ranges) {
std::mt19937 rng(1234);
std::uniform_real_distribution<float> d(-100.0f, 100.0f);
std::vector<float> v(5000);
MinMaxPyramid p;
for (auto& x : v) { x = d(rng); p.append(x); }
for (u64 a = 0; a < 5000; a += kBucket * 3) {
for (u64 b = a + kBucket; b <= 5000; b += kBucket * 7) {
const u64 ahi = (b / kBucket) * kBucket; // whole buckets only
if (ahi <= a) continue;
float lo = v[a], hi = v[a];
for (u64 i = a; i < ahi; ++i) { lo = std::min(lo, v[i]); hi = std::max(hi, v[i]); }
const Range r = p.reduce_buckets(a, ahi);
PLOT_CHECK_NEAR(r.min, lo, 1e-4);
PLOT_CHECK_NEAR(r.max, hi, 1e-4);
}
}
}
PLOT_TEST(pyramid_reduce_buckets_excludes_partial_edges) {
// Bucket 1 covers samples [64,128). reduce_buckets answers in whole buckets
// only, so the spike must show up in its own bucket and nowhere else.
MinMaxPyramid p;
for (int i = 0; i < 128; ++i) p.append(i == 70 ? 999.0f : 1.0f);
PLOT_CHECK_NEAR(p.reduce_buckets(64, 128).max, 999.0, 1e-6);
PLOT_CHECK_NEAR(p.reduce_buckets(0, 64).max, 1.0, 1e-6);
PLOT_CHECK(p.reduce_buckets(64, 64).span() == 0.0); // empty -> degenerate
}
PLOT_TEST(pyramid_level_selection) {
MinMaxPyramid p;
p.reserve(1u << 20);
for (u64 i = 0; i < (1u << 20); ++i) p.append(0.0f);
// <= kBucket samples per pixel must use level 0 (raw path handles it anyway).
PLOT_CHECK(p.level_for(1u << 20, 1.0) == 0);
// 512 samples per pixel with kBucket == 64 → bucket size 512 → level 3.
PLOT_CHECK(p.level_for(1u << 20, 512.0) == 3);
// Level selection must never exceed the deepest level that has data.
PLOT_CHECK(p.level_for(1000, 1e9) <= p.level_count() - 1);
}
PLOT_TEST(pyramid_snap_returns_nearest_sample) {
MinMaxPyramid p;
for (int i = 0; i < 100; ++i) p.append(float(i));
PLOT_CHECK(p.snap(10.4, 0.0, 1.0) == 10);
PLOT_CHECK(p.snap(10.6, 0.0, 1.0) == 11);
PLOT_CHECK(p.snap(-5.0, 0.0, 1.0) == 0); // clamped
PLOT_CHECK(p.snap(1e9, 0.0, 1.0) == 99); // clamped
}
PLOT_TEST(pyramid_sums_match_bruteforce_on_aligned_ranges) {
std::mt19937 rng(99);
std::uniform_real_distribution<float> d(-3.0f, 7.0f);
std::vector<float> v(3000);
MinMaxPyramid p;
for (auto& x : v) { x = d(rng); p.append(x); }
for (u64 a = 0; a < 3000; a += kBucket * 5) {
for (u64 b = a + kBucket; b <= 3000; b += kBucket * 9) {
double s = 0.0, sq = 0.0;
for (u64 i = a; i < b; ++i) { s += v[i]; sq += double(v[i]) * double(v[i]); }
PLOT_CHECK_NEAR(p.sum_buckets(a, b), s, 1e-3);
PLOT_CHECK_NEAR(p.sumsq_buckets(a, b), sq, 1e-2);
}
}
}
PLOT_TEST(pyramid_clear_and_rebuild) {
MinMaxPyramid p;
for (int i = 0; i < 500; ++i) p.append(1.0f);
p.clear();
PLOT_CHECK(p.size() == 0);
PLOT_CHECK(p.level_count() <= 1);
p.append(7.0f);
PLOT_CHECK(p.size() == 1);
for (int i = 1; i < int(kBucket); ++i) p.append(1.0f); // complete bucket 0
PLOT_CHECK_NEAR(p.reduce_buckets(0, kBucket).max, 7.0, 1e-6);
PLOT_CHECK_NEAR(p.reduce_buckets(0, kBucket).min, 1.0, 1e-6);
}
```
`tests/test_ring_buffer.cpp`:
```cpp
PLOT_TEST(ring_wraps_and_keeps_latest) {
plot::data::RingBuffer<f32> r(8); // capacity rounded up to a power of two
for (int i = 0; i < 20; ++i) r.push(f32(i));
PLOT_CHECK(r.size() == 8);
PLOT_CHECK_NEAR(r[0], 12.0); // oldest retained
PLOT_CHECK_NEAR(r[7], 19.0); // newest
}
PLOT_TEST(ring_clear_resets) {
plot::data::RingBuffer<f32> r(8);
for (int i = 0; i < 5; ++i) r.push(f32(i));
r.clear();
PLOT_CHECK(r.size() == 0);
r.push(42.0f);
PLOT_CHECK(r.size() == 1);
PLOT_CHECK_NEAR(r[0], 42.0);
}
```
`tests/test_series.cpp`:
```cpp
#include "harness.hpp"
#include <plot/data/array_series.hpp>
#include <plot/data/stream_series.hpp>
#include <algorithm>
#include <cmath>
#include <random>
#include <vector>
PLOT_TEST(array_series_bounds_and_gather) {
std::vector<f32> y{1, 5, -2, 3};
plot::data::ArraySeries s(std::move(y), /*x0=*/10.0, /*dx=*/0.5);
PLOT_CHECK(s.size() == 4);
PLOT_CHECK_NEAR(s.x_bounds().min, 10.0, 1e-9);
PLOT_CHECK_NEAR(s.x_bounds().max, 11.5, 1e-9);
PLOT_CHECK_NEAR(s.y_bounds().min, -2.0, 1e-6);
std::vector<f32> out;
s.gather(0, 0, 4, out);
PLOT_CHECK(out.size() == 8); // interleaved x,y
PLOT_CHECK_NEAR(out[0], 10.0, 1e-6);
PLOT_CHECK_NEAR(out[1], 1.0, 1e-6);
PLOT_CHECK_NEAR(out[6], 11.5, 1e-6);
}
PLOT_TEST(stream_series_acquire_publishes_snapshot) {
plot::data::StreamSeries s(1 << 12, /*x0=*/0.0, /*dx=*/1e-6);
for (int i = 0; i < 100; ++i) s.push(f32(i));
PLOT_CHECK(s.produced() == 100);
s.acquire();
PLOT_CHECK(s.size() == 100);
// Pyramid must track the published samples.
PLOT_CHECK_NEAR(s.reduce(0, 100).max, 99.0, 1e-4);
for (int i = 100; i < 250; ++i) s.push(f32(i));
s.acquire();
PLOT_CHECK(s.size() == 250);
}
PLOT_TEST(stream_series_drops_oldest_when_full) {
plot::data::StreamSeries s(256, 0.0, 1.0);
for (int i = 0; i < 1000; ++i) s.push(f32(i));
s.acquire();
PLOT_CHECK(s.size() <= 256);
PLOT_CHECK_NEAR(s.y_bounds().max, 999.0, 1e-4);
}
PLOT_TEST(array_series_exact_range_queries_match_bruteforce) {
// The strong test for the exactness split: arbitrary, deliberately
// unaligned ranges must match a brute-force scan of the raw samples.
std::mt19937 rng(7);
std::uniform_real_distribution<float> d(-5.0f, 5.0f);
std::vector<f32> y(4000);
for (auto& v : y) v = d(rng);
plot::data::ArraySeries s(y, 0.0, 1.0);
for (u64 a = 0; a < 4000; a += 173) {
for (u64 b = a + 1; b <= 4000; b += 331) {
float lo = y[a], hi = y[a];
double sum = 0.0, sq = 0.0;
for (u64 i = a; i < b; ++i) {
lo = std::min(lo, y[i]); hi = std::max(hi, y[i]);
sum += y[i]; sq += double(y[i]) * double(y[i]);
}
const plot::Range r = s.reduce(a, b);
PLOT_CHECK_NEAR(r.min, lo, 1e-4);
PLOT_CHECK_NEAR(r.max, hi, 1e-4);
PLOT_CHECK_NEAR(s.sum(a, b), sum, 1e-2);
PLOT_CHECK_NEAR(s.sumsq(a, b), sq, 1e-1);
}
}
}
PLOT_TEST(stream_series_exact_queries_survive_ring_wrap) {
plot::data::StreamSeries s(1u << 10, 0.0, 1.0);
for (int i = 0; i < 3000; ++i) s.push(f32(i % 97)); // wraps the ring twice
s.acquire();
const u64 n = s.size();
const plot::Range r = s.reduce(0, n); // retained window, wrapped
PLOT_CHECK_NEAR(r.max, 96.0, 1e-4);
PLOT_CHECK_NEAR(r.min, 0.0, 1e-4);
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cmake --build build/debug --target plot_tests`
Expected: FAIL to compile — `plot/data/minmax_pyramid.hpp` not found.
- [ ] **Step 3: Write the implementation**
`include/plot/data/minmax_pyramid.hpp`:
```cpp
#pragma once
#include <plot/core/types.hpp>
#include <span>
#include <vector>
namespace plot::data {
struct MinMax { f32 min = 0.0f; f32 max = 0.0f; };
inline constexpr u32 kBucket = 64;
class MinMaxPyramid {
public:
void reserve(u64 n);
void append(f32 v);
void clear();
u64 size() const noexcept { return size_; }
u32 level_count() const noexcept { return u32(levels_.size()); }
std::span<const MinMax> level(u32 l) const;
// Whole-bucket only: [begin, end) is intersected with whole level-0 buckets and
// partial edges are excluded, never approximated. An empty result has span()==0.
Range reduce_buckets(u64 begin, u64 end) const;
f64 sum_buckets(u64 begin, u64 end) const;
f64 sumsq_buckets(u64 begin, u64 end) const;
u32 level_for(u64 n, f64 samples_per_pixel) const;
u64 snap(f64 x, f64 x0, f64 dx) const;
private:
u64 size_ = 0;
std::vector<std::vector<MinMax>> levels_; // levels_[0] = kBucket granularity
std::vector<std::vector<f64>> sums_, sumsqs_; // inclusive prefix sums per level
};
} // namespace plot::data
```
Implementation rules for `src/data/minmax_pyramid.cpp`:
- `append(v)`: write/merge into the last bucket of `levels_[0]`, then for each level `l`, if level `l` gained a complete pair, merge the last two entries of level `l` into a new entry of level `l+1`; otherwise stop early. Amortised O(1). The same walk maintains `sums_`/`sumsqs_` (inclusive prefix sums per level).
- `reduce_buckets(begin, end)`: `if (end <= begin) return {0,0};` intersect `[begin, end)` with whole level-0 buckets, then cover the result by descending to the coarsest level whose buckets lie entirely inside it, finishing the remainder at level 0. It must NEVER scan raw samples — it stores none — and a partial edge bucket is excluded, not approximated. `sum_buckets`/`sumsq_buckets` use the same decomposition over `sums_`/`sumsqs_`.
- `Series::reduce`/`sum`/`sumsq` in `src/data/series.cpp` are the exact entry points every other module uses: they take the bucket-aligned interior from `reduce_buckets`/`sum_buckets`/`sumsq_buckets` and scan the at-most-two partial edge buckets obtained via `copy_raw` (a stack buffer of at most 2*kBucket floats, so no allocation on the query path).
- `level_for(n, spp)`: `if (spp <= f64(kBucket)) return 0;` else `u32 l = u32(std::floor(std::log2(spp / f64(kBucket))));` clamp to `level_count() - 1`, and additionally clamp so that `level(l).size() >= 2`.
- `snap(x, x0, dx)`: `if (dx <= 0.0 || size_ == 0) return 0;` compute `i = llround((x - x0) / dx)` and clamp to `[0, size_ - 1]`.
`include/plot/data/ring_buffer.hpp`: `RingBuffer<T>` with power-of-two capacity, atomic producer index, `push(T)` overwriting the oldest entry when full, `size()`, `operator[](u64)` indexing oldest→newest, `clear()`.
`include/plot/data/series.hpp`: the abstract `Series` from spec §6. `virtual f64 x0() const` and `virtual f64 dx() const` expose the uniform x grid (measurement and `snap` need them). `virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const = 0;` is documented as "appends interleaved (x, y) pairs in data coordinates for bucket-aligned level `level`; level 0 emits raw samples". `reduce`/`sum`/`sumsq` are non-virtual, implemented once in `src/data/series.cpp` over the protected pure virtual `copy_raw(u64 begin, u64 end, f32* out) const`, which copies `end - begin` raw y values and handles any wraparound.
`ArraySeries`: holds `std::vector<f32> y_`, `f64 x0_`, `f64 dx_`, a `MinMaxPyramid`, and cached `Range y_bounds_`/`Range x_bounds_`. Constructor and `reset()` fill the pyramid and bounds; `commit()` rebuilds them (for when the caller mutated the vector out of band — document that the vector is owned and mutated only via `reset`). `copy_raw` is a bounds-checked `std::copy` over `y_`.
`StreamSeries`: `RingBuffer<f32>` of rounded-up capacity, atomic `produced_`, `f64 x0_`, `f64 dx_`; `push` is the only producer entry point and MUST NOT touch the pyramid; `acquire()` reads `produced_` once, grows the pyramid by the newly produced samples, and updates bounds. `x_bounds()` derives from the oldest retained index and `dx_`, so a wrap shifts the window forward. `copy_raw` must split the copy at the ring's wrap point; `reduce`/`sum`/`sumsq` are documented as operating on the *retained* window, so index 0 is the oldest sample still held.
Add `test_ring_buffer.cpp`, `test_minmax_pyramid.cpp` and `test_series.cpp` to the existing `plot_tests` target's source list in `tests/CMakeLists.txt`. Do not add new `add_test` entries — one `add_test(NAME plot_tests ...)` runs every registered case.
- [ ] **Step 4: Run tests to verify they pass**
Run: `cmake --build build/debug && ctest --test-dir build/debug --output-on-failure`
Expected: `pyramid_reduce_matches_bruteforce` (the 1300-odd range comparisons) passes, along with all series and ring tests. 0 failed.
- [ ] **Step 5: Commit**
```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/render/primitives.hpp>
PLOT_TEST(line_batch_emits_two_verts_per_segment) {
plot::render::LineBatch b;
b.begin();
const plot::Vec2 pts[3] = {{0, 0}, {1, 1}, {2, 0}};
b.add_polyline(pts, 3); // 2 segments
PLOT_CHECK(b.vertex_count() == 4);
b.clear();
PLOT_CHECK(b.vertex_count() == 0);
PLOT_CHECK(b.data().capacity() >= 4); // clear() must not free capacity
b.add_segment({0, 0}, {1, 1});
PLOT_CHECK(b.vertex_count() == 2);
}
PLOT_TEST(text_batch_measures_ascii) {
plot::render::BitmapFont f;
const f32 w = f.measure("12.5 ms");
PLOT_CHECK(w > 0.0f);
PLOT_CHECK_NEAR(f.measure(""), 0.0, 1e-6);
PLOT_CHECK(f.measure("ab") < f.measure("abc")); // monotonic in length
PLOT_CHECK(f.measure("iiii") < f.measure("WWWW")); // proportional-ish, not fixed-width-blank
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cmake --build build/debug --target plot_tests`
Expected: FAIL to compile — `plot/render/primitives.hpp` not found.
- [ ] **Step 3: Write the implementation**
`include/plot/render/style.hpp`:
```cpp
#pragma once
#include <plot/core/color.hpp>
namespace plot::render {
struct Style {
Color background = Color::hex(0x101418);
Color grid_major = Color::hex(0x2A3038);
Color grid_minor = Color::hex(0x1C2126);
Color axis_line = Color::hex(0x8A94A6);
Color axis_text = Color::hex(0xC8D0DC);
Color cursor = Color::hex(0xFFB020);
Color measure_fill = Color::hex(0xFFB020, 0.15f);
f32 axis_font_px = 12.0f;
f32 grid_label_pad = 4.0f;
f32 line_width_px = 1.5f;
f32 dense_threshold_spp = 1.5f; // spec §4.3
f32 tick_min_px = 60.0f; // minimum spacing between major ticks
};
} // namespace plot::render
```
`include/plot/render/text.hpp`: `class TextBackend { public: virtual ~TextBackend(); virtual f32 measure(const char*) const = 0; virtual f32 height() const = 0; virtual f32 ascent() const = 0; }`.
`include/plot/render/bitmap_font.hpp`: `class BitmapFont final : public TextBackend` — owns a 95-glyph (ASCII 32..126) 8×16 1-bit atlas as a `static constexpr u8 kAtlas[95 * 16]` table in the `.cpp`, plus `f32 measure`, `f32 advance(char)`, and `u32 glyph_rows(char)` returning the 16 rows. `measure("") == 0`.
`include/plot/render/primitives.hpp`:
```cpp
#pragma once
#include <plot/core/color.hpp>
#include <plot/math/vec2.hpp>
#include <vector>
namespace plot::render {
/// CPU-side staging for one instanced-AA line draw call.
class LineBatch {
public:
void begin();
void clear();
void add_segment(Vec2 a, Vec2 b); // one instance
void add_polyline(const Vec2* pts, u32 n); // n-1 instances
u32 vertex_count() const noexcept; // 2 vertices per staged segment
const std::vector<f32>& data() const noexcept; // interleaved x0,y0,x1,y1
};
} // namespace plot::render
```
`src/render/primitives.cpp` implements the staging buffers with `reserve`/`resize` reuse — `clear()` MUST NOT free capacity, and `begin()` MUST NOT reallocate when the frame's workload matches the previous frame.
GLSL sources live as raw string literals in `src/render/program.cpp`:
- `kLineVS`: inputs `vec2 a_p0, a_p1` (per-instance) and a unit-quad corner via `gl_VertexID`; uniforms `vec2 u_scale, u_offset` (data→pixel), `vec2 u_viewport`, `float u_width`; expands the segment in pixel space by `u_width/2` along the segment normal plus 1px of AA fringe, and outputs `v_dist` (distance to the segment axis in pixels) and `v_along`.
- `kLineFS`: `discard` when `length(v_dist) > u_width * 0.5 + 1.0`; alpha = `smoothstep` over the last pixel of the half-width; outputs `u_color * alpha`.
- `kTextVS/kTextFS`: instanced quads from the glyph atlas; a single-channel `GL_R8` texture sampled with `GL_NEAREST`, tinted by `u_color`, with `u_px_range`/`u_tex_size` uniforms.
Both shaders declare `#version 330 core`. The 4.3+ fast path in Task 7 changes only the buffer upload strategy, never the shader source, so these stay valid on both paths.
`include/plot/render/program.hpp` / `buffer.hpp` / `vertex_array.hpp` are thin owning wrappers over `plot::gl::{Program,Buffer,VertexArray}` that know the attribute layouts for the line and text pipelines (offsetof-based, no magic numbers in callers).
- [ ] **Step 4: Run test to verify it passes**
Run: `cmake --build build/debug && ctest --test-dir build/debug --output-on-failure`
Expected: `line_batch_emits_two_verts_per_segment`, `text_batch_measures_ascii` `[ ok ]`, 0 failed.
Then confirm the shaders compile on the real driver (this catches GLSL syntax errors that no CPU test can). Append this permanent test to `tests/test_gl_context.cpp`:
```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<Tick>&)`, `plot::format_si(f64, char* buf, usize n)`, `plot::format_time(f64 seconds, char*, usize)`, `plot::AxisPlace`, `plot::Axis`, `plot::Grid`, `plot::Trace`, `plot::Cursor`, `plot::Measurement{ dx, dy, slope, rms, mean, ymin, ymax, integral, valid }`, `plot::Measure::evaluate(const data::Series&, u64 begin, u64 end) -> Measurement`, and `plot::Plot` with `layout()`, `trace_count()`, `measurement(u32)`.
- [ ] **Step 1: Write the failing tests**
`tests/test_ticks.cpp`:
```cpp
#include "harness.hpp"
#include <plot/plot/ticks.hpp>
#include <cmath>
#include <string>
#include <vector>
PLOT_TEST(nice_ticks_are_powers_of_ten_times_125) {
std::vector<plot::Tick> t;
plot::generate_ticks({0.0, 1.0}, /*px=*/500.0f, /*min_px=*/60.0f, t);
PLOT_CHECK(!t.empty());
for (auto& tick : t) PLOT_CHECK(std::fabs(tick.value * 5.0 - std::round(tick.value * 5.0)) < 1e-9);
// spacing of major ticks >= min_px
PLOT_CHECK(t.size() <= 500.0 / 60.0 + 2.0);
}
PLOT_TEST(ticks_are_monotonic_and_cover_range) {
std::vector<plot::Tick> t;
plot::generate_ticks({-3.7, 91.2}, 800.0f, 60.0f, t);
for (size_t i = 1; i < t.size(); ++i) PLOT_CHECK(t[i].value > t[i-1].value);
PLOT_CHECK(t.front().value <= 0.0);
PLOT_CHECK(t.back().value >= 90.0);
}
PLOT_TEST(si_formatting) {
char b[32];
plot::format_si(0.0000015, b, sizeof b); PLOT_CHECK(std::string(b) == "1.5\xC2\xB5s" || std::string(b).find("1.5") == 0);
plot::format_si(2.5e9, b, sizeof b); PLOT_CHECK(std::string(b).find("2.5") == 0);
plot::format_si(1234.0, b, sizeof b); PLOT_CHECK(std::string(b).find("1.23") == 0);
plot::format_si(0.0, b, sizeof b); PLOT_CHECK(std::string(b) == "0");
}
PLOT_TEST(degenerate_ranges_do_not_hang) {
std::vector<plot::Tick> t;
plot::generate_ticks({5.0, 5.0}, 500.0f, 60.0f, t); // must not loop forever
PLOT_CHECK(t.size() <= 64);
plot::generate_ticks({0.0, 0.0}, 0.0f, 60.0f, t);
plot::generate_ticks({1e300, 1e300}, 500.0f, 60.0f, t);
}
```
`tests/test_measure.cpp`:
```cpp
#include "harness.hpp"
#include <plot/plot/measure.hpp>
#include <plot/data/array_series.hpp>
#include <algorithm>
#include <chrono>
#include <cmath>
#include <vector>
PLOT_TEST(measurement_over_interval_matches_direct_computation) {
std::vector<f32> y; for (int i = 0; i < 1000; ++i) y.push_back(f32(std::sin(i * 0.01)));
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
const auto m = plot::Measure::evaluate(s, 100, 800);
double sum = 0, sumsq = 0, integral = 0; float lo = 1e30f, hi = -1e30f;
for (u64 i = 100; i < 800; ++i) {
const double v = std::sin(i * 0.01);
sum += v; sumsq += v * v; integral += v; // dx == 1
lo = std::min(lo, float(v)); hi = std::max(hi, float(v));
}
const double n = 700;
PLOT_CHECK_NEAR(m.mean, sum / n, 1e-5);
PLOT_CHECK_NEAR(m.rms, std::sqrt(sumsq / n), 1e-5);
PLOT_CHECK_NEAR(m.integral, integral, 1e-3);
PLOT_CHECK_NEAR(m.ymin, lo, 1e-6);
PLOT_CHECK_NEAR(m.ymax, hi, 1e-6);
PLOT_CHECK_NEAR(m.dx, 699.0, 1e-9);
}
PLOT_TEST(measurement_uses_pyramid_not_scan) {
// Guard against a future regression that linear-scans the series: a huge
// series must evaluate a 1-sample-wide interval in well under a millisecond.
std::vector<f32> y(1u << 22, 1.0f); // 4M samples
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
const auto t0 = std::chrono::steady_clock::now();
for (int i = 0; i < 1000; ++i) (void)plot::Measure::evaluate(s, 1'000'000, 1'000'001);
const auto us = std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - t0).count();
PLOT_CHECK(us < 50'000); // 1000 evals in < 50 ms
}
PLOT_TEST(empty_and_single_sample_intervals_are_valid_false) {
std::vector<f32> y{3.0f};
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
PLOT_CHECK(!plot::Measure::evaluate(s, 0, 0).valid);
PLOT_CHECK(!plot::Measure::evaluate(s, 1, 1).valid);
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cmake --build build/debug --target plot_tests`
Expected: FAIL to compile — `plot/plot/ticks.hpp` not found.
- [ ] **Step 3: Write the implementation**
`src/plot/ticks.cpp` — the nice-number algorithm:
```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<f32> y(1'000'000);
for (u64 i = 0; i < y.size(); ++i) y[i] = f32(std::sin(double(i) * 0.0001));
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
plot::Plot p;
p.set_viewport({0, 0, f32(W), f32(H)});
const u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
const u32 tr = p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
r.begin_frame(W, H, {0, 0, f32(W), f32(H)});
r.draw(p);
r.end_frame();
// Trace colour must appear in every one of 16 sampled columns across the
// plot area; background must dominate the corners.
const plot::Rect a = p.layout().axes;
u32 hit_columns = 0;
for (u32 c = 0; c < 16; ++c) {
const u32 x = u32(a.x + a.w * (f64(c) + 0.5) / 16.0);
bool hit = false;
for (u32 yy = u32(a.y); yy < u32(a.y + a.h) && !hit; ++yy) {
u8 px[4]; PLOT_CHECK(ctx.read_pixel(x, yy, px));
if (px[2] > 120 && px[3] > 0 && px[0] < 160) hit = true; // blue-ish
}
if (hit) ++hit_columns;
}
PLOT_CHECK(hit_columns >= 15); // >= 95% of columns covered
u8 corner[4]; PLOT_CHECK(ctx.read_pixel(2, 2, corner));
PLOT_CHECK(corner[0] + corner[1] + corner[2] < 200); // dark background
PLOT_CHECK(!r.last_frame_had_gl_error());
ctx.destroy();
}
PLOT_TEST(dense_and_sparse_paths_agree_on_extent) {
plottest::HeadlessContext ctx; plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r; PLOT_CHECK(r.init(&st));
const u32 W = 640, H = 360;
std::vector<f32> y(1'000'000);
for (u64 i = 0; i < y.size(); ++i) y[i] = f32(std::sin(double(i) * 0.0001));
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
plot::Plot p;
p.set_viewport({0, 0, f32(W), f32(H)});
const u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
auto covered_columns = [&](plot::Range xr) -> u32 {
p.set_x_range(ax, xr);
r.begin_frame(W, H, {0, 0, f32(W), f32(H)});
r.draw(p);
r.end_frame();
const plot::Rect a = p.layout().axes;
u32 cols = 0;
for (u32 c = 0; c < 32; ++c) {
const u32 x = u32(a.x + a.w * (f64(c) + 0.5) / 32.0);
for (u32 yy = u32(a.y); yy < u32(a.y + a.h); ++yy) {
u8 px[4]; PLOT_CHECK(ctx.read_pixel(x, yy, px));
if (px[2] > 120 && px[0] < 160) { ++cols; break; }
}
}
return cols;
};
// Dense: 1M samples over 1M x-units -> ~1562 samples/pixel -> LOD buckets.
const u32 coarse = covered_columns({0.0, 1'000'000.0});
// Sparse: 640 samples over 640 x-units -> 1 sample/pixel -> instanced quads.
const u32 fine = covered_columns({0.0, 640.0});
PLOT_CHECK(coarse >= 30); // >= 95% of 32 columns
PLOT_CHECK(fine >= 30);
PLOT_CHECK(!r.last_frame_had_gl_error());
ctx.destroy();
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cmake --build build/debug --target plot_tests`
Expected: FAIL to compile — `plot/render/renderer.hpp` not found.
- [ ] **Step 3: Write the implementation**
`src/render/renderer.cpp` frame sequence:
```
begin_frame(fb_w, fb_h, viewport_px):
glViewport(fb_w, fb_h, viewport_px); // origin flipped: Rect is top-left, GL is bottom-left
clear the viewport to style.background
reset all batches
draw(plot):
for each axis: compute ticks; stage grid segments; stage tick labels
for each visible trace:
spp = x_span_samples / plot_width_px
if (spp < style.dense_threshold_spp) stage raw polyline (level 0, instanced path)
else level = pyramid.level_for(n, spp);
stage bucket min/max vertical strokes
flush grids, then line batches, then text last (text is texture-bound, draw once)
end_frame():
unbind everything; record glGetError into last_frame_had_gl_error()
```
Upload strategy selected once in `init()`:
- `caps().buffer_storage && caps().persistent_map` → allocate each VBO with `glBufferStorage(MAP_WRITE|MAP_PERSISTENT|MAP_COHERENT)`, keep a persistent pointer, and triple-buffer with `glFenceSync`/`glClientWaitSync(0)` before reusing a slot.
- otherwise → `glBufferData(size, nullptr, GL_STREAM_DRAW)` orphan-and-refill each frame.
Both paths go through the same `upload_vertices(span)` helper so no caller sees the difference.
Coordinate note to get right the first time: `Rect` is top-left-origin in pixels, GL is bottom-left. Convert once in `begin_frame` and store the flipped viewport in the renders; never flip per-primitive.
- [ ] **Step 4: Run test to verify it passes**
Run: `cmake --build build/debug && ctest --test-dir build/debug --output-on-failure`
Expected: `renders_visible_trace_across_viewport` `[ ok ]` with `hit_columns >= 15`, `dense_and_sparse_paths_agree_on_extent` `[ ok ]`. 0 failed.
Save one rendered frame as a PPM to `build/debug/smoke.ppm` from the test when `PLOT_DUMP_FRAMES` is set in the environment, so the render can be eyeballed without a window.
- [ ] **Step 5: Commit**
```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<void(plot::Plot&, f64 dt)>;
bool run(const RunConfig&, const FrameFn&, plot::Status* = nullptr);
```
Translates GLFW callbacks (`glfwSetCursorPosCallback`, `glfwSetMouseButtonCallback`, `glfwSetScrollCallback`, `glfwSetKeyCallback`, `glfwSetFramebufferSizeCallback`, `glfwGetFramebufferSize`, `glfwGetWindowContentScale`) into `plot::input` events and keeps `Plot::viewport()` in framebuffer pixels.
- [ ] **Step 1: Write the demo**
`examples/glfw_demo.cpp`: creates a `StreamSeries(1u << 20, 0.0, 1e-6)` fed by a background thread at 1 MS/s (`std::this_thread::sleep_for` paced, sine + noise + a periodic spike so decimation correctness is visible), two axes with SI labels on the time axis, a grid, one trace, and two cursors with a measurement readout. An ImGui-free on-screen HUD is out of scope — the measurement is drawn as plot annotations.
The time axis label must be `format_time` based and the title must show live samples/second and frame time.
- [ ] **Step 2: Build and run it**
Run: `cmake --preset linux-release && cmake --build build/release --target plot_glfw_demo && ./build/release/examples/plot_glfw_demo`
Expected: a window with a scrolling sine trace, grid, labelled axes, two draggable cursors and a Δ/RMS readout. Pan, wheel-zoom, shift-box-zoom and cursor drag all work. Screenshot it.
- [ ] **Step 3: Commit**
```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 <plot/plot.hpp>
int main() {
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
return int(p.trace_count());
}
EOF
cmake -S /tmp/plot-consumer -B /tmp/plot-consumer/build -DCMAKE_PREFIX_PATH=/tmp/plot-install
cmake --build /tmp/plot-consumer/build
/tmp/plot-consumer/build/consumer; echo "exit=$?"
```
Expected: configure finds `plot 0.1.0`, the consumer compiles and links `plot::core` with no `-I` or `-L` flags of its own.
- [ ] **Step 3: Full verification pass**
```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"
```