16 KiB
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:
- The y-axis label is laid out vertically, as every plotting package does it.
- 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.
- Cursors and measurement areas are directly draggable (grab on the marker, no modifier), and droppable with snapping.
- A legend for the signals, and it must appear in the examples.
- 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.
- Proportional (variable-width) text. An atlas is a fixed-cell grid: every glyph occupies one cell of the same size. That is what keeps the staged instance at 6 floats and the vertex shader free of a per-glyph size, and it is why a proportional font must draw its glyphs into a uniform cell instead of trimming the atlas rect. The constraint is structural — declared by the backend, asserted in debug builds — not a note, because violating it silently draws every glyph at the first staged glyph's size.
- Text selection/editing, data export, annotations beyond what exists.
- A general constraint/layout engine —
Figureis 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.
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
BitmapFontis renamed toEmbeddedMonoFont: every caller insrc/,adapters/,examples/andtests/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:
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:
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:
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
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 Rects (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_coreand all public headers stay third-party-free (now: the optionalplot_font_ttftarget 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*_bucketsforms. - 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.
Figuretouches the renderer's frame contract. Mitigated by leavingdraw(Plot&)untouched and addingdraw(Figure&)alongside it, so the four shipped adapters keep working unchanged.- Snapshot/
revision()staleness could render a stale atlas. Mitigated by makingrevision()monotone and testing a backend swap mid-run.