264 lines
21 KiB
Markdown
264 lines
21 KiB
Markdown
# `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.
|
||
- **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 — `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.
|
||
|
||
---
|
||
|
||
## 10. Consistent physical sizing: the UI scale
|
||
|
||
**This reverses a decision recorded in the predecessor spec.** That document settled on style metrics being *device* pixels and accepted the consequence that labels are relatively smaller on a high-DPI host. That is backwards for a library whose output should look the same everywhere: a 12 px axis label must be the same physical size on a 1x panel and a 2x panel, and a 1 px grid must not be a hairline on one and a slab on the other.
|
||
|
||
`Style` gains `f32 ui_scale = 1.0f` — device pixels per logical unit. Every visual metric in `Style` is henceforth a **logical** unit (`axis_font_px`, line widths, grid widths, paddings, swatch and marker sizes, tick minimum spacing), converted to device pixels by one helper per consumer: `PlotLayout` when it sizes label strips, title strips, the legend and region chrome, and the renderer when it converts widths and font px. `Plot` scales the font px it hands to the text pipeline, so a logical 12 px label is drawn at `12 * ui_scale` device px.
|
||
|
||
Data-space geometry is untouched: only chrome scales. The pixel-space convention is otherwise unchanged — `Rect` is still device pixels with a top-left origin, so the renderer, the scissor clear and every adapter's event mapping stay as they are.
|
||
|
||
The host supplies the value and every adapter already knows it: GLFW from `glfwGetFramebufferSize` / `glfwGetWindowSize`, Qt from `devicePixelRatioF()`, GTK from the two sizes the `::resize` signal reports, ImGui from `io.DisplayFramebufferScale`. `Renderer::draw()` propagates `style().ui_scale` onto the plot before laying out, so layout and drawing cannot disagree about the scale.
|
||
|
||
Invariant, and the test that pins it: with `ui_scale = 2.0`, every chrome element's **device** extent doubles while its **logical** extent is unchanged — the same axis label occupies twice the pixels but expresses the same size, and `measure`-derived strip widths scale with it.
|
||
|
||
## 11. Grid lines as two styled classes
|
||
|
||
`Style` carried separate colours for major and minor grid lines but neither width nor visibility, so a dense plot could not switch the minor grid off, and a fine grid could not be drawn thinner than a coarse one. Each class gains its own width and visibility, all in logical units scaled by §10:
|
||
|
||
```cpp
|
||
Color grid_major = Color::hex(0x2A3038);
|
||
Color grid_minor = Color::hex(0x1C2126);
|
||
f32 grid_major_width = 1.0f; // logical px
|
||
f32 grid_minor_width = 1.0f;
|
||
bool grid_major_visible = true;
|
||
bool grid_minor_visible = true;
|
||
```
|
||
|
||
The grid emits majors exactly at the major ticks and minors exactly at the minor ticks — one source, the tick set — so a major grid line coincides with a labelled tick by construction rather than by coincidence. Visibility is checked before staging, so a disabled class costs nothing to draw rather than being drawn transparent.
|
||
|
||
## 12. Live data: relative zoom and panning that keeps following
|
||
|
||
The streaming case is the library's headline use, and today any interaction destroys it: a wheel zoom or a drag calls `set_x_range`, which clears `auto_scale`, so a live plot stops following the newest sample and never resumes. That is wrong for a scrolling display, and it is why panning feels useless there. The fix is an explicit follow state on the x axis instead of an implicit consequence of `auto_scale`:
|
||
|
||
```cpp
|
||
enum class XFollow { Off, Live };
|
||
// on Plot:
|
||
void set_x_follow(u32 axis, XFollow);
|
||
XFollow x_follow(u32 axis) const;
|
||
void set_x_offset(u32 axis, f64 units_behind_live); // >= 0, clamped to the retained window
|
||
f64 x_offset(u32 axis) const;
|
||
```
|
||
|
||
With `Live`, the visible window is `[newest - offset - span, newest - offset]`, recomputed from the series' newest x every frame, so new samples keep arriving at the right edge and the plot never stops following.
|
||
|
||
- **Relative zoom.** A wheel zoom **multiplies** the span rather than adding to it, so successive notches compound and the zoom is scale-free. The anchor is the data coordinate under the pointer when the pointer is inside `axes`, and the live edge otherwise. Following is preserved either way, and the offset is adjusted so the anchored coordinate does not move on screen.
|
||
- **Mouse panning.** A left-drag changes `offset` by the data delta and leaves the span alone — this is the panning a live plot needs, and what "add mouse panning" means here. Dragging toward the live edge stops at `offset == 0`; you cannot pan past the newest sample. Dragging away moves back through the retained window, so the limit is what the `StreamSeries` still holds.
|
||
- **Returning to live.** `auto_fit` under `Live` sets `offset = 0` and fits the span to the retained window. It does not leave `Live`.
|
||
- **Interactions never silently disable following.** Pan and zoom keep `XFollow::Live` armed; only an explicit `set_x_follow(axis, Off)` turns it off. This is the behavioural change: today's interaction path disarms auto-follow as a side effect.
|
||
- **Static plots are unaffected.** `XFollow::Off` is the default, and a plot whose series is not streaming behaves exactly as it does today, including `auto_scale` on the y axis.
|
||
|
||
The y axis keeps its existing `auto_scale`; this section governs the x view of a streaming series.
|