# `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` 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 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)` 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.