Files
plotter/docs/superpowers/plans/2026-09-27-plot-library-scaffold.md
T
Martino Ferrari fb2c2f5e4d fix(data): draw the partial edge buckets at LOD levels, correct the pyramid's memory and precondition docs
Findings from the review of the sliding-window change:

- A level >= 1 gather emitted only complete, wholly-retained buckets, so the
  geometry stopped at the last bucket boundary inside the range: the newest
  samples of a live window -- up to `span - 1` of them, about a pixel at the
  level level_for() picks -- were not drawn, on the right edge, which is the
  part of a scrolling trace that gets watched, and the oldest retained samples
  fell outside the geometry too (the level-1 gather of a sliding window started
  51 samples in). The two partial edge buckets are now gathered as raw samples
  through copy_raw() and bracketed around the envelope -- the same exactness
  split reduce/sum/sumsq already use -- so the emitted geometry spans the
  requested range exactly. The pyramid still stores no partial bucket, so append
  stays amortised O(1); at 1 MS/s the demo stages 511 extra sub-pixel segments
  and still draws at 1.05 ms/frame, and the screenshot's trace now reaches one
  column further at each end.
- `drop_before`'s doc claimed a no-op "for an empty pyramid": false, and
  backwards -- the restart branch is exactly what an empty pyramid hits, which is
  what StreamSeries::acquire() depends on when the ring saturated before the
  first acquire. The precondition is `absolute_sample <= window_base()`, and the
  restart is documented as what it is.
- The pyramid's memory figure was 3x optimistic: an entry is 24 bytes (MinMax
  plus the exact sum/sumsq doubles), so level 0 costs 0.375n bytes and the whole
  pyramid ~0.75n -- about 19% of 4-byte raw samples, not the 6% a min/max-only
  index would cost. Corrected in the header, spec 4.2 and 4.5, with the trade
  stated (the doubles buy O(log n) sum/mean/RMS/integral from whole buckets),
  and the plan's Task 3 sketch no longer describes per-level prefix sums, a
  level() span or an unaligned-end widening that does not exist.

Tests: `gather_at_a_lod_level_reaches_both_ends_of_the_window` pins that the
first and last emitted points are the oldest and newest retained samples -- with
spike values no bucket envelope can produce -- at level 1, at the level a dense
view picks, and for a static series whose end is unaligned. It fails against the
previous commit (first point at the first whole bucket), and so does the sliding
gather mapping check, which now accounts for the raw edge pairs.
2026-09-28 00:19:59 +02:00

75 KiB
Raw Blame History

plot library scaffold — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build a zero-dependency-core C++20 OpenGL plotting library that renders real-time, high-sample-rate time series into a caller-owned framebuffer, with GLFW, Dear ImGui, Qt6 and GTK4 adapters.

Architecture: Strict downward layering (core/math → gl → data → render → plot → input) with adapters on top. Vertex data stays in data space and pan/zoom is a shader uniform, so nothing is re-uploaded on interaction. An incremental min/max LOD pyramid gives O(pixels) rasterisation and O(log n) measurement queries on the same index.

Tech Stack: C++20, CMake ≥3.24, Ninja, OpenGL 3.3 core (runtime 4.3+ fast path), EGL for headless tests, GLFW/ImGui/Qt6/GTK4 only for adapters.

Spec: docs/superpowers/specs/2026-09-27-plot-library-design.md (read it before starting any task).

Global Constraints

  • Core target plot_core MUST NOT link or include any third-party library: no GLM, no glad, no GLEW, no GL/glcorearb.h, no third_party/ directory.
  • Public headers live under include/plot/ and MUST be self-contained (each compiles standalone).
  • OpenGL baseline is 3.3 core. Anything above 3.3 MUST be behind a runtime capability check in plot::gl::caps().
  • Every GL vertex/instance buffer holds data-space coordinates. Pan/zoom MUST NOT re-upload vertex data.
  • No exceptions cross the public API boundary; fallible operations return bool/Status.
  • C++20. Warnings: -Wall -Wextra -Wpedantic -Wconversion -Wshadow on plot targets; warnings MUST be clean, never suppressed per-file.
  • Namespace plot, sub-namespaces plot::gl, plot::data, plot::input, plot::math.
  • Commit after every task with the message given in the task. Do not run project-wide test suites inside a task; run only the commands listed.
  • tests/ must pass with ctest --output-on-failure from the build directory at the end of every task from Task 1 onward.

File Structure

plotter/
├── CMakeLists.txt                       plot_core, options, install/export
├── CMakePresets.json                    linux-debug, linux-release, linux-asan
├── .clang-format
├── cmake/plot_options.cmake             version floor, warnings, option defaults
├── include/plot/
│   ├── plot.hpp                         umbrella include
│   ├── core/{types,color,rect,span,status}.hpp
│   ├── math/{vec2,mat3,transform}.hpp
│   ├── gl/{api,loader,handle,caps}.hpp
│   ├── render/{style,text,bitmap_font,program,buffer,vertex_array,primitives,renderer}.hpp
│   ├── data/{ring_buffer,minmax_pyramid,series,array_series,stream_series}.hpp
│   ├── plot/{ticks,axis,grid,trace,cursor,measure,annotation,plot}.hpp
│   └── input/{events,controller}.hpp
├── src/                                 one .cpp per non-inline component
├── adapters/{glfw,imgui,qt6,gtk4}/      each: CMakeLists.txt + header + impl
├── examples/{glfw_demo,imgui_demo,qt_demo,gtk_demo}.cpp
└── tests/                               harness.hpp, egl_headless.{hpp,cpp}, test_*.cpp

Task 1: Repository skeleton, core types, math

Files:

  • Create: CMakeLists.txt, CMakePresets.json, .clang-format, cmake/plot_options.cmake, .gitignore
  • Create: include/plot/core/{types,color,rect,status,span}.hpp
  • Create: include/plot/math/{vec2,mat3,transform}.hpp
  • Create: src/math/transform.cpp, src/core/status.cpp
  • Create: include/plot/plot.hpp
  • Create: tests/CMakeLists.txt, tests/harness.hpp, tests/test_transform.cpp

Interfaces:

  • Consumes: nothing.

  • Produces: plot::f32/f64/u32/u64/kInvalid, plot::Range, plot::Rect, plot::Color, plot::Status, plot::ErrorCode, plot::Transform with to_screen_x/to_data_x/to_screen_y/to_data_y, and the PLOT_CHECK(cond) test macro, PLOT_TEST(name) test registration.

  • Step 1: Write the failing test

tests/harness.hpp — a zero-dependency CTest harness (no gtest):

#pragma once
#include <cstdio>
#include <cmath>
#include <chrono>
#include <algorithm>
#include <vector>
#include <string>
#include <functional>

using plot::f32;
using plot::f64;
using plot::u32;
using plot::u64;

namespace plottest {
struct Case { const char* name; std::function<void()> fn; };
inline std::vector<Case>& cases() { static std::vector<Case> c; return c; }
struct Reg { Reg(const char* n, std::function<void()> f) { cases().push_back({n, std::move(f)}); } };
struct Failure { std::string msg; };
inline int run_all() {
  int failed = 0;
  for (auto& c : cases()) {
    try { c.fn(); std::printf("[ ok ] %s\n", c.name); }
    catch (const Failure& f) { std::printf("[FAIL] %s: %s\n", c.name, f.msg.c_str()); ++failed; }
  }
  std::printf("%zu cases, %d failed\n", cases().size(), failed);
  return failed == 0 ? 0 : 1;
}
}  // namespace plottest

#define PLOT_TEST(name)                                                        \
  static void name();                                                          \
  static ::plottest::Reg reg_##name(#name, name);                              \
  static void name()

#define PLOT_CHECK(cond)                                                       \
  do { if (!(cond)) throw ::plottest::Failure{std::string(__FILE__) + ":" +    \
        std::to_string(__LINE__) + " CHECK(" #cond ")"}; } while (0)

#define PLOT_CHECK_NEAR(a, b, eps)                                             \
  do { const double _a = (a), _b = (b);                                        \
       if (!(std::fabs(_a - _b) <= (eps))) throw ::plottest::Failure{           \
         std::string(__FILE__) + ":" + std::to_string(__LINE__) +               \
         " CHECK_NEAR(" #a ", " #b ") got " + std::to_string(_a) +              \
         " vs " + std::to_string(_b)}; } while (0)

tests/main.cpp:

#include "harness.hpp"
int main() { return plottest::run_all(); }

tests/test_transform.cpp:

#include "harness.hpp"
#include <plot/math/transform.hpp>

using plot::Transform;

PLOT_TEST(linear_round_trip) {
  Transform t{2.0, 10.0, 3.0, -1.0, false, false};
  PLOT_CHECK_NEAR(t.to_screen_x(0.0), 10.0, 1e-12);
  PLOT_CHECK_NEAR(t.to_screen_x(5.0), 20.0, 1e-12);
  PLOT_CHECK_NEAR(t.to_data_x(20.0), 5.0, 1e-12);
  PLOT_CHECK_NEAR(t.to_data_y(t.to_screen_y(7.5)), 7.5, 1e-12);
}

PLOT_TEST(log_x_round_trip) {
  Transform t{100.0, 0.0, 1.0, 0.0, true, false};
  PLOT_CHECK_NEAR(t.to_screen_x(1.0), 0.0, 1e-12);
  PLOT_CHECK_NEAR(t.to_screen_x(10.0), 100.0, 1e-12);
  PLOT_CHECK_NEAR(t.to_data_x(100.0), 10.0, 1e-9);
}

PLOT_TEST(zoom_anchor_is_invariant) {
  // Anchoring a zoom at a data coordinate must leave that coordinate's screen
  // position unchanged.
  Transform t{1.0, 0.0, 1.0, 0.0, false, false};
  const double anchor_data = 3.25;
  const double before = t.to_screen_x(anchor_data);
  t.sx *= 4.0;
  t.tx = before - anchor_data * t.sx;
  PLOT_CHECK_NEAR(t.to_screen_x(anchor_data), before, 1e-12);
}
  • Step 2: Run test to verify it fails

Run: cmake --preset linux-debug && cmake --build build/debug --target plot_tests Expected: FAIL — plot/math/transform.hpp does not exist.

  • Step 3: Write the implementation

include/plot/core/types.hpp:

#pragma once
#include <cstdint>
#include <cstddef>

namespace plot {
using f32 = float;
using f64 = double;
using i32 = std::int32_t;
using u32 = std::uint32_t;
using u64 = std::uint64_t;
using usize = std::size_t;
constexpr u64 kInvalid = ~u64{0};

struct Range {
  f64 min = 0.0;
  f64 max = 1.0;
  constexpr f64 span() const noexcept { return max - min; }
  constexpr f64 clamp(f64 v) const noexcept { return v < min ? min : (v > max ? max : v); }
};
}  // namespace plot

include/plot/math/vec2.hpp:

#pragma once
#include <plot/core/types.hpp>
namespace plot {
struct Vec2 {
  f32 x = 0.0f, y = 0.0f;
};
}  // namespace plot

(Implement Vec2{x,y} with + - * /, dot, length.)

include/plot/math/transform.hpp:

#pragma once
#include <plot/core/types.hpp>
#include <cmath>
namespace plot {
/// Affine data→screen mapping: screen = data * s + t, applied after log10 when enabled.
struct Transform {
  f64 sx = 1.0, tx = 0.0;
  f64 sy = 1.0, ty = 0.0;
  bool log_x = false, log_y = false;

  f64 to_screen_x(f64 x) const noexcept {
    const f64 v = log_x ? log10_positive(x) : x;
    return v * sx + tx;
  }
  f64 to_screen_y(f64 y) const noexcept {
    const f64 v = log_y ? log10_positive(y) : y;
    return v * sy + ty;
  }
  f64 to_data_x(f64 sx_screen) const noexcept {
    const f64 v = (sx_screen - tx) / sx;
    return log_x ? std::pow(10.0, v) : v;
  }
  f64 to_data_y(f64 sy_screen) const noexcept {
    const f64 v = (sy_screen - ty) / sy;
    return log_y ? std::pow(10.0, v) : v;
  }
  /// Set scale/offset so that [lo,hi] data maps onto [pix_lo,pix_hi] screen.
  static Transform from_range(Range x, Range y, f64 px0, f64 px1, f64 py0, f64 py1, bool lx, bool ly);
  static f64 log10_positive(f64 v) noexcept;  // clamps to >= 1e-300 before log10
};
}  // namespace plot

Implement from_range and log10_positive in src/math/transform.cpp. from_range for a log axis maps log10(lo)→log10(hi), and for a degenerate span (min == max) expands by ±0.5 so the transform is always invertible.

include/plot/core/{color,rect,status,span}.hpp per spec §6: Color{r,g,b,a} with static Color hex(u32 rgb, f32 a = 1.0f); Rect{x,y,w,h} with contains, right(), bottom(); ErrorCode + Status with explicit operator bool() const { return code == ErrorCode::Ok; }; span.hpp re-exports std::span as plot::Span.

tests/CMakeLists.txt:

add_executable(plot_tests main.cpp test_transform.cpp)
target_link_libraries(plot_tests PRIVATE plot::core)
add_test(NAME plot_tests COMMAND plot_tests)

Every later task adds its new test_*.cpp to the plot_tests source list; there is exactly one add_test entry, because the harness runs every registered case in one binary. The top-level CMakeLists.txt calls enable_testing() when PLOT_BUILD_TESTS.

Top-level CMakeLists.txt: cmake_minimum_required(VERSION 3.24), project(plot VERSION 0.1.0 LANGUAGES CXX), include cmake/plot_options.cmake, declare the six PLOT_BUILD_* options from the plan header, build plot_core from src/**/*.cpp (explicit list), add_library(plot::core ALIAS plot_core), target_include_directories(plot_core PUBLIC include), target_compile_features(plot_core PUBLIC cxx_std_20), target_link_libraries(plot_core PRIVATE ${CMAKE_DL_LIBS}), then add_subdirectory(tests) when PLOT_BUILD_TESTS.

  • Step 4: Run test to verify it passes

Run: cmake --build build/debug && ctest --test-dir build/debug --output-on-failure Expected: linear_round_trip, log_x_round_trip, zoom_anchor_is_invariant all [ ok ], 3 cases, 0 failed, ctest reports 100% tests passed.

  • Step 5: Commit
git add -A && git commit -m "feat: project skeleton, core types and math with transform tests"

Task 2: GL loader, RAII handles, capability probe, headless EGL harness

Files:

  • Create: include/plot/gl/{api,loader,handle,caps}.hpp, src/gl/loader.cpp, src/gl/handle.cpp
  • Create: tests/egl_headless.hpp, tests/egl_headless.cpp, tests/test_gl_context.cpp
  • Modify: CMakeLists.txt (add src/gl/*.cpp, EGL dependency for tests), tests/CMakeLists.txt

Interfaces:

  • Consumes: plot::Status, plot::ErrorCode.

  • Produces: plot::gl::load(ProcLoader), plot::gl::caps(), plot::gl::Buffer/VertexArray/Program/Texture/Framebuffer/Fence, and test helper plottest::HeadlessContext with bool create(Status*), void destroy(), u32 width/height, bool read_pixel(u32 x, u32 y, u8 rgba[4]).

  • Step 1: Write the failing test

tests/test_gl_context.cpp:

#include "harness.hpp"
#include "egl_headless.hpp"
#include <plot/gl/loader.hpp>
#include <plot/gl/caps.hpp>

PLOT_TEST(headless_context_provides_gl33) {
  plottest::HeadlessContext ctx;
  plot::Status st;
  PLOT_CHECK(ctx.create(&st));           // fails loudly if surfaceless EGL is unavailable
  PLOT_CHECK(plot::gl::load(nullptr));
  const plot::gl::Caps& c = plot::gl::caps();
  PLOT_CHECK(c.major > 3 || (c.major == 3 && c.minor >= 3));
  PLOT_CHECK(c.renderer != nullptr && c.renderer[0] != '\0');
  ctx.destroy();
}
  • Step 2: Run test to verify it fails

Run: cmake --build build/debug --target plot_tests && ./build/debug/tests/plot_tests Expected: FAIL to compile — egl_headless.hpp not found.

  • Step 3: Write the implementation

include/plot/gl/api.hpp — own typedefs and the function table, no Khronos headers:

#pragma once
#include <plot/core/types.hpp>

namespace plot::gl {

using GLenum = unsigned int;  using GLuint = unsigned int;
using GLint  = int;           using GLsizei = int;
using GLboolean = unsigned char;
using GLbitfield = unsigned int; using GLintptr = std::intptr_t;
using GLsizeiptr = std::ptrdiff_t; using GLchar = char;

// Enums actually used. Values are fixed by the OpenGL specification.
enum : GLenum {
  GL_NO_ERROR = 0, GL_TRIANGLES = 0x0004, GL_UNSIGNED_BYTE = 0x1401,
  GL_FLOAT = 0x1406, GL_TRIANGLE_STRIP = 0x0005, GL_LINE_STRIP = 0x0003,
  GL_ARRAY_BUFFER = 0x8892, GL_STATIC_DRAW = 0x88E4, GL_DYNAMIC_DRAW = 0x88E8,
  GL_STREAM_DRAW = 0x88E0, GL_FRAGMENT_SHADER = 0x8B30, GL_VERTEX_SHADER = 0x8B31,
  GL_COMPILE_STATUS = 0x8B81, GL_LINK_STATUS = 0x8B82, GL_RGBA8 = 0x8058,
  GL_RGBA = 0x1908, GL_TEXTURE_2D = 0x0DE1, GL_FRAMEBUFFER = 0x8D40,
  GL_COLOR_ATTACHMENT0 = 0x8CE0, GL_FRAMEBUFFER_COMPLETE = 0x8CD5,
  GL_BLEND = 0x0BE2, GL_SRC_ALPHA = 0x0302, GL_ONE_MINUS_SRC_ALPHA = 0x0303,
  GL_TEXTURE0 = 0x84C0, GL_LINEAR = 0x2601, GL_CLAMP_TO_EDGE = 0x812F,
  GL_MAP_WRITE_BIT = 0x0002, GL_MAP_PERSISTENT_BIT = 0x0040,
  GL_MAP_COHERENT_BIT = 0x0080, GL_SYNC_GPU_COMMANDS_COMPLETE = 0x9117,
  /* … remaining enums added as used … */
};

// X-macro list: (return type, name, parameter list)
#define PLOT_GL_FUNCS(X)                                                       \
  X(const GLubyte*, glGetString, (GLenum))                                     \
  X(GLenum, glGetError, (void))                                                \
  X(void, glViewport, (GLint, GLint, GLsizei, GLsizei))                        \
  X(void, glClearColor, (float, float, float, float))                          \
  X(void, glClear, (GLbitfield))                                               \
  X(void, glEnable, (GLenum))                                                  \
  X(void, glBlendFunc, (GLenum, GLenum))                                       \
  X(GLuint, glCreateShader, (GLenum))                                          \
  X(void, glShaderSource, (GLuint, GLsizei, const GLchar* const*, const GLint*))\
  X(void, glCompileShader, (GLuint))                                           \
  X(void, glGetShaderiv, (GLuint, GLenum, GLint*))                             \
  X(void, glGetShaderInfoLog, (GLuint, GLsizei, GLsizei*, GLchar*))            \
  X(GLuint, glCreateProgram, (void))                                           \
  X(void, glAttachShader, (GLuint, GLuint))                                    \
  X(void, glLinkProgram, (GLuint))                                             \
  X(void, glGetProgramiv, (GLuint, GLenum, GLint*))                            \
  X(void, glGetProgramInfoLog, (GLuint, GLsizei, GLsizei*, GLchar*))           \
  X(void, glUseProgram, (GLuint))                                              \
  X(GLint, glGetUniformLocation, (GLuint, const GLchar*))                      \
  X(void, glUniform1f, (GLint, float))                                         \
  X(void, glUniform2f, (GLint, float, float))                                  \
  X(void, glUniform1i, (GLint, GLint))                                         \
  X(void, glUniform2fv, (GLint, GLsizei, const float*))                        \
  X(void, glUniform1fv, (GLint, GLsizei, const float*))                        \
  X(void, glGenBuffers, (GLsizei, GLuint*))                                    \
  X(void, glDeleteBuffers, (GLsizei, const GLuint*))                           \
  X(void, glBindBuffer, (GLenum, GLuint))                                      \
  X(void, glBufferData, (GLenum, GLsizeiptr, const void*, GLenum))             \
  X(void, glBufferSubData, (GLenum, GLintptr, GLsizeiptr, const void*))        \
  X(void, glGenVertexArrays, (GLsizei, GLuint*))                               \
  X(void, glDeleteVertexArrays, (GLsizei, const GLuint*))                      \
  X(void, glBindVertexArray, (GLuint))                                         \
  X(void, glEnableVertexAttribArray, (GLuint))                                 \
  X(void, glVertexAttribPointer, (GLuint, GLint, GLenum, GLboolean, GLsizei, const void*))\
  X(void, glDrawArrays, (GLenum, GLint, GLsizei))                              \
  X(void, glDrawArraysInstanced, (GLenum, GLint, GLsizei, GLsizei))            \
  X(void, glGenTextures, (GLsizei, GLuint*))                                   \
  X(void, glDeleteTextures, (GLsizei, const GLuint*))                          \
  X(void, glBindTexture, (GLenum, GLuint))                                     \
  X(void, glTexImage2D, (GLenum, GLint, GLint, GLsizei, GLsizei, GLint, GLenum, GLenum, const void*))\
  X(void, glTexParameteri, (GLenum, GLenum, GLint))                            \
  X(void, glGenFramebuffers, (GLsizei, GLuint*))                               \
  X(void, glDeleteFramebuffers, (GLsizei, const GLuint*))                      \
  X(void, glBindFramebuffer, (GLenum, GLuint))                                 \
  X(void, glFramebufferTexture2D, (GLenum, GLenum, GLenum, GLuint, GLint))     \
  X(GLenum, glCheckFramebufferStatus, (GLenum))                                \
  X(void, glReadPixels, (GLint, GLint, GLsizei, GLsizei, GLenum, GLenum, void*))\
  X(void, glGenRenderbuffers, (GLsizei, GLuint*))                              \
  X(void, glDeleteRenderbuffers, (GLsizei, const GLuint*))                     \
  X(void, glBindRenderbuffer, (GLenum, GLuint))                                \
  X(void, glRenderbufferStorage, (GLenum, GLenum, GLsizei, GLsizei))           \
  X(void, glFramebufferRenderbuffer, (GLenum, GLenum, GLenum, GLuint))

#define PLOT_GL_DECL(ret, name, args) extern ret (*name) args;
PLOT_GL_FUNCS(PLOT_GL_DECL)
#undef PLOT_GL_DECL

}  // namespace plot::gl

include/plot/gl/loader.hpp:

#pragma once
#include <plot/core/status.hpp>
namespace plot::gl {
using ProcLoader = void* (*)(const char*);
/// Resolves every PLOT_GL_FUNCS entry. loader == nullptr selects the platform
/// default: glXGetProcAddressARB / eglGetProcAddress / wglGetProcAddress, then
/// dlsym / GetProcAddress on the system GL library. Returns false if any
/// required entry point is missing.
bool load(ProcLoader loader = nullptr);
}  // namespace plot::gl

src/gl/loader.cpp resolves each name through the supplied loader, falling back to dlsym(RTLD_DEFAULT, name) and then dlopen("libGL.so.1") + dlsym. Report the first missing symbol via PLOT_ASSERT in debug and return false.

include/plot/gl/caps.hpp:

#pragma once
#include <plot/gl/api.hpp>
namespace plot::gl {
struct Caps {
  int major = 0, minor = 0;
  const char* renderer = "";
  const char* vendor = "";
  bool compute = false;          // GL 4.3+
  bool buffer_storage = false;   // GL 4.4+ / ARB_buffer_storage
  bool persistent_map = false;   // 4.4+ or ARB_buffer_storage
  bool core_profile = false;
};
const Caps& caps();              // valid after load(); queries the current context once
}  // namespace plot::gl

include/plot/gl/handle.hpp + src/gl/handle.cpp: move-only RAII wrappers. Buffer with void upload(const void*, usize bytes, GLenum usage), void upload_sub(const void*, usize bytes, usize offset); VertexArray with void attrib(u32 index, i32 components, usize stride, usize offset) using a GL_FLOAT type; Program with static Program from_source(const char* vs, const char* fs, Status*) and void set_uniform(const char* name, f32) / (const char*, Vec2); Texture, Framebuffer (void attach_color(Texture&), bool complete()), Fence. All destructors are no-ops when id() == 0, so a Renderer destroyed after context loss is safe.

tests/egl_headless.cpp — surfaceless EGL, no window:

// 1. eglGetPlatformDisplayEXT(EGL_PLATFORM_SURFACELESS_MESA, EGL_DEFAULT_DISPLAY, nullptr)
//    with fallback to eglGetDisplay(EGL_DEFAULT_DISPLAY).
// 2. eglInitialize, eglBindAPI(EGL_OPENGL_API).
// 3. choose a config with EGL_SURFACE_TYPE = EGL_PBUFFER_BIT, EGL_RENDERABLE_TYPE = EGL_OPENGL_BIT,
//    EGL_RED/GREEN/BLUE/ALPHA_SIZE >= 8, EGL_DEPTH_SIZE = 0.
// 4. eglCreateContext(cfg, EGL_NO_CONTEXT, {EGL_CONTEXT_MAJOR_VERSION, 3,
//    EGL_CONTEXT_MINOR_VERSION, 3, EGL_CONTEXT_OPENGL_PROFILE_MASK,
//    EGL_CONTEXT_OPENGL_CORE_PROFILE_BIT_KHR, EGL_NONE}).
// 5. eglMakeCurrent(dpy, EGL_NO_SURFACE, EGL_NO_SURFACE, ctx)  // requires EGL_KHR_surfaceless_context
// 6. create a color texture + framebuffer of (w,h), bind it.
// read_pixel() binds the FBO, glReadPixels(x, y, 1, 1, ...), returns true on success.

Link tests against EGL via find_package(EGL REQUIRED) or pkg-config --libs egl; if either is missing, PLOT_BUILD_TESTS MUST fail configuration with a clear message rather than silently skipping the GL tests.

  • Step 4: Run test to verify it passes

Run: cmake --build build/debug && ctest --test-dir build/debug --output-on-failure Expected: 4 tests pass. headless_context_provides_gl33 prints [ ok ]. If EGL surfaceless fails here, stop and switch egl_headless.cpp to the GLX hidden-window fallback before proceeding — Tasks 7–11 depend on this.

Quick manual confirmation of the GPU path:

./build/debug/tests/plot_tests  # includes gl_context
  • Step 5: Commit
git add -A && git commit -m "feat(gl): dependency-free GL loader, RAII handles, caps probe, headless EGL harness"

Task 3: Data layer — ring buffer, min/max pyramid, series

Files:

  • Create: include/plot/data/{ring_buffer,minmax_pyramid,series,array_series,stream_series}.hpp
  • Create: src/data/{minmax_pyramid,array_series,stream_series}.cpp
  • Create: tests/test_ring_buffer.cpp, tests/test_minmax_pyramid.cpp, tests/test_series.cpp
  • Modify: CMakeLists.txt, tests/CMakeLists.txt

Interfaces:

  • Consumes: plot::Range, plot::f32/f64/u64, plot::kInvalid.

  • Produces: plot::data::RingBuffer, plot::data::MinMaxPyramid, plot::data::Series, plot::data::ArraySeries, plot::data::StreamSeries exactly as declared in spec §6, plus plot::data::MinMax{min,max} and plot::data::kBucket == 64. Exactness split (do not get this wrong): a min/max pyramid stores one aggregate per bucket, so it can only answer exactly for whole buckets. Exact range queries therefore live on Series — reduce, sum, sumsq, non-virtual, implemented once in src/data/series.cpp over the protected copy_raw hook: the bucket-aligned interior comes from the pyramid, and at most two partial edge buckets are copied through copy_raw and scanned. MinMaxPyramid exposes only the bucket-exact reduce_buckets/sum_buckets/sumsq_buckets that back them. Task 5 measurements and auto-scale consume Series::reduce/sum/sumsq and never the *_buckets forms directly.

  • Step 1: Write the failing tests

tests/test_minmax_pyramid.cpp — the pyramid is the performance core, so it gets brute-force cross-checks:

#include "harness.hpp"
#include <plot/data/minmax_pyramid.hpp>
#include <random>

using plot::data::kBucket;
using plot::data::MinMaxPyramid;
using plot::Range;

PLOT_TEST(pyramid_bucket_extremes_preserved) {
  MinMaxPyramid p;
  p.reserve(1000);
  for (int i = 0; i < 1000; ++i) p.append(i == 617 ? -50.0f : (i == 42 ? 900.0f : 1.0f));
  PLOT_CHECK(p.size() == 1000);
  const Range r = p.reduce_buckets(0, 960);        // exactly 15 whole buckets
  PLOT_CHECK_NEAR(r.min, -50.0, 1e-6);
  PLOT_CHECK_NEAR(r.max, 900.0, 1e-6);
}

PLOT_TEST(pyramid_reduce_buckets_matches_bruteforce_on_aligned_ranges) {
  std::mt19937 rng(1234);
  std::uniform_real_distribution<float> d(-100.0f, 100.0f);
  std::vector<float> v(5000);
  MinMaxPyramid p;
  for (auto& x : v) { x = d(rng); p.append(x); }
  for (u64 a = 0; a < 5000; a += kBucket * 3) {
    for (u64 b = a + kBucket; b <= 5000; b += kBucket * 7) {
      const u64 ahi = (b / kBucket) * kBucket;     // whole buckets only
      if (ahi <= a) continue;
      float lo = v[a], hi = v[a];
      for (u64 i = a; i < ahi; ++i) { lo = std::min(lo, v[i]); hi = std::max(hi, v[i]); }
      const Range r = p.reduce_buckets(a, ahi);
      PLOT_CHECK_NEAR(r.min, lo, 1e-4);
      PLOT_CHECK_NEAR(r.max, hi, 1e-4);
    }
  }
}

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

PLOT_TEST(pyramid_level_selection) {
  MinMaxPyramid p;
  p.reserve(1u << 20);
  for (u64 i = 0; i < (1u << 20); ++i) p.append(0.0f);
  // <= kBucket samples per pixel must use level 0 (raw path handles it anyway).
  PLOT_CHECK(p.level_for(1u << 20, 1.0) == 0);
  // 512 samples per pixel with kBucket == 64 → bucket size 512 → level 3.
  PLOT_CHECK(p.level_for(1u << 20, 512.0) == 3);
  // Level selection must never exceed the deepest level that has data.
  PLOT_CHECK(p.level_for(1000, 1e9) <= p.level_count() - 1);
}

PLOT_TEST(pyramid_snap_returns_nearest_sample) {
  MinMaxPyramid p;
  for (int i = 0; i < 100; ++i) p.append(float(i));
  PLOT_CHECK(p.snap(10.4, 0.0, 1.0) == 10);
  PLOT_CHECK(p.snap(10.6, 0.0, 1.0) == 11);
  PLOT_CHECK(p.snap(-5.0, 0.0, 1.0) == 0);      // clamped
  PLOT_CHECK(p.snap(1e9, 0.0, 1.0) == 99);      // clamped
}

PLOT_TEST(pyramid_sums_match_bruteforce_on_aligned_ranges) {
  std::mt19937 rng(99);
  std::uniform_real_distribution<float> d(-3.0f, 7.0f);
  std::vector<float> v(3000);
  MinMaxPyramid p;
  for (auto& x : v) { x = d(rng); p.append(x); }
  for (u64 a = 0; a < 3000; a += kBucket * 5) {
    for (u64 b = a + kBucket; b <= 3000; b += kBucket * 9) {
      double s = 0.0, sq = 0.0;
      for (u64 i = a; i < b; ++i) { s += v[i]; sq += double(v[i]) * double(v[i]); }
      PLOT_CHECK_NEAR(p.sum_buckets(a, b), s, 1e-3);
      PLOT_CHECK_NEAR(p.sumsq_buckets(a, b), sq, 1e-2);
    }
  }
}

PLOT_TEST(pyramid_clear_and_rebuild) {
  MinMaxPyramid p;
  for (int i = 0; i < 500; ++i) p.append(1.0f);
  p.clear();
  PLOT_CHECK(p.size() == 0);
  PLOT_CHECK(p.level_count() <= 1);
  p.append(7.0f);
  PLOT_CHECK(p.size() == 1);
  for (int i = 1; i < int(kBucket); ++i) p.append(1.0f);   // complete bucket 0
  PLOT_CHECK_NEAR(p.reduce_buckets(0, kBucket).max, 7.0, 1e-6);
  PLOT_CHECK_NEAR(p.reduce_buckets(0, kBucket).min, 1.0, 1e-6);
}

tests/test_ring_buffer.cpp:

PLOT_TEST(ring_wraps_and_keeps_latest) {
  plot::data::RingBuffer<f32> r(8);        // capacity rounded up to a power of two
  for (int i = 0; i < 20; ++i) r.push(f32(i));
  PLOT_CHECK(r.size() == 8);
  PLOT_CHECK_NEAR(r[0], 12.0);             // oldest retained
  PLOT_CHECK_NEAR(r[7], 19.0);             // newest
}
PLOT_TEST(ring_clear_resets) {
  plot::data::RingBuffer<f32> r(8);
  for (int i = 0; i < 5; ++i) r.push(f32(i));
  r.clear();
  PLOT_CHECK(r.size() == 0);
  r.push(42.0f);
  PLOT_CHECK(r.size() == 1);
  PLOT_CHECK_NEAR(r[0], 42.0);
}

tests/test_series.cpp:

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

PLOT_TEST(array_series_bounds_and_gather) {
  std::vector<f32> y{1, 5, -2, 3};
  plot::data::ArraySeries s(std::move(y), /*x0=*/10.0, /*dx=*/0.5);
  PLOT_CHECK(s.size() == 4);
  PLOT_CHECK_NEAR(s.x_bounds().min, 10.0, 1e-9);
  PLOT_CHECK_NEAR(s.x_bounds().max, 11.5, 1e-9);
  PLOT_CHECK_NEAR(s.y_bounds().min, -2.0, 1e-6);
  std::vector<f32> out;
  s.gather(0, 0, 4, out);
  PLOT_CHECK(out.size() == 8);             // interleaved x,y
  PLOT_CHECK_NEAR(out[0], 10.0, 1e-6);
  PLOT_CHECK_NEAR(out[1], 1.0, 1e-6);
  PLOT_CHECK_NEAR(out[6], 11.5, 1e-6);
}

PLOT_TEST(stream_series_acquire_publishes_snapshot) {
  plot::data::StreamSeries s(1 << 12, /*x0=*/0.0, /*dx=*/1e-6);
  for (int i = 0; i < 100; ++i) s.push(f32(i));
  PLOT_CHECK(s.produced() == 100);
  s.acquire();
  PLOT_CHECK(s.size() == 100);
  // Pyramid must track the published samples.
  PLOT_CHECK_NEAR(s.reduce(0, 100).max, 99.0, 1e-4);
  for (int i = 100; i < 250; ++i) s.push(f32(i));
  s.acquire();
  PLOT_CHECK(s.size() == 250);
}

PLOT_TEST(stream_series_drops_oldest_when_full) {
  plot::data::StreamSeries s(256, 0.0, 1.0);
  for (int i = 0; i < 1000; ++i) s.push(f32(i));
  s.acquire();
  PLOT_CHECK(s.size() <= 256);
  PLOT_CHECK_NEAR(s.y_bounds().max, 999.0, 1e-4);
}

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

PLOT_TEST(stream_series_exact_queries_survive_ring_wrap) {
  plot::data::StreamSeries s(1u << 10, 0.0, 1.0);
  for (int i = 0; i < 3000; ++i) s.push(f32(i % 97));   // wraps the ring twice
  s.acquire();
  const u64 n = s.size();
  const plot::Range r = s.reduce(0, n);                 // retained window, wrapped
  PLOT_CHECK_NEAR(r.max, 96.0, 1e-4);
  PLOT_CHECK_NEAR(r.min, 0.0, 1e-4);
}
  • Step 2: Run tests to verify they fail

Run: cmake --build build/debug --target plot_tests Expected: FAIL to compile — plot/data/minmax_pyramid.hpp not found.

  • Step 3: Write the implementation

include/plot/data/minmax_pyramid.hpp:

#pragma once
#include <plot/core/types.hpp>
#include <span>
#include <vector>

namespace plot::data {
struct MinMax { f32 min = 0.0f; f32 max = 0.0f; };
inline constexpr u32 kBucket = 64;

class MinMaxPyramid {
 public:
  void reserve(u64 n);
  void append(f32 v);
  void drop_before(u64 absolute_sample);   // slide the retained window to an absolute sample
  void clear();
  u64 size() const noexcept;               // retained-window samples
  u64 window_base() const noexcept;        // absolute index of window index 0
  u64 window_end() const noexcept;         // absolute index one past the last append
  u32 level_count() const noexcept;
  u64 level_base(u32 l) const noexcept;    // absolute level-l bucket index of entry 0
  u64 level_size(u32 l) const noexcept;
  MinMax bucket(u32 l, u64 i) const;       // absolute level-l bucket i
  // Whole-bucket only: [begin, end) is intersected with whole level-0 buckets and
  // partial edges are excluded, never approximated. An empty result has span()==0.
  // Window coordinates: the grid is absolute, so the intersection runs through
  // window_base().
  Range reduce_buckets(u64 begin, u64 end) const;
  f64 sum_buckets(u64 begin, u64 end) const;
  f64 sumsq_buckets(u64 begin, u64 end) const;
  u32 level_for(u64 n, f64 samples_per_pixel) const;
  u64 snap(f64 x, f64 x0, f64 dx) const;

 private:
  // One bucket: its extremes plus its own exact sum and sum of squares. The two
  // doubles take a bucket from 8 bytes to 24 -- level 0 costs 0.375n bytes, the
  // whole pyramid ≈0.75n, ≈19% of 4-byte raw samples -- and are what let
  // mean/RMS/integral come from whole buckets. Absolute prefix sums cannot: they
  // grow without bound and cannot survive a sliding front.
  struct Entry { MinMax mm; f64 sum = 0.0; f64 sumsq = 0.0; };
  struct Level { std::deque<Entry> entries; u64 base = 0; };  // absolute bucket space
  u64 count_ = 0;                          // samples ever appended
  u64 base_ = 0;                           // absolute index of the window's oldest sample
  u32 live_levels_ = 1;
  std::vector<Level> levels_{1};
  Entry pending_;                          // the in-progress level-0 bucket
};
}  // namespace plot::data

Implementation rules for src/data/minmax_pyramid.cpp:

  • append(v): fold the sample into pending_, the in-progress level-0 bucket; when that bucket completes, push it as an entry and merge upward while kBucket << l divides the sample count, stopping at the first level that gained no entry. Amortised O(1) -- 1/kBucket + 1/(2·kBucket) + … = 2/kBucket merges per sample, independent of the number of live levels -- and no entry is ever rewritten, which is what lets a window slide over the pyramid instead of forcing a rebuild.
  • drop_before(u): set the window base and discard, per level, the leading entries whose span starts before u; level 0 also drops its in-progress bucket. Memory then tracks the window, not the samples ever pushed.
  • reduce_buckets(begin, end): if (end <= begin) return {0,0}; intersect [begin, end) with whole level-0 buckets, then cover the result by descending to the coarsest level whose buckets lie entirely inside it, finishing the remainder at level 0. It must NEVER scan raw samples — it stores none — and a partial edge bucket is excluded, not approximated. sum_buckets/sumsq_buckets use the same decomposition, folding each covered entry's own sum/sumsq.
  • Series::reduce/sum/sumsq in src/data/series.cpp are the exact entry points every other module uses: they take the bucket-aligned interior from reduce_buckets/sum_buckets/sumsq_buckets and scan the at-most-two partial edge buckets obtained via copy_raw (a stack buffer of at most 2*kBucket floats, so no allocation on the query path).
  • level_for(n, spp): if (spp <= f64(kBucket)) return 0; else u32 l = u32(std::floor(std::log2(spp / f64(kBucket)))); clamp to level_count() - 1, and additionally clamp so that level_size(l) >= 2.
  • snap(x, x0, dx): if (dx <= 0.0 || size_ == 0) return 0; compute i = llround((x - x0) / dx) and clamp to [0, size_ - 1].

include/plot/data/ring_buffer.hpp: RingBuffer<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 "level 0 emits the raw samples of [begin, end); level >= 1 emits one (min, max) envelope pair per complete retained bucket, bracketed by the partial edge buckets drawn as raw samples", so the geometry spans the requested range exactly without the pyramid storing a partial bucket. reduce/sum/sumsq are non-virtual, implemented once in src/data/series.cpp over the protected pure virtual copy_raw(u64 begin, u64 end, f32* out) const, which copies end - begin raw y values and handles any wraparound.

ArraySeries: holds std::vector<f32> y_, f64 x0_, f64 dx_, a MinMaxPyramid, and cached Range y_bounds_/Range x_bounds_. Constructor and reset() fill the pyramid and bounds; commit() rebuilds them (for when the caller mutated the vector out of band — document that the vector is owned and mutated only via reset). copy_raw is a bounds-checked std::copy over y_.

StreamSeries: RingBuffer<f32> of rounded-up capacity, atomic produced_, f64 x0_, f64 dx_; push is the only producer entry point and MUST NOT touch the pyramid; acquire() reads produced_ once, grows the pyramid by the newly produced samples, and updates bounds. x_bounds() derives from the oldest retained index and dx_, so a wrap shifts the window forward. copy_raw must split the copy at the ring's wrap point; reduce/sum/sumsq are documented as operating on the retained window, so index 0 is the oldest sample still held.

Add test_ring_buffer.cpp, test_minmax_pyramid.cpp and test_series.cpp to the existing plot_tests target's source list in tests/CMakeLists.txt. Do not add new add_test entries — one add_test(NAME plot_tests ...) runs every registered case.

  • Step 4: Run tests to verify they pass

Run: cmake --build build/debug && ctest --test-dir build/debug --output-on-failure Expected: pyramid_reduce_matches_bruteforce (the 1300-odd range comparisons) passes, along with all series and ring tests. 0 failed.

  • Step 5: Commit
git add -A && git commit -m "feat(data): SPSC ring buffer, incremental min/max LOD pyramid, series types"

Task 4: Render layer — programs, buffers, batching, bitmap text

Files:

  • Create: include/plot/render/{style,text,bitmap_font,program,buffer,vertex_array,primitives}.hpp
  • Create: src/render/{program,buffer,vertex_array,bitmap_font,primitives}.cpp
  • Create: src/render/shaders/*.glsl (embedded at build time via a generated header, or as raw string literals in .cpp — pick the raw-string approach to avoid a build step)
  • Create: tests/test_primitives.cpp

Interfaces:

  • Consumes: plot::gl::{Buffer,VertexArray,Program}, plot::gl::caps(), plot::Color, plot::Rect, plot::Transform.

  • Produces: plot::render::Style, plot::render::TextBackend (abstract), plot::render::BitmapFont, plot::render::LineBatch, plot::render::TextBatch, each with begin()/flush() semantics, and the GLSL sources as constexpr const char* kLineVS/kLineFS/kTextVS/kTextFS.

  • Step 1: Write the failing test

tests/test_primitives.cpp (CPU-side geometry, no GL needed):

#include "harness.hpp"
#include <plot/render/primitives.hpp>

PLOT_TEST(line_batch_emits_two_verts_per_segment) {
  plot::render::LineBatch b;
  b.begin();
  const plot::Vec2 pts[3] = {{0, 0}, {1, 1}, {2, 0}};
  b.add_polyline(pts, 3);                 // 2 segments
  PLOT_CHECK(b.vertex_count() == 4);
  b.clear();
  PLOT_CHECK(b.vertex_count() == 0);
  PLOT_CHECK(b.data().capacity() >= 4);   // clear() must not free capacity
  b.add_segment({0, 0}, {1, 1});
  PLOT_CHECK(b.vertex_count() == 2);
}

PLOT_TEST(text_batch_measures_ascii) {
  plot::render::BitmapFont f;
  const f32 w = f.measure("12.5 ms");
  PLOT_CHECK(w > 0.0f);
  PLOT_CHECK_NEAR(f.measure(""), 0.0, 1e-6);
  PLOT_CHECK(f.measure("ab") < f.measure("abc"));   // monotonic in length
  PLOT_CHECK(f.measure("iiii") < f.measure("WWWW")); // proportional-ish, not fixed-width-blank
}
  • Step 2: Run test to verify it fails

Run: cmake --build build/debug --target plot_tests Expected: FAIL to compile — plot/render/primitives.hpp not found.

  • Step 3: Write the implementation

include/plot/render/style.hpp:

#pragma once
#include <plot/core/color.hpp>
namespace plot::render {
struct Style {
  Color background   = Color::hex(0x101418);
  Color grid_major   = Color::hex(0x2A3038);
  Color grid_minor   = Color::hex(0x1C2126);
  Color axis_line    = Color::hex(0x8A94A6);
  Color axis_text    = Color::hex(0xC8D0DC);
  Color cursor       = Color::hex(0xFFB020);
  Color measure_fill = Color::hex(0xFFB020, 0.15f);
  f32 axis_font_px   = 12.0f;
  f32 grid_label_pad = 4.0f;
  f32 line_width_px  = 1.5f;
  f32 dense_threshold_spp = 1.5f;   // spec §4.3
  f32 tick_min_px    = 60.0f;       // minimum spacing between major ticks
};
}  // namespace plot::render

include/plot/render/text.hpp: class TextBackend { public: virtual ~TextBackend(); virtual f32 measure(const char*) const = 0; virtual f32 height() const = 0; virtual f32 ascent() const = 0; }.

include/plot/render/bitmap_font.hpp: class BitmapFont final : public TextBackend — owns a 95-glyph (ASCII 32..126) 8×16 1-bit atlas as a static constexpr u8 kAtlas[95 * 16] table in the .cpp, plus f32 measure, f32 advance(char), and u32 glyph_rows(char) returning the 16 rows. measure("") == 0.

include/plot/render/primitives.hpp:

#pragma once
#include <plot/core/color.hpp>
#include <plot/math/vec2.hpp>
#include <vector>

namespace plot::render {
/// CPU-side staging for one instanced-AA line draw call.
class LineBatch {
 public:
  void begin();
  void clear();
  void add_segment(Vec2 a, Vec2 b);              // one instance
  void add_polyline(const Vec2* pts, u32 n);     // n-1 instances
  u32 vertex_count() const noexcept;             // 2 vertices per staged segment
  const std::vector<f32>& data() const noexcept; // interleaved x0,y0,x1,y1
};
}  // namespace plot::render

src/render/primitives.cpp implements the staging buffers with reserve/resize reuse — clear() MUST NOT free capacity, and begin() MUST NOT reallocate when the frame's workload matches the previous frame.

GLSL sources live as raw string literals in src/render/program.cpp:

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

Both shaders declare #version 330 core. The 4.3+ fast path in Task 7 changes only the buffer upload strategy, never the shader source, so these stay valid on both paths.

include/plot/render/program.hpp / buffer.hpp / vertex_array.hpp are thin owning wrappers over plot::gl::{Program,Buffer,VertexArray} that know the attribute layouts for the line and text pipelines (offsetof-based, no magic numbers in callers).

  • Step 4: Run test to verify it passes

Run: cmake --build build/debug && ctest --test-dir build/debug --output-on-failure Expected: line_batch_emits_two_verts_per_segment, text_batch_measures_ascii [ ok ], 0 failed.

Then confirm the shaders compile on the real driver (this catches GLSL syntax errors that no CPU test can). Append this permanent test to tests/test_gl_context.cpp:

PLOT_TEST(shaders_compile_and_link) {
  plottest::HeadlessContext ctx; plot::Status st;
  PLOT_CHECK(ctx.create(&st));
  PLOT_CHECK(plot::gl::load(nullptr));
  plot::Status s0, s1;
  plot::gl::Program line = plot::gl::Program::from_source(kLineVS, kLineFS, &s0);
  plot::gl::Program text = plot::gl::Program::from_source(kTextVS, kTextFS, &s1);
  PLOT_CHECK(bool(s0)); PLOT_CHECK(bool(s1));
  PLOT_CHECK(line.id() != 0); PLOT_CHECK(text.id() != 0);
  ctx.destroy();
}
  • Step 5: Commit
git add -A && git commit -m "feat(render): style, GLSL line/text pipelines, staging batches, bitmap font"

Task 5: Plot layer — ticks, axes, grid, trace, cursor, measurement

Files:

  • Create: include/plot/plot/{ticks,axis,grid,trace,cursor,measure,annotation,plot}.hpp
  • Create: src/plot/{ticks,axis,grid,plot,measure}.cpp
  • Create: tests/test_ticks.cpp, tests/test_measure.cpp

Interfaces:

  • Consumes: plot::data::{Series,MinMaxPyramid}, plot::Range, plot::Transform, plot::render::{Style,TextBackend}.

  • Produces: plot::Tick{ f64 value; bool major; std::string label; }, plot::generate_ticks(Range, f32 px, f32 min_px, std::vector<Tick>&), plot::format_si(f64, char* buf, usize n), plot::format_time(f64 seconds, char*, usize), plot::AxisPlace, plot::Axis, plot::Grid, plot::Trace, plot::Cursor, plot::Measurement{ dx, dy, slope, rms, mean, ymin, ymax, integral, valid }, plot::Measure::evaluate(const data::Series&, u64 begin, u64 end) -> Measurement, and plot::Plot with layout(), trace_count(), measurement(u32).

  • Step 1: Write the failing tests

tests/test_ticks.cpp:

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

PLOT_TEST(nice_ticks_are_powers_of_ten_times_125) {
  std::vector<plot::Tick> t;
  plot::generate_ticks({0.0, 1.0}, /*px=*/500.0f, /*min_px=*/60.0f, t);
  PLOT_CHECK(!t.empty());
  for (auto& tick : t) PLOT_CHECK(std::fabs(tick.value * 5.0 - std::round(tick.value * 5.0)) < 1e-9);
  // spacing of major ticks >= min_px
  PLOT_CHECK(t.size() <= 500.0 / 60.0 + 2.0);
}
PLOT_TEST(ticks_are_monotonic_and_cover_range) {
  std::vector<plot::Tick> t;
  plot::generate_ticks({-3.7, 91.2}, 800.0f, 60.0f, t);
  for (size_t i = 1; i < t.size(); ++i) PLOT_CHECK(t[i].value > t[i-1].value);
  PLOT_CHECK(t.front().value <= 0.0);
  PLOT_CHECK(t.back().value >= 90.0);
}
PLOT_TEST(si_formatting) {
  char b[32];
  plot::format_si(0.0000015, b, sizeof b); PLOT_CHECK(std::string(b) == "1.5\xC2\xB5s" || std::string(b).find("1.5") == 0);
  plot::format_si(2.5e9, b, sizeof b);     PLOT_CHECK(std::string(b).find("2.5") == 0);
  plot::format_si(1234.0, b, sizeof b);    PLOT_CHECK(std::string(b).find("1.23") == 0);
  plot::format_si(0.0, b, sizeof b);       PLOT_CHECK(std::string(b) == "0");
}
PLOT_TEST(degenerate_ranges_do_not_hang) {
  std::vector<plot::Tick> t;
  plot::generate_ticks({5.0, 5.0}, 500.0f, 60.0f, t);   // must not loop forever
  PLOT_CHECK(t.size() <= 64);
  plot::generate_ticks({0.0, 0.0}, 0.0f, 60.0f, t);
  plot::generate_ticks({1e300, 1e300}, 500.0f, 60.0f, t);
}

tests/test_measure.cpp:

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

PLOT_TEST(measurement_over_interval_matches_direct_computation) {
  std::vector<f32> y; for (int i = 0; i < 1000; ++i) y.push_back(f32(std::sin(i * 0.01)));
  plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
  const auto m = plot::Measure::evaluate(s, 100, 800);
  double sum = 0, sumsq = 0, integral = 0; float lo = 1e30f, hi = -1e30f;
  for (u64 i = 100; i < 800; ++i) {
    const double v = std::sin(i * 0.01);
    sum += v; sumsq += v * v; integral += v;         // dx == 1
    lo = std::min(lo, float(v)); hi = std::max(hi, float(v));
  }
  const double n = 700;
  PLOT_CHECK_NEAR(m.mean, sum / n, 1e-5);
  PLOT_CHECK_NEAR(m.rms, std::sqrt(sumsq / n), 1e-5);
  PLOT_CHECK_NEAR(m.integral, integral, 1e-3);
  PLOT_CHECK_NEAR(m.ymin, lo, 1e-6);
  PLOT_CHECK_NEAR(m.ymax, hi, 1e-6);
  PLOT_CHECK_NEAR(m.dx, 699.0, 1e-9);
}
PLOT_TEST(measurement_uses_pyramid_not_scan) {
  // Guard against a future regression that linear-scans the series: a huge
  // series must evaluate a 1-sample-wide interval in well under a millisecond.
  std::vector<f32> y(1u << 22, 1.0f);              // 4M samples
  plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
  const auto t0 = std::chrono::steady_clock::now();
  for (int i = 0; i < 1000; ++i) (void)plot::Measure::evaluate(s, 1'000'000, 1'000'001);
  const auto us = std::chrono::duration_cast<std::chrono::microseconds>(
      std::chrono::steady_clock::now() - t0).count();
  PLOT_CHECK(us < 50'000);                          // 1000 evals in < 50 ms
}
PLOT_TEST(empty_and_single_sample_intervals_are_valid_false) {
  std::vector<f32> y{3.0f};
  plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
  PLOT_CHECK(!plot::Measure::evaluate(s, 0, 0).valid);
  PLOT_CHECK(!plot::Measure::evaluate(s, 1, 1).valid);
}
  • Step 2: Run tests to verify they fail

Run: cmake --build build/debug --target plot_tests Expected: FAIL to compile — plot/plot/ticks.hpp not found.

  • Step 3: Write the implementation

src/plot/ticks.cpp — the nice-number algorithm:

// generate_ticks(range, px, min_px, out, bool include_minors = false):
//   if (!(range.span() > 0) || px <= 0 || !isfinite(span) || min > max) { out.clear(); return; }
//   const f64 target_count = std::max(1.0, f64(px) / f64(min_px));
//   const f64 raw_step = range.span() / target_count;
//   const f64 mag  = std::pow(10.0, std::floor(std::log10(raw_step)));
//   const f64 norm = raw_step / mag;                       // in [1, 10)
//   // ROUND UP, not to nearest. Rounding to nearest can exceed target_count and
//   // break the "at most px/min_px + 2 ticks" bound that the tests assert.
//   const f64 step = mag * (norm <= 1.0 ? 1.0 : norm <= 2.0 ? 2.0 : norm <= 5.0 ? 5.0 : 10.0);
//   f64 first = std::ceil(range.min / step) * step;
//   for (f64 v = first; v <= range.max + step * 1e-9; v += step) out.push_back({v, true, {}});
//   // include_minors defaults to false because minors share this same vector: a
//   // caller that wants only majors must not have to filter, and the "every value
//   // is a multiple of the major step" assertions only hold for the majors-only
//   // form. Grid rendering passes true.
//   // minor ticks: step/5 (step/4 for a step beginning with 2), same loop.
//   // hard guard: 256 entries maximum INCLUDING minors.

Labels are assigned by format_si unless the axis is a time axis, in which case format_time is used. Ticks whose label would be non-finite or absurdly long (> 12 chars) fall back to %.3g.

src/plot/measure.cpp:

Measurement Measure::evaluate(const data::Series& s, u64 begin, u64 end) {
  Measurement r{};
  if (end <= begin || end > s.size()) return r;
  // Every quantity comes from Series, never from the pyramid's *_buckets forms:
  //   const Range r = s.reduce(begin, end);
  //   const f64 total = s.sum(begin, end), sq = s.sumsq(begin, end);
  // Each is O(kBucket) worst case: bucket-aligned interior from the pyramid,
  // plus at most two partial edge buckets read through copy_raw().
  // dx = (end - 1 - begin) * dx_of_series;
  // slope = (y[end-1] - y[begin]) / dx  (0 when dx == 0);
  // dy   = y[end-1] - y[begin];
  // valid = true.
  return r;
}

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

include/plot/plot/plot.hpp — the public façade, exactly the API in spec §6 plus:

struct PlotLayout {
  Rect axes;                       // plot area: excludes tick labels AND titles
  Rect y_label_area, x_label_area; // one text line each, for tick labels
  Rect y_title_area, x_title_area; // one text line each, for axis titles
  f32  y_axis_width   = 56.0f;
  f32  x_axis_height  = 22.0f;
  f32  y_title_width  = 0.0f;      // set from the title's measured extent
  f32  x_title_height = 0.0f;
};

layout() reserves one text line for tick labels and a second for the axis title on each occupied edge, and insets axes by all of them. A title drawn inside axes sits on top of the data and is a defect — the reserved strips exist to make that impossible rather than merely unlikely. An axis with no title reserves nothing.

Plot owns vectors of axes/traces/cursors/measures, computes PlotLayout from viewport() and the text backend metrics, and recomputes measurement results for every measure whose cursors moved (dirty flag, not every frame).

Grid produces tick-aligned line segments in data space; Trace binds a Series to a color/width/axis pair; Cursor carries f64 x, SnapMode { Free, NearestSample }, color and an optional label; Annotation is a pinned point with text.

  • Step 4: Run tests to verify they pass

Run: cmake --build build/debug && ctest --test-dir build/debug --output-on-failure Expected: all tick and measure tests [ ok ], including measurement_uses_pyramid_not_scan. 0 failed.

  • Step 5: Commit
git add -A && git commit -m "feat(plot): nice-number ticks, axes, grid, traces, cursors, measurement queries"

Task 6: Input layer — events and controller

Files:

  • Create: include/plot/input/{events,controller}.hpp, src/input/controller.cpp
  • Create: tests/test_controller.cpp

Interfaces:

  • Consumes: plot::{Plot,Rect,Range,Transform}.

  • Produces: plot::input::{Button,Mod,PointerEvent,WheelEvent,KeyEvent,DragMode,Controller} exactly as in spec §6.

  • Step 1: Write the failing test

PLOT_TEST(wheel_zoom_keeps_data_under_cursor_fixed) {
  plot::Plot p; p.set_viewport({0, 0, 800, 400});
  const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, true, plot::AxisPlace::Bottom, false});
  plot::input::Controller c(p);
  c.set_viewport({0, 0, 800, 400});
  const f32 cursor_x = 200.0f;
  const f64 before = p.x_transform(ax).to_data_x(cursor_x);
  plot::input::WheelEvent w{cursor_x, 100.0f, 0.0f, 1.0f, 0};
  c.on_wheel(w);
  const f64 after = p.x_transform(ax).to_data_x(cursor_x);
  PLOT_CHECK_NEAR(before, after, 1e-6);
  PLOT_CHECK(plot::Range{p.x_range(ax)}.span() < 100.0);   // zoomed in
}

PLOT_TEST(left_drag_pans_without_changing_span) {
  plot::Plot p; p.set_viewport({0, 0, 800, 400});
  const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, true, plot::AxisPlace::Bottom, false});
  const u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
  plot::input::Controller c(p); c.set_viewport({0, 0, 800, 400});
  const double span_before = p.x_range(ax).span();
  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
git add -A && git commit -m "feat(input): host-agnostic events and pan/zoom/cursor controller"

Task 7: Renderer and headless render smoke test

Files:

  • Create: include/plot/render/renderer.hpp, src/render/renderer.cpp
  • Create: tests/test_render_smoke.cpp
  • Modify: CMakeLists.txt, tests/CMakeLists.txt

Interfaces:

  • Consumes: everything below it.

  • Produces: plot::Renderer with bool init(Status*), void begin_frame(u32 fb_w, u32 fb_h, Rect viewport_px), void draw(Plot&), void end_frame(), Style& style(), bool last_frame_had_gl_error() const, and void dump_frame_ppm(const char* path) (used by the test when PLOT_DUMP_FRAMES is set). Consumes Plot::layout(), Plot::x_transform(...), Plot::x_range(...), Plot::add_trace(...) from Tasks 5 and 6.

  • Step 1: Write the failing test

PLOT_TEST(renders_visible_trace_across_viewport) {
  plottest::HeadlessContext ctx; plot::Status st;
  PLOT_CHECK(ctx.create(&st));
  PLOT_CHECK(plot::gl::load(nullptr));

  plot::Renderer r;
  PLOT_CHECK(r.init(&st));

  const u32 W = 640, H = 360;
  std::vector<f32> y(1'000'000);
  for (u64 i = 0; i < y.size(); ++i) y[i] = f32(std::sin(double(i) * 0.0001));
  plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
  plot::Plot p;
  p.set_viewport({0, 0, f32(W), f32(H)});
  const u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false});
  const u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
  const u32 tr = p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});

  r.begin_frame(W, H, {0, 0, f32(W), f32(H)});
  r.draw(p);
  r.end_frame();

  // Trace colour must appear in every one of 16 sampled columns across the
  // plot area; background must dominate the corners.
  const plot::Rect a = p.layout().axes;
  u32 hit_columns = 0;
  for (u32 c = 0; c < 16; ++c) {
    const u32 x = u32(a.x + a.w * (f64(c) + 0.5) / 16.0);
    bool hit = false;
    for (u32 yy = u32(a.y); yy < u32(a.y + a.h) && !hit; ++yy) {
      u8 px[4]; PLOT_CHECK(ctx.read_pixel(x, yy, px));
      if (px[2] > 120 && px[3] > 0 && px[0] < 160) hit = true;   // blue-ish
    }
    if (hit) ++hit_columns;
  }
  PLOT_CHECK(hit_columns >= 15);        // >= 95% of columns covered

  u8 corner[4]; PLOT_CHECK(ctx.read_pixel(2, 2, corner));
  PLOT_CHECK(corner[0] + corner[1] + corner[2] < 200);   // dark background

  PLOT_CHECK(!r.last_frame_had_gl_error());
  ctx.destroy();
}

PLOT_TEST(dense_and_sparse_paths_agree_on_extent) {
  plottest::HeadlessContext ctx; plot::Status st;
  PLOT_CHECK(ctx.create(&st));
  PLOT_CHECK(plot::gl::load(nullptr));
  plot::Renderer r; PLOT_CHECK(r.init(&st));

  const u32 W = 640, H = 360;
  std::vector<f32> y(1'000'000);
  for (u64 i = 0; i < y.size(); ++i) y[i] = f32(std::sin(double(i) * 0.0001));
  plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
  plot::Plot p;
  p.set_viewport({0, 0, f32(W), f32(H)});
  const u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false});
  const u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
  p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});

  auto covered_columns = [&](plot::Range xr) -> u32 {
    p.set_x_range(ax, xr);
    r.begin_frame(W, H, {0, 0, f32(W), f32(H)});
    r.draw(p);
    r.end_frame();
    const plot::Rect a = p.layout().axes;
    u32 cols = 0;
    for (u32 c = 0; c < 32; ++c) {
      const u32 x = u32(a.x + a.w * (f64(c) + 0.5) / 32.0);
      for (u32 yy = u32(a.y); yy < u32(a.y + a.h); ++yy) {
        u8 px[4]; PLOT_CHECK(ctx.read_pixel(x, yy, px));
        if (px[2] > 120 && px[0] < 160) { ++cols; break; }
      }
    }
    return cols;
  };

  // Dense: 1M samples over 1M x-units -> ~1562 samples/pixel -> LOD buckets.
  const u32 coarse = covered_columns({0.0, 1'000'000.0});
  // Sparse: 640 samples over 640 x-units -> 1 sample/pixel -> instanced quads.
  const u32 fine = covered_columns({0.0, 640.0});
  PLOT_CHECK(coarse >= 30);   // >= 95% of 32 columns
  PLOT_CHECK(fine >= 30);
  PLOT_CHECK(!r.last_frame_had_gl_error());
  ctx.destroy();
}
  • Step 2: Run test to verify it fails

Run: cmake --build build/debug --target plot_tests Expected: FAIL to compile — plot/render/renderer.hpp not found.

  • Step 3: Write the implementation

src/render/renderer.cpp frame sequence:

begin_frame(fb_w, fb_h, viewport_px):
  glViewport(fb_w, fb_h, viewport_px);  // origin flipped: Rect is top-left, GL is bottom-left
  clear the viewport to style.background
  reset all batches

draw(plot):
  for each axis:  compute ticks; stage grid segments; stage tick labels
  for each visible trace:
    spp = x_span_samples / plot_width_px
    if (spp < style.dense_threshold_spp)  stage raw polyline (level 0, instanced path)
    else                                  level = pyramid.level_for(n, spp);
                                          stage bucket min/max vertical strokes
    flush grids, then line batches, then text last (text is texture-bound, draw once)

end_frame():
  unbind everything; record glGetError into last_frame_had_gl_error()

Upload strategy selected once in init():

  • caps().buffer_storage && caps().persistent_map → allocate each VBO with glBufferStorage(MAP_WRITE|MAP_PERSISTENT|MAP_COHERENT), keep a persistent pointer, and triple-buffer with glFenceSync/glClientWaitSync(0) before reusing a slot.
  • otherwise → glBufferData(size, nullptr, GL_STREAM_DRAW) orphan-and-refill each frame.

Both paths go through the same upload_vertices(span) helper so no caller sees the difference.

Coordinate note to get right the first time: Rect is top-left-origin in pixels, GL is bottom-left. Convert once in begin_frame and store the flipped viewport in the renders; never flip per-primitive.

  • Step 4: Run test to verify it passes

Run: cmake --build build/debug && ctest --test-dir build/debug --output-on-failure Expected: renders_visible_trace_across_viewport [ ok ] with hit_columns >= 15, dense_and_sparse_paths_agree_on_extent [ ok ]. 0 failed.

Save one rendered frame as a PPM to build/debug/smoke.ppm from the test when PLOT_DUMP_FRAMES is set in the environment, so the render can be eyeballed without a window.

  • Step 5: Commit
git add -A && git commit -m "feat(render): Renderer with LOD selection, 3.3 and 4.3+ upload paths, headless smoke test"

Task 8: GLFW adapter and 1M-sample demo

Files:

  • Create: adapters/glfw/{CMakeLists.txt,glfw_host.hpp,glfw_host.cpp}
  • Create: examples/glfw_demo.cpp
  • Modify: CMakeLists.txt (add_subdirectory(adapters/glfw) when PLOT_BUILD_GLFW), examples/CMakeLists.txt

Interfaces:

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

  • Produces: plot::glfw::Host — owns the window/context/loop. Types:

    struct RunConfig { int width = 1280; int height = 720; const char* title = "plot"; bool vsync = true; };
    using FrameFn = std::function<void(plot::Plot&, f64 dt)>;
    bool run(const RunConfig&, const FrameFn&, plot::Status* = nullptr);
    

    Translates GLFW callbacks (glfwSetCursorPosCallback, glfwSetMouseButtonCallback, glfwSetScrollCallback, glfwSetKeyCallback, glfwSetFramebufferSizeCallback, glfwGetFramebufferSize, glfwGetWindowContentScale) into plot::input events and keeps Plot::viewport() in framebuffer pixels.

  • Step 1: Write the demo

examples/glfw_demo.cpp: creates a StreamSeries(1u << 20, 0.0, 1e-6) fed by a background thread at 1 MS/s (std::this_thread::sleep_for paced, sine + noise + a periodic spike so decimation correctness is visible), two axes with SI labels on the time axis, a grid, one trace, and two cursors with a measurement readout. An ImGui-free on-screen HUD is out of scope — the measurement is drawn as plot annotations.

The time axis label must be format_time based and the title must show live samples/second and frame time.

  • Step 2: Build and run it

Run: cmake --preset linux-release && cmake --build build/release --target plot_glfw_demo && ./build/release/examples/plot_glfw_demo Expected: a window with a scrolling sine trace, grid, labelled axes, two draggable cursors and a Δ/RMS readout. Pan, wheel-zoom, shift-box-zoom and cursor drag all work. Screenshot it.

  • Step 3: Commit
git add -A && git commit -m "feat(glfw): adapter and 1 MS/s streaming demo"

Task 9: Dear ImGui adapter and demo

Files:

  • Create: adapters/imgui/{CMakeLists.txt,imgui_plot.hpp,imgui_plot.cpp}
  • Create: examples/imgui_demo.cpp
  • Modify: CMakeLists.txt, cmake/plot_options.cmake (FetchContent fallback for ImGui when find_package fails)

Interfaces:

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

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

  • Step 1: Write the demo

examples/imgui_demo.cpp: GLFW + ImGui docking-free single window, a slider controlling sample rate, a button that pushes a spike into the StreamSeries, and a checkbox toggling the LOD statistics overlay.

  • Step 2: Build and run it

Run: cmake --build build/release --target plot_imgui_demo && ./build/release/examples/plot_imgui_demo Expected: ImGui window showing the live plot; dragging inside the child region pans only when hovering it. Screenshot it.

  • Step 3: Commit
git add -A && git commit -m "feat(imgui): embeddable plot widget and demo"

Task 10: Qt6 adapter and demo

Files:

  • Create: adapters/qt6/{CMakeLists.txt,qt_plot_widget.hpp,qt_plot_widget.cpp}
  • Create: examples/qt_demo.cpp
  • Modify: CMakeLists.txt, examples/CMakeLists.txt (Qt targets need CMAKE_AUTOMOC)

Interfaces:

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

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

    • initializeGL() → initializeOpenGLFunctions(); plot::gl::load(); renderer_.init();
    • paintGL() → begin_frame(width() * devicePixelRatioF(), height() * devicePixelRatioF(), rect); renderer_.draw(plot_); renderer_.end_frame();
    • resizeGL(int,int) → update viewport rect
    • mousePressEvent/mouseMoveEvent/mouseReleaseEvent/wheelEvent/keyPressEvent → construct plot::input events in logical pixels and pass them to the controller. The controller works in the same pixel space as Plot::viewport(), so the DPR conversion must be applied consistently in set_viewport/event translation — pick physical pixels for both, or logical for both, and document it in the header.
    • Qt6 requires QSurfaceFormat with setVersion(3,3), setProfile(CoreProfile), setSwapBehavior(DoubleBuffer) set before the widget is created; set it in main() for the demo and document that an embedding application must have done the same.
  • Step 1: Write the demo

examples/qt_demo.cpp: QApplication, a QMainWindow with a toolbar (Fit, Autoscale, Add cursor), the PlotWidget as central widget, fed by a QTimer at 60 Hz pushing samples from a producer thread.

  • Step 2: Build and run it

Run: cmake --build build/release --target plot_qt_demo && ./build/release/examples/plot_qt_demo Expected: a Qt window with the live plot; resizing reflows the grid and labels; DPR scaling on this display renders crisp, not stretched. Screenshot it.

  • Step 3: Commit
git add -A && git commit -m "feat(qt6): QOpenGLWidget-based plot widget and demo"

Task 11: GTK4 adapter and demo

Files:

  • Create: adapters/gtk4/{CMakeLists.txt,gtk_plot_area.hpp,gtk_plot_area.cpp}
  • Create: examples/gtk_demo.cpp
  • Modify: CMakeLists.txt, examples/CMakeLists.txt

Interfaces:

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

  • Produces: plot::gtk4::PlotArea — a GtkWidget subclass (C++ wrapper around a GtkGLArea created in a factory function, since GTK4 forbids deriving new GObject types in C++ without G_DEFINE_TYPE) with:

    • "realize" → gtk_gl_area_set_required_version(area, 3, 3) and gtk_gl_area_make_current, then plot::gl::load() + renderer_.init()
    • "render" → begin_frame(fb_w, fb_h, rect) … end_frame(), scaling by gtk_widget_get_scale_factor()
    • GtkGestureDrag + GtkEventControllerScroll + GtkEventControllerKey → plot::input events
    • "resize" → update the viewport rect
    • Guard against GtkGLArea with no error set (gtk_gl_area_get_error) and report ErrorCode::GLFailure instead of crashing.
  • Step 1: Write the demo

examples/gtk_demo.cpp: gtk_application_new + GtkApplicationWindow containing the plot area and a header bar with a "Fit" button; a g_timeout_add pump feeding the series.

  • Step 2: Build and run it

Run: cmake --build build/release --target plot_gtk_demo && ./build/release/examples/plot_gtk_demo Expected: a GTK4 window with the live plot; scroll-to-zoom and drag-to-pan work; window resize reflows. Screenshot it.

  • Step 3: Commit
git add -A && git commit -m "feat(gtk4): GtkGLArea-based plot area and demo"

Task 12: Install/export, umbrella header, full verification

Files:

  • Create: cmake/plotConfig.cmake.in
  • Modify: CMakeLists.txt (install rules, plot::core export set, PLOT_INSTALL option), include/plot/plot.hpp (umbrella include of every public header)

Interfaces:

  • Consumes: all previous tasks.

  • Produces: find_package(plot) → plot::core (+ plot::glfw, plot::imgui, plot::qt6, plot::gtk4 when their deps resolve), working from an installed prefix.

  • Step 1: Add install and export rules

install(TARGETS plot_core EXPORT plotTargets ...) with INCLUDES DESTINATION include; install(DIRECTORY include/plot DESTINATION include); install(EXPORT plotTargets NAMESPACE plot:: FILE plotTargets.cmake DESTINATION lib/cmake/plot); configure_package_config_file + write_basic_package_version_file for plotConfig.cmake/plotConfigVersion.cmake. Adapters export only if their dependency was found, and MUST NOT be exported with a hard dependency on a package that was only found via FetchContent — in that case skip the export and say so in the configure log.

  • Step 2: Verify the install is usable
cmake --install build/release --prefix /tmp/plot-install
mkdir -p /tmp/plot-consumer
cat > /tmp/plot-consumer/CMakeLists.txt <<'EOF'
cmake_minimum_required(VERSION 3.24)
project(consumer LANGUAGES CXX)
find_package(plot 0.1 REQUIRED)
add_executable(consumer main.cpp)
target_link_libraries(consumer PRIVATE plot::core)
EOF
cat > /tmp/plot-consumer/main.cpp <<'EOF'
#include <plot/plot.hpp>
int main() {
  plot::Plot p;
  p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
  return int(p.trace_count());
}
EOF
cmake -S /tmp/plot-consumer -B /tmp/plot-consumer/build -DCMAKE_PREFIX_PATH=/tmp/plot-install
cmake --build /tmp/plot-consumer/build
/tmp/plot-consumer/build/consumer; echo "exit=$?"

Expected: configure finds plot 0.1.0, the consumer compiles and links plot::core with no -I or -L flags of its own.

  • Step 3: Full verification pass
cmake --preset linux-debug && cmake --build build/debug && ctest --test-dir build/debug --output-on-failure
cmake --preset linux-asan  && cmake --build build/asan  && ctest --test-dir build/asan  --output-on-failure
cmake --preset linux-release && cmake --build build/release

Expected: warnings clean with -Wall -Wextra -Wpedantic -Wconversion -Wshadow; all ctest cases pass on both debug and asan; release builds all four demos.

Then launch all four demos and screenshot each: plot_glfw_demo, plot_imgui_demo, plot_qt_demo, plot_gtk_demo.

  • Step 4: Commit
git add -A && git commit -m "feat(build): install/export rules, umbrella header, verified consumer"