docs: spec and plan for vertical y label, fonts, legend, draggable areas and synced multi-plot
This commit is contained in:
1 parent
d110bfd459
commit
90e17a0a11
2 files changed
+539
No files matched your search
@@ -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.
|
||||
Reference in new issue
Block a user