docs: spec and plan for vertical y label, fonts, legend, draggable areas and synced multi-plot

This commit is contained in:
Martino Ferrari committed 2026-09-28 09:32:02 +02:00
1 parent d110bfd459
commit 90e17a0a11
2 files changed
+539

No files matched your search

@@ -0,0 +1,329 @@
# `plot` interaction, fonts, legend and multi-plot — 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:** Make the y-axis label vertical, replace the 1-bit bitmap font with an anti-aliased embedded Adwaita Mono plus runtime external-font support, make cursors and areas directly draggable, add a signal legend, and add a multi-plot figure with a synchronised x axis.
**Architecture:** The text pipeline is refactored first so a `TextBackend` owns its glyph atlas and staged glyphs carry a rotation basis — that single change is what makes both the new font and the vertical label possible. Everything else is additive: a `Legend` on the plot, a `Region` primitive plus direct-manipulation drag modes on the controller, and a `Figure` that stacks plots and shares an x range through a link group. `Renderer::draw(Plot&)` stays exactly as it is.
**Tech stack:** C++20, CMake, OpenGL 3.3 core, `fontTools` (authoring-time only) for atlas generation, vendored `stb_truetype.h` for the optional runtime-font target.
**Spec:** `docs/superpowers/specs/2026-09-28-plot-interaction-and-fonts-design.md` (read it before starting any task).
## Global Constraints
- `plot_core` and every public header under `include/plot/` MUST stay free of third-party code. The narrowed rule: only the optional `plot_font_ttf` target may use the vendored `third_party/stb_truetype.h`.
- Public headers MUST be self-contained; the configure-time umbrella and self-containment checks must keep passing (a new header must be added to `plot.hpp` or configure fails).
- C++20; our targets stay clean under `-Wall -Wextra -Wpedantic -Wconversion -Wshadow`, never suppressed per-file.
- No exceptions across the public API boundary: fallible operations return `bool`/`Status` plus an out-param. Do not introduce a `Result<T>`.
- Vertex data stays in DATA space; pan/zoom is a uniform update; per-frame cost stays O(visible pixels + produced samples).
- The renderer clears only its viewport region via scissor and hands back host GL state (extend the documented residue list for anything new).
- Exact interval queries go through `Series::reduce`/`sum`/`sumsq`, never the pyramid's `*_buckets` forms.
- OpenGL 3.3 core baseline; anything above stays behind `plot::gl::caps()`.
- The 130 existing test cases keep passing. Any signature change is called out in the task report.
- Every task runs `cmake --preset linux-debug && cmake --build build/debug && ctest --test-dir build/debug --output-on-failure` and commits with the message given in its own section.
---
### Task 1: Text atlas interface and rotated glyphs
**Files:**
- Modify: `include/plot/render/text.hpp`, `include/plot/render/bitmap_font.hpp`, `src/render/bitmap_font.cpp`, `include/plot/render/primitives.hpp`, `include/plot/render/buffer.hpp`, `include/plot/render/program.hpp`, `include/plot/render/shaders.hpp`, `src/render/shaders.cpp`, `src/render/renderer.cpp`, `include/plot/gl/handle.hpp`
- Test: `tests/test_primitives.cpp`, `tests/test_render_smoke.cpp`
**Interfaces:**
- Consumes: existing `BitmapFont`, `TextBatch`, `TextProgram`, `Texture::create_r8`/`upload_r8`, `Renderer`.
- Produces: `plot::render::Glyph{u0,v0,u1,v1,xoff,yoff,advance}`; the widened `TextBackend` with `glyph(u32)`, `atlas_pixels()`, `atlas_width()`, `atlas_height()`, `revision()`; `TextBatch::add_text(const char* utf8, Vec2 pen, f32 px, TextBackend&, Color, Vec2 axis = {1,0})` and `add_glyph(..., Vec2 axis)`; the text instance as 6 floats `{x,y,u,v,ax,ay}`.
- [ ] **Step 1: Replace the backend interface and the text instance layout**
`include/plot/render/text.hpp` — add `Glyph` and widen `TextBackend` exactly as spec §1 shows. Keep `measure`/`height`/`ascent` as they are so `PlotLayout` is untouched by this step.
`include/plot/render/buffer.hpp` — `TextInstance` becomes `{f32 x, y, u, v, ax, ay;}` with `ax=1, ay=0` meaning "unrotated". Update `TextVertexArray`'s attribute layout and stride accordingly, and document the basis in the header: `(ax, ay)` is the glyph's local +x direction in **pixels**, so `(0,-1)` is a run advancing upward (a 90° CCW y label).
`src/render/shaders.cpp` — `kTextVS` builds the cell from the basis and its perpendicular instead of hardcoded `+x`/`+y`, and maps UVs consistently with the basis. Delete `kGlyphTexels`; the cell size comes from `u_px_range` and the glyph's rect comes from the instance UVs. Required UV convention: `(u0,v0)` is the glyph's top-left texel and `(u1,v1)` its bottom-right, with `v` increasing downward in the atlas.
- [ ] **Step 2: Make the renderer upload the backend's atlas and re-upload on revision change**
`src/render/renderer.cpp` — replace the hardcoded 128×56 upload with: upload `backend.atlas_pixels()` at `atlas_width() × atlas_height()` (GL_R8, `GL_UNPACK_ALIGNMENT` 1 while uploading, restored after), remember the `revision()` used, and re-upload whenever it changes. Handle a null atlas, a zero-sized atlas, and a null `glyph()` (draw the fallback box using a glyph the backend does provide, or skip if it provides none).
- [ ] **Step 3: Tests**
Commit in `tests/test_render_smoke.cpp`:
- `rotated_text_advances_along_its_basis` — a run of 6 glyphs at `axis = (0,-1)` occupies a box taller than it is wide, and the ink is *above* the pen (negative y), not to its right. Assert with the pixel scanner, and fail if the basis is ignored.
- `rotated_glyph_orientation_is_not_mirrored` — an asymmetric glyph (use `L`) rotated 90° CCW has its ink in the correct corner; compute the expected corner from the basis rather than hardcoding a pixel, so the test states its own rule.
- `atlas_is_reuploaded_when_the_backend_revision_changes` — a test double `TextBackend` with a *different* atlas size that bumps `revision()` mid-run must render the new atlas; assert the rendered result changes and that a stale atlas would not produce it.
In `tests/test_primitives.cpp`: `add_text` advances the pen by the backend's advances along the basis, and `measure` and the staged run agree in length for the same string.
- [ ] **Step 4: Verify and commit**
Run `cmake --preset linux-debug && cmake --build build/debug && ctest --test-dir build/debug --output-on-failure`; all cases pass, warning-clean, and each new test fails against the pre-change code (report the failure text).
```bash
git add -A && git commit -m "refactor(render): TextBackend owns its atlas, and staged glyphs carry a rotation basis"
```
---
### Task 2: Embedded Adwaita Mono atlas, generated at authoring time
**Files:**
- Create: `tools/fonts/gen_atlas.py`, `licences/OFL-1.1.txt`, `include/plot/render/embedded_mono_font.hpp`, `src/render/embedded_mono_font.cpp`
- Delete: `include/plot/render/bitmap_font.hpp`, `src/render/bitmap_font.cpp`
- Modify: every caller of `BitmapFont` (`src/render/renderer.cpp`, `adapters/*`, `examples/*`, `tests/*`)
**Interfaces:**
- Consumes: the widened `TextBackend` from Task 1.
- Produces: `plot::render::EmbeddedMonoFont final : public TextBackend`, dropping in wherever `BitmapFont` was; and `tools/fonts/gen_atlas.py` regenerating `src/render/embedded_mono_font.cpp` deterministically.
- [ ] **Step 1: Write the generator**
`tools/fonts/gen_atlas.py` takes the source TTF path, an em size, and an output path; rasterises the coverage set with `fontTools` (`fontTools.pens`, grayscale supersampling with a simple coverage count — do not shell out to a rasteriser); packs glyphs into a single-row atlas in codepoint order; emits a C++ translation unit containing the pixel bytes, the per-glyph rect/bearing/advance table, and a header comment with provenance (source font file name, its version string, em size, supersample factor, generator invocation, coverage list, and the licence file path).
Hard requirements:
- Assert every requested codepoint produced non-empty ink, and exit non-zero if not. A silent blank glyph must be impossible.
- Be deterministic: same input and em size must produce a byte-identical file (sort inputs, no timestamps, no dict-iteration order dependence).
- Default coverage: ASCII 32..126, U+00B0, U+00B5, U+03A9, U+2013, U+2014, plus a fallback box glyph stored at a fixed index.
- `--check` mode: regenerate into a temp file and fail if it differs from the committed one, so drift is caught in CI.
- [ ] **Step 2: Generate and commit the atlas**
Run it against `/usr/share/fonts/Adwaita/AdwaitaMono-Regular.ttf` at 13 px em with 4× supersampling. Commit the generated `.cpp`, `licences/OFL-1.1.txt` (copied verbatim from `/usr/share/licenses/adwaita-fonts/LICENSE`), and the generator. Rename `BitmapFont` to `EmbeddedMonoFont` in the same commit and migrate **every** caller — no alias, no re-export; `grep -rn "BitmapFont"` must return nothing when you are done.
- [ ] **Step 3: Tests**
- `embedded_atlas_covers_every_expected_codepoint` — every codepoint in the coverage set has a glyph whose rect is inside the atlas and whose ink is non-empty; the fallback has non-empty ink.
- `embedded_font_metrics_are_sane` — `height() > ascent() > 0`; `measure("i") < measure("W")`; `measure("") == 0`; advance is monotone in string length.
- `generator_is_deterministic_and_current` — run `gen_atlas.py --check` from the test (skip with a clear message if `fontTools` is absent, but do not silently pass), asserting the committed atlas matches its generator.
- The existing `bitmap_font_bit_order_runs_left_to_right` case is replaced by an equivalent absolute-orientation assertion on the new atlas.
- [ ] **Step 4: Verify the visual result**
Because the whole point is that the old font looked terrible, produce evidence rather than an assertion: render a frame headlessly and report a crop of the axis-label region as ASCII ink coverage, plus the mean stroke weight and the ratio of antialiased (mid-grey) to fully-covered pixels. AA text must show a substantial mid-grey population — a 1-bit font cannot, so this number distinguishes the change.
- [ ] **Step 5: Commit**
```bash
git add -A && git commit -m "feat(render): anti-aliased embedded Adwaita Mono atlas, generated and committed"
```
---
### Task 3: Vertical y-axis label
**Files:**
- Modify: `include/plot/plot/plot.hpp`, `src/plot/plot.cpp`, `src/render/renderer.cpp`
- Test: `tests/test_plot.cpp`, `tests/test_render_smoke.cpp`
**Interfaces:**
- Consumes: Task 1's rotation basis; `PlotLayout`.
- Produces: `PlotLayout::y_title_area` as a narrow vertical strip and `y_title_width` as that strip's width (one line height); the y title drawn bottom-to-top.
- [ ] **Step 1: Change the layout**
`Plot::layout()` — `y_title_area` becomes one text line **wide** (`height()` + padding) placed outside `y_label_area`, spanning the axes' vertical extent; `y_title_width` is that width. The spec's rule replaces "one row per titled y axis" with "one column per titled y axis", so a Left/Right titled pair still gets its own column and cannot collide. An axis with no title reserves nothing, as before.
- [ ] **Step 2: Rotate the title**
`draw_titles()` — stage the x title as today (axis `(1,0)`), and the y title with `axis = (0,-1)` starting from the strip's bottom-left, so it reads bottom-to-top. Keep the existing rule that a title is never drawn inside `axes`.
- [ ] **Step 3: Tests**
- `y_title_strip_is_narrow_when_a_y_title_is_set` — with a y title, `y_title_area.w` is about one line height and `y_title_area.h` is about the axes height; `axes` is inset by it; the strip lies outside `y_label_area`.
- `y_title_ink_is_rotated_and_outside_the_axes` — pixel test: ink exists in `y_title_area`; its bounding box is taller than it is wide for a title that is wider than tall when horizontal; and no title-coloured ink appears inside `axes`. Must fail if the axis basis is left at `(1,0)`.
- `two_titled_y_axes_get_two_columns` — a Left and a Right titled axis occupy distinct x ranges in the strip.
- [ ] **Step 4: Verify and commit**
Also run the GLFW demo headlessly and report the y-label region from its own frame so the change is visible, not just asserted.
```bash
git add -A && git commit -m "feat(plot): vertical y-axis label via a rotated text run"
```
---
### Task 4: Signal legend
**Files:**
- Create: `include/plot/plot/legend.hpp`
- Modify: `include/plot/plot/trace.hpp`, `include/plot/plot/plot.hpp`, `src/plot/plot.cpp`, `src/render/renderer.cpp`, `include/plot/plot.hpp` (umbrella)
- Test: `tests/test_plot.cpp`, `tests/test_render_smoke.cpp`
**Interfaces:**
- Consumes: `TextBackend` metrics via the plot's backend, `Trace`, `PlotLayout`.
- Produces: `Trace::name`; `plot::LegendPlace`; `plot::Legend{visible, place, frame, pad, swatch_w, swatch_h, row_gap, margin}`; `Plot::legend()`, `Plot::set_legend(const Legend&)`, and a computed `LegendLayout` (panel rect + per-row rects) that `layout()` exposes so tests and the renderer agree.
- [ ] **Step 1: Trace name and legend config**
Add `std::string name;` to `Trace` (empty means no entry), and the `Legend` struct per spec §4 with the stated defaults. Add the legend accessors to `Plot` and include the new header from the umbrella.
- [ ] **Step 2: Legend layout**
Compute it in `Plot::layout()` from the plot's `TextBackend`: entry rows are `max(swatch_h, line_height)` tall with `row_gap` between; the panel is `pad + swatch_w + gap + widest_name + pad` by `pad + rows + pad`, anchored per `place` and inset by `margin`, clamped inside `axes`. If the panel does not fit, drop entries from the end and add a `…` row; if nothing fits, produce an empty layout. No backend ⇒ no legend (nothing to measure), and a plot whose traces are all unnamed produces an empty layout and draws nothing.
- [ ] **Step 3: Draw it**
In `draw()` after the grid and traces, before text is flushed: a filled panel in `style.background` at partial alpha when `frame` is set, a 1 px border, a filled swatch per entry in that trace's colour, and the name in `axis_text`. A trace bound to a null series or marked invisible contributes no entry. Clamp so the panel cannot cross `axes`.
- [ ] **Step 4: Tests**
- Layout: `legend_panel_wraps_the_entries`, `legend_is_clamped_inside_the_axes` (place each of the four corners), `legend_drops_overflowing_entries`, `unnamed_traces_produce_no_legend`, `no_text_backend_produces_no_legend`.
- Pixel: `legend_swatch_and_label_are_drawn` — the swatch colour and `axis_text` both appear inside the panel rect and not outside it; and the presence of the panel does not change trace pixel coverage outside `axes`.
- `legend_does_not_cover_the_plot_when_placed_bottom_left` — assert the panel rect is inside `axes` and that no swatch pixel lands outside it.
- [ ] **Step 5: Commit**
```bash
git add -A && git commit -m "feat(plot): per-trace legend with measured layout and swatches"
```
---
### Task 5: Direct manipulation — grabbable cursors, regions and band edges
**Files:**
- Create: `include/plot/plot/region.hpp`
- Modify: `include/plot/plot/plot.hpp`, `src/plot/plot.cpp`, `include/plot/input/controller.hpp`, `src/input/controller.cpp`, `src/render/renderer.cpp`, `include/plot/plot.hpp` (umbrella)
- Test: `tests/test_controller.cpp`, `tests/test_plot.cpp`, `tests/test_render_smoke.cpp`
**Interfaces:**
- Consumes: `Plot::layout()`, the axis transforms, the existing cursor hit radius and `DragMode`.
- Produces: `plot::Region`, `plot::RegionSnap`, `plot::RegionStats`; `Plot::add_region/region/set_region/region_stats/remove_region`; `DragMode::MoveRegion`, `DragMode::ResizeRegionLeft`, `DragMode::ResizeRegionRight`; `plot::input::RegionGrab{None, Left, Right, Body}` and `Controller::region_grab(u32) -> RegionGrab` reporting the hot zone.
- [ ] **Step 1: The Region primitive**
Add the type and the plot accessors per spec §5. Bounds are ordered on assignment (`set_region` stores `min`/`max`, so `x0 > x1` cannot be stored). `region_stats(region, trace)` is exact via `Series::reduce`/`sum`/`sumsq` over the region's sample interval, and caches on (region, trace, series identity, sample count, `x0()`) exactly as `Plot::measurement` does after its final-review fix — the sliding-window term included, or a streaming region shows frozen statistics, which is the bug that fix existed to prevent.
- [ ] **Step 2: Cursors grabbable without a modifier**
`Controller::begin_drag` — hit-test cursor markers **first**, before pan, using the same radius `hovered_cursor()` already uses; a hit enters `MoveCursor` with no modifier required. Ctrl+drag keeps working (it must not regress into a different mode). Add `Controller::grabbed_cursor()` and expose the hovered/grabbed cursor to the renderer so the marker can be drawn highlighted.
- [ ] **Step 3: Region drag zones**
Resolve the hot zone nearest-first: an edge within the radius beats the body; a region narrower than 3 px cannot be grabbed by its edges (so it stays movable once collapsed). `MoveRegion` translates both bounds preserving width; the resize modes move one bound and re-order via `set_region`. Snapping follows `RegionSnap` and applies on release. Pressing where no handle is hot preserves today's pan/box-zoom behaviour exactly.
- [ ] **Step 4: Draggable measurement-band edges**
Hit-test the cursor-bound band's two edges in the same pass, and map a grab to the cursor that owns that edge, so an existing measurement band can be resized by dragging either end while `Plot::measurement` updates as it does today. No new plumbing: it is the cursor drag path with a different hit target.
- [ ] **Step 5: Draw regions and highlights**
Filled `Region::fill` over the x interval between the axes bounds, a `Region::edge` line at each bound, an optional label, the grabbed/hovered handle highlighted. Extend `renderer.hpp`'s documented GL-state residue list if anything new is left behind.
- [ ] **Step 6: Tests**
Controller: `cursor_is_grabbed_without_a_modifier`; `pressing_away_from_a_handle_still_pans`; `region_edge_wins_over_region_body`; `region_body_move_preserves_width`; `region_bounds_stay_ordered_when_dragged_past_each_other`; `collapsed_region_cannot_be_grabbed_by_an_edge`; `band_edge_drag_moves_the_owning_cursor_and_updates_the_measurement`.
Plot: `region_stats_match_bruteforce_over_an_unaligned_interval`; `region_stats_track_a_sliding_streaming_window` (the saturated-ring case).
Pixel: `region_fill_and_edges_are_drawn_inside_the_axes`; `grabbed_handle_is_highlighted`.
- [ ] **Step 7: Commit**
```bash
git add -A && git commit -m "feat(input): draggable cursors, regions and measurement-band edges"
```
---
### Task 6: Figure — stacked plots with a synchronised x axis
**Files:**
- Create: `include/plot/plot/figure.hpp`, `src/plot/figure.cpp`
- Modify: `include/plot/render/renderer.hpp`, `src/render/renderer.cpp`, `include/plot/plot.hpp` (umbrella)
- Test: `tests/test_figure.cpp`, `tests/test_render_smoke.cpp`
**Interfaces:**
- Consumes: `Plot`, `PlotLayout`, `Renderer::draw(Plot&)`, the controller's range setters.
- Produces: `plot::Figure` and `plot::FigureLayout` per spec §6; `Renderer::draw(Figure&)`; `Figure::plot(u32)` row access, `add_x_link()`, `link_x(u32 row, u32 group)`, `set_x_link_range(u32, Range)`, `set_row_weights(std::span<const f32>)`, `set_x_labels_only_on_bottom(bool)`.
- [ ] **Step 1: Layout**
`FigureLayout` holds `std::vector<Rect> rows`, a shared bottom x-axis strip, and the inter-row gap. Row heights come from equal division or `set_row_weights` (normalised; a zero/negative weight set is rejected with `Status` rather than producing a degenerate rect). Each row's `PlotLayout` is derived by setting that plot's viewport to its row rect — so a row's own y-axis strips still come from its own `Plot::layout()`. Give every row a floor height so chrome cannot erase a plot (the same invariant the title strips needed).
- [ ] **Step 2: The x link**
A link group holds one `Range`. `link_x` moves a plot into a group and immediately applies that group's range to it. `set_x_link_range` sets the group and pushes to every member. A range change arriving from the controller on row *i* is applied to the group and pushed to all members before the next frame. **Propagation must be idempotent and must terminate**: a member receiving a propagated range must not re-propagate it — enforce this with an explicit flag or by comparing ranges before assigning, and test it.
- [ ] **Step 3: Render**
`Renderer::draw(Figure&)` — for each row: `plot.set_viewport(row rect)`, then the existing single-plot draw path; then draw one shared x axis under the bottom row. Interior rows suppress their x label strip when `set_x_labels_only_on_bottom(true)` (the default), so labels are not duplicated. `draw(Plot&)` is untouched.
- [ ] **Step 4: Tests**
- Layout: `row_rects_tile_the_figure_without_overlap`, `row_weights_change_relative_heights_and_sum_to_the_viewport`, `degenerate_row_weights_are_rejected`, `every_row_keeps_a_positive_height`.
- Link: `link_x_applies_the_group_range_immediately`, `setting_a_range_on_any_member_propagates_to_all`, `propagation_is_idempotent_and_terminates` (drive a long alternating sequence and assert it converges in one pass), `unlinked_plots_keep_independent_ranges`.
- Pixel: `two_rows_render_distinct_content` (different trace colours land in different row rects), `shared_x_axis_is_drawn_once` (count label ink in the bottom strip only, and none in the interior rows' strips when labels are bottom-only), `a_row_resize_does_not_rescale_a_stale_layout`.
- [ ] **Step 5: Commit**
```bash
git add -A && git commit -m "feat(plot): Figure with stacked rows and a synchronised x axis"
```
---
### Task 7: Multi-plot example, legends everywhere, full verification
**Files:**
- Create: `examples/multiplot_demo.cpp`
- Modify: `examples/{glfw,imgui,qt,gtk}_demo.cpp`, `examples/CMakeLists.txt`, `CMakePresets.json` if needed
- Modify: `README`-class docs only if one exists (it does not — do not create one)
**Interfaces:**
- Consumes: everything above.
- [ ] **Step 1: The multi-plot example**
`examples/multiplot_demo.cpp`: a 3-row `Figure` on the GLFW host. Row 1 carries three named signals (a sine, a square, and a noisy ramp) on a shared y axis with a legend; row 2 carries two signals at a different scale; row 3 carries a single sparse signal. All three rows share one x link, so panning or zooming anywhere moves all three together. Every signal is named so the legend is populated. Add two cursors and one region with a stats readout, and drive it at a high sample rate through a `StreamSeries` so the sync is exercised while data slides. Reuse the project's `PLOT_DEMO_EXIT_AFTER_MS` / `PLOT_DEMO_SCREENSHOT` headless hooks.
- [ ] **Step 2: Legends and interaction in the existing demos**
Add names to the traces and enable the legend in all four existing demos, and add one region to at least one of them so the new interaction is reachable in the GLFW and Qt demos (the two with the richest input paths). Do not restructure those demos otherwise.
- [ ] **Step 3: Full verification**
```
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
```
Run all five demos headlessly with their own screenshot paths and confirm, from each PPM: trace pixels present, axis text present, legend swatch colours present where a legend is on, and the region fill present. Then report each demo's measured frame time against the pre-change baselines (GLFW ~0.7-1.3 ms, Qt ~3.6 ms, GTK ~1.1-5.3 ms at 810x564, ImGui ~0.5-0.76 ms) so any regression is visible.
- [ ] **Step 4: Commit**
```bash
git add -A && git commit -m "feat(examples): multi-plot synced demo, legends in every demo, verified headlessly"
```
---
### Task 8: Optional runtime TrueType font target
Independent of Tasks 3-7 (it needs only Task 1's interface), so it can be done last. It exists because the request was for a font that is both embedded **and** user-customisable from external files.
**Files:**
- Create: `third_party/stb_truetype.h`, `include/plot/text/true_type_font.hpp`, `src/text/true_type_font.cpp`, `adapters/`-style `CMakeLists.txt` for the target
- Modify: top-level `CMakeLists.txt` (new `PLOT_BUILD_TTF_FONT` option, default ON when the header is present), `include/plot/plot.hpp` **only if** the header is reachable without stb_truetype — see the note below
- Test: `tests/test_true_type_font.cpp`
**Interfaces:**
- Consumes: the widened `TextBackend` from Task 1.
- Produces: `plot::text::TrueTypeFont` with `static bool from_file(const char* path, f32 px, TrueTypeFont* out, Status* st = nullptr)` and `void set_coverage(std::span<const u32>)`, implementing `TextBackend`.
- [ ] **Step 1: Vendor and isolate**
Download `stb_truetype.h` (public domain) into `third_party/` with its version and the source URL recorded in a comment at the top of our own wrapper, and in `third_party/README`-equivalent text inside the wrapper header — do not create a new markdown file. Define `STB_TRUETYPE_IMPLEMENTATION` in exactly one translation unit inside the new target. Confirm with `nm`/`ldd` that `plot_core` and the public headers are untouched: `plot_core` must link and include nothing new.
- [ ] **Step 2: The backend**
Implement `TrueTypeFont` over `stb_truetype`: bake the coverage set at `px` into a packed single-row R8 atlas, build the glyph table (rect, bearing, advance) from `stbtt_GetCodepointBitmap` and `stbtt_GetCodepointHMetrics`, and implement the full `TextBackend`, including `revision()` (monotone; bumped by `set_coverage` and by re-baking). Reject a missing file, an unparsable font, a non-positive `px`, and a bake that produced no ink, filling `Status`.
**Important:** do **not** add this header to `include/plot/plot.hpp`. The umbrella must stay compilable with no third-party header on the include path, and the configure-time coverage check will reject a header it cannot compile standalone. Keep `plot/text/true_type_font.hpp` self-contained (it may include `plot/render/text.hpp` and the standard library, and must forward-declare nothing from stb_truetype), and document that it is built only by the optional target.
- [ ] **Step 3: Tests**
- `ttf_backend_bakes_the_requested_coverage` — load the Adwaita Mono TTF from the system path if present, else skip with a printed reason and a non-zero-skipped marker; assert every requested codepoint has non-empty ink and a plausible advance.
- `ttf_failure_paths_report_status` — a nonexistent path, a directory path, a text file, and `px <= 0` each return false with a `Status` and leave `out` untouched.
- `ttf_backend_renders_through_the_renderer` — bake at a size clearly different from the embedded font, render a frame with it as the plot's text backend, and assert the ink differs from the embedded font's for the same string (proving the renderer genuinely uses it, including the `revision()` re-upload path).
- `plot_core_stays_free_of_stb_truetype` — assert the built `plot_core` archive contains no stb_truetype symbols and that no public header mentions it.
- [ ] **Step 4: Verify and commit**
Build with `PLOT_BUILD_TTF_FONT=OFF` as well and confirm everything still configures and passes, so the optional target is genuinely optional.
```bash
git add -A && git commit -m "feat(text): optional vendored stb_truetype backend for runtime external fonts"
```
@@ -0,0 +1,210 @@
# `plot` — interaction, fonts, legend and multi-plot
**Date:** 2026-09-28
**Status:** design approved by the requester (font, external-font mechanism, and area semantics chosen explicitly)
**Predecessor:** `2026-09-27-plot-library-design.md` (the library itself; its invariants still bind)
## Goal
Five changes to the shipped library, driven by using it:
1. The y-axis label is laid out **vertically**, as every plotting package does it.
2. The default font is replaced — the 8×8 1-bit bitmap reads badly — with an anti-aliased embedded Adwaita Mono, and the library gains **runtime external-font** support.
3. Cursors and measurement **areas** are directly **draggable** (grab on the marker, no modifier), and droppable with snapping.
4. A **legend** for the signals, and it must appear in the examples.
5. A **multi-plot** example: several plots and several signals per plot, with the x axis **synchronised** across plots.
## Non-goals
- Arbitrary font features: no kerning, no shaping, no hinting, no subpixel positioning. Explicitly deferred.
- Text selection/editing, data export, annotations beyond what exists.
- A general constraint/layout engine — `Figure` is a fixed vertical stack, not a docking system.
## 1. Text pipeline refactor (prerequisite for 1 and 2)
Today the split is wrong: `TextBackend` (`include/plot/render/text.hpp`) exposes only `measure`/`height`/`ascent`, while the renderer privately owns a hardcoded 128×56 1-bit atlas and `kTextVS` hardcodes `kGlyphTexels = vec2(8.0)`. Two consequences: a non-bitmap font cannot be plugged in, and text cannot be rotated. Both are fixed by making the backend the atlas owner.
```cpp
namespace plot::render {
/// One glyph's atlas rectangle and metrics, in font pixels.
struct Glyph {
f32 u0 = 0, v0 = 0, u1 = 0, v1 = 0; // normalised atlas rect
f32 xoff = 0, yoff = 0; // bearing from the pen, top-left origin
f32 advance = 0; // pen movement along the baseline
};
class TextBackend {
public:
virtual ~TextBackend() = default;
virtual f32 measure(const char* utf8) const = 0;
virtual f32 height() const = 0;
virtual f32 ascent() const = 0;
/// Null for a codepoint with no glyph; the caller draws its fallback box.
virtual const Glyph* glyph(u32 codepoint) const = 0;
/// 8-bit single-channel coverage, row-major, `atlas_width() * atlas_height()`
/// bytes. Uploaded as GL_R8. Null is legal and means "no text".
virtual const u8* atlas_pixels() const = 0;
virtual u32 atlas_width() const = 0;
virtual u32 atlas_height() const = 0;
/// Bumped whenever `atlas_pixels()` content changes, so the renderer knows to
/// re-upload instead of trusting its cache. Must not change otherwise.
virtual u32 revision() const = 0;
};
} // namespace plot::render
```
The renderer uploads whatever atlas the backend exposes and re-uploads when `revision()` changes — the hardcoded atlas and the 8×8 texel assumption both go away.
**Rotation is a property of the staged instance, not a new shader path.** `TextBatch`'s instance grows from `{x, y, u, v}` to `{x, y, u, v, ax, ay}` where `(ax, ay)` is the glyph's local +x axis expressed in pixels (default `(1, 0)`). The vertex shader builds the cell from that basis and its perpendicular, and maps UVs consistently, so a 90° run is just `(0, -1)`. This supports arbitrary angles at 8 bytes per glyph, which is cheaper than a matrix and keeps one code path.
## 2. Font strategy
**Embedded default.** Adwaita Mono (SIL OFL 1.1 — derived from Inter and Iosevka; `adwaita-fonts` ships the licence). A greyscale, anti-aliased atlas is rasterised **at authoring time** by a committed generator script (`tools/fonts/gen_atlas.py`, using `fontTools`, already present) at a chosen pixel size, and the resulting byte table is **committed** as generated data. Consequences: no runtime dependency, no build-time dependency, no network, and the atlas is reviewable in a diff.
OFL compliance, explicitly:
- The full OFL 1.1 text is committed under `licences/`.
- The backend is named neutrally (`EmbeddedMonoFont`), not "Adwaita Mono", because OFL reserves the font name for modified redistributions.
- **This is a clean cutover, not an alias.** The public class `BitmapFont` is *renamed* to `EmbeddedMonoFont`: every caller in `src/`, `adapters/`, `examples/` and `tests/` is migrated and the old name is deleted. No deprecated alias, no re-export.
- Provenance (source font, version, generator invocation, size, and the coverage set) is stated in both the generated header and the script.
Coverage: ASCII 32..126, plus U+00B0, U+00B5, U+03A9 (the codepoints `format_si` emits) and U+2013/U+2014, plus a fallback box for everything else. The generator asserts every requested codepoint rasterised to non-empty ink, so a coverage regression fails generation rather than shipping blank glyphs.
**Runtime external fonts** are the second half of the request. A new optional target `plot_font_ttf` vendors `stb_truetype.h` (public domain, single header) under `third_party/` and provides:
```cpp
namespace plot::text {
/// Rasterises a TrueType/OpenType file into an atlas at one pixel size.
class TrueTypeFont final : public render::TextBackend {
public:
/// Reads `utf8_path` fully and bakes an atlas at `px` em. Returns false and
/// fills `*st` on a missing file, an unparsable font, or a bake that produced
/// no ink — matching the existing `bool` + out-param + `Status*` convention
/// rather than introducing a new `Result<T>` type.
static bool from_file(const char* utf8_path, f32 px, TrueTypeFont* out, Status* st = nullptr);
/// Which codepoints to bake. Defaults to the same coverage as the embedded font.
void set_coverage(std::span<const u32> codepoints);
};
}
```
`third_party/` and stb_truetype are reachable **only** by this opt-in target; `plot_core` and every public header under `include/plot/` remain third-party-free, and the negative check for that (the loader/`nm` audit and the self-containment build) still applies. The `no third_party/` rule in the predecessor spec is hereby narrowed to the core, not the optional font target.
## 3. Vertical y-axis label
`PlotLayout::y_title_area` becomes a **narrow vertical strip** — one text line *wide* rather than tall — placed immediately outside `y_label_area`, and the y title is drawn bottom-to-top (rotated 90° CCW) inside it. `y_title_width` consequently means the strip's width (one line height), and the old "horizontal run stacked down a tall strip" model and the "one row per titled y axis" rule are replaced by "one *column* per titled y axis", so a Left/Right pair still cannot collide.
`Plot` continues to reserve nothing when no axis of that direction has a title. The invariant that a title is never drawn inside `axes` is unchanged and still tested.
## 4. Legend
`Trace` gains `std::string name` (empty means "no legend entry"). A new `Legend` config lives on the plot:
```cpp
struct Legend {
bool visible = true; // auto-suppressed when no trace has a name
LegendPlace place = LegendPlace::TopRight; // TopLeft/TopRight/BottomLeft/BottomRight
bool frame = true; // background panel behind the entries
f32 pad = 6.0f; f32 swatch_w = 14.0f; f32 swatch_h = 8.0f; f32 row_gap = 3.0f;
f32 margin = 8.0f; // inset from the axes edge
};
```
Layout: entries are measured with the same `TextBackend` the renderer draws with; the panel is `swatch_w + gap + widest name + padding` wide and `rows * line + gaps` tall, clamped inside `axes`; if it cannot fit, entries are dropped from the end and a `…` row is drawn. Each row draws a filled swatch in the trace colour (alpha 1.0, so a translucent trace is still legible) and the name in `axis_text`. A trace with an empty name is skipped, and a legend with no entries draws nothing at all — so the feature is free for callers who never name a trace.
## 5. Direct manipulation: cursors, regions and areas
**Cursors** become grabbable with no modifier: the controller hit-tests the cursor marker first, and a press within the existing cursor hit radius enters `MoveCursor`, showing the cursor in a highlighted colour while hovered or grabbed. The radius is not a new constant — it is the one the controller already uses, so hover and grab agree. Falling back: pressing elsewhere still pans, so the old behaviour is a superset. Snapping is unchanged (`SnapMode::NearestSample` on release/drag).
**Areas — both forms, since both were requested:**
```cpp
enum class RegionSnap { Free, NearestSample };
struct Region {
f64 x0 = 0.0, x1 = 0.0; // ordered on the x axis
RegionSnap snap = RegionSnap::NearestSample;
Color fill = Color::hex(0xFFB020, 0.15f);
Color edge = Color::hex(0xFFB020);
std::string label;
bool visible = true;
};
class RegionStats { // computed over the region's x interval
public:
f64 x0 = 0, x1 = 0, width = 0;
f64 mean = 0, rms = 0, min = 0, max = 0, integral = 0;
u64 count = 0; // samples in the interval
bool valid = false;
};
```
Statistics are exact via `Series::reduce`/`sum`/`sumsq`, exactly as measurements are.
`Plot` gains `add_region(Region)` / `region(u32)` / `set_region(u32, f64 x0, f64 x1)` / `region_stats(u32, u32 trace)` / `remove_region(u32)`. Region statistics reuse the exact `Series` interval queries, exactly as measurements do — no new scanning path.
**Interaction:** a region has three grab zones — the interior (move the whole interval, preserving width), the left edge, and the right edge — using that same cursor hit radius, resolved nearest-first so an edge always wins over the interior when both are in range. Dragging an edge moves one bound; `x0 > x1` is never stored (bounds are ordered on assignment). A region narrower than ~3 px is treated as a click, not an edge grab, so it stays draggable once collapsed.
**The cursor-bound measurement band also gets draggable edges**, so the shaded band between two measurement cursors can be resized by dragging either edge — the same hit-test path, mapping the grab to the cursor that owns that edge.
New controller modes: `MoveRegion`, `ResizeRegionLeft`, `ResizeRegionRight`, plus `RegionGrab` reporting which zone is hot so a host can set a resize cursor.
## 6. Multi-plot with a synchronised x axis
```cpp
class Figure {
public:
explicit Figure(u32 rows);
Plot& plot(u32 row);
/// Axis link groups. Plots in the same group share one x range: setting the
/// range on any member (by API or by interaction) propagates to the others.
/// Group 0 exists by default and every plot starts in it.
u32 add_x_link();
void link_x(u32 row, u32 group);
void set_x_link_range(u32 group, Range);
void set_viewport(Rect); // the whole figure, framebuffer pixels
const FigureLayout& layout() const;
};
```
The legend is **per plot**, not per figure (§4): each row legends its own traces, which is what a multi-signal row needs and what keeps `Renderer::draw(Plot&)` unchanged for single-plot hosts.
`FigureLayout` holds the per-row `Rect`s (each row's `PlotLayout` inset by the figure's margins), one shared bottom x-axis strip, and the gap between rows. Row heights are equal by default; `set_row_weights(span<const f32>)` allows a taller main plot.
**Rendering:** `Renderer::draw(Figure&)` lays the rows out, then for each row sets `Plot::set_viewport(row rect)` and draws it exactly as `draw(Plot&)` does today, then draws one shared x axis under the last row. The x-axis *ticks* are drawn once, not per row; interior rows hide their bottom label strip to avoid duplicated labels (configurable). `draw(Plot&)` remains and is unchanged for single-plot hosts, so no existing caller breaks.
**Propagation:** a range change from the controller on row *i* is applied to the group and then pushed to every member before the next frame, so a pan or zoom anywhere moves all plots together. Propagation is idempotent and must terminate: a group member receiving a propagated range must not re-propagate it.
## 7. Invariants that must not regress
These are inherited from the predecessor spec and are non-negotiable; every task's review checks them:
- `plot_core` and all public headers stay third-party-free (now: the *optional* `plot_font_ttf` target may use the vendored stb_truetype and nothing else may).
- Vertex data stays in data space; pan/zoom remains a uniform update; the per-frame cost stays O(visible pixels + produced samples).
- The renderer clears only its viewport region via scissor and hands back host GL state.
- Exact interval queries: regions, legends and measurements use `Series::reduce/sum/sumsq`, never the pyramid's `*_buckets` forms.
- 3.3-core baseline; anything above stays behind `caps()`.
- The 130 existing test cases keep passing unmodified except where a signature genuinely changes, and every signature change is called out in the task report.
## 8. Verification
| Area | How |
|---|---|
| Text refactor, font, rotation | Pixel tests on the headless EGL context: a rotated run occupies a tall narrow box and its ink is where the basis says; the embedded atlas reports non-empty ink for every covered codepoint; a swapped-in second backend (a test double with a differently sized atlas) renders, proving `revision()`-driven re-upload works. |
| Vertical y label | Pixel test that the title strip is narrow and tall, that no title ink appears inside `axes`, and that the ink is rotated (taller than wide for a run that is wider than tall when horizontal). |
| Legend | Layout unit tests (panel size, clamping, overflow-drop) plus a pixel test asserting the swatch colour and the label text appear inside the panel, and that an unnamed plot draws no panel. |
| Drag and drop | Controller unit tests: grab without modifier, edge-before-interior resolution, ordering invariant under inversion, sub-3px click handling, snap on release, and that a pan still happens when no handle is hot. |
| Multi-plot + x-link | Layout unit tests for row rects and weights; link tests (set on any member propagates to all, idempotent, terminating); pixel test that two rows render distinct content and the shared x axis appears once. |
| Examples | All four existing demos gain a legend and keep their headless `PLOT_DEMO_EXIT_AFTER_MS`/`PLOT_DEMO_SCREENSHOT` mode; a fifth demo shows the multi-plot synced figure. Every demo verified by headless run plus pixel classification of its own frame, and I eyeball the frames myself. |
## 9. Risks
- **The text refactor touches every text pixel in the library.** Mitigated by doing it first, alone, with the existing pixel tests as the regression net plus new rotation tests.
- **Rotation in the vertex shader is easy to get subtly wrong** (mirrored glyphs, UV handedness). Mitigated by asserting absolute orientation with an asymmetric glyph, as the bitmap font already does.
- **Adwaita Mono's licence** is OFL 1.1 with reserved font names; mitigated by a neutral backend name, a committed licence, and stated provenance.
- **`Figure` touches the renderer's frame contract.** Mitigated by leaving `draw(Plot&)` untouched and adding `draw(Figure&)` alongside it, so the four shipped adapters keep working unchanged.
- **Snapshot/`revision()` staleness** could render a stale atlas. Mitigated by making `revision()` monotone and testing a backend swap mid-run.