327 lines
26 KiB
Markdown
327 lines
26 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.
|
||
|
||
---
|
||
|
||
## 13. Clip the data to the plot area (defect)
|
||
|
||
**This is a bug, reported from use: "you can see signals outside the plot area."** `Renderer::begin_frame` scissors the *viewport*, and traces are drawn through the axes transform, so any sample whose y lies outside the visible range maps outside `axes` and is drawn anyway — over the y label strip, the y title strip, and in a `Figure` straight into the neighbouring row. The same applies to grid lines at a tick outside the range, region fills and their bounds, cursors and the measurement band.
|
||
|
||
The fix is a scissor to `layout().axes`, in framebuffer pixels and intersected with the viewport, around every pass that draws *inside* the axes: grid, regions, traces, cursors, and the measurement band. The passes that draw *outside* it by design — tick labels, axis titles — must run with the axes clip released, or they would be erased. So the frame alternates: viewport clip → axes clip for the interior passes → back to the viewport clip for the label and title passes.
|
||
|
||
This is a state change, not a geometry change: no vertex data moves, no CPU clipping is introduced, and the data-space invariant is untouched.
|
||
|
||
Verification is a pixel test with a discriminating predicate: a trace whose values lie far outside the y range must produce **no** trace-coloured pixel outside `axes` — specifically none in the label or title strips — and, for a two-row `Figure`, none inside the neighbouring row's rect. It must fail against the current code.
|
||
|
||
## 14. Horizontal rulers
|
||
|
||
Cursors are vertical only: `Cursor` carries an `x` and the drag path moves it along x. Reading a value off the y axis needs the same instrument turned 90°.
|
||
|
||
`Cursor` gains an orientation and a second coordinate, and the existing grab/drag/hover path handles both:
|
||
|
||
```cpp
|
||
enum class CursorOrientation { Vertical, Horizontal };
|
||
struct Cursor {
|
||
CursorOrientation orientation = CursorOrientation::Vertical;
|
||
f64 x = 0.0; // meaningful for Vertical
|
||
f64 y = 0.0; // meaningful for Horizontal
|
||
CursorSnap snap = CursorSnap::NearestSample; // Vertical
|
||
HRulerSnap h_snap = HRulerSnap::Free; // Horizontal: Free | NearestTick
|
||
Color color = Color::hex(0xFFB020);
|
||
std::string label;
|
||
u32 axis_x = 0, axis_y = 0;
|
||
bool visible = true;
|
||
};
|
||
```
|
||
|
||
Horizontal rulers span the axes width, are grabbable and draggable along y with the same hit radius as a vertical cursor, and snap either freely or to the y tick set (`NearestTick` — a y sample has no meaning, since the series is sampled in x). The plot gains `add_h_ruler(f64 y, Color)` / `set_cursor_y(u32, f64)` alongside the vertical pair, and `hovered_cursor`/`grabbed_cursor` report both orientations. A vertical and a horizontal cursor are indistinguishable to the drag machinery except in which coordinate they mutate, so there is one hit-test path, not two.
|
||
|
||
## 15. Crosshair with a per-signal hover readout
|
||
|
||
Following the pointer inside `axes` should show the data under it: a crosshair at the pointer's x (and optionally its y), a marker on each visible trace at that x, and a readout panel listing each trace's name and value there — the same information a cursor gives, but continuous with the mouse and available per signal.
|
||
|
||
```cpp
|
||
struct Crosshair {
|
||
bool active = false;
|
||
bool vertical = true, horizontal = true; // which lines to draw
|
||
bool show_readout = true;
|
||
f64 x = 0.0, y = 0.0; // data coordinates
|
||
};
|
||
// on Plot:
|
||
void set_crosshair(f64 x, bool active); // vertical line + per-trace readout
|
||
void set_crosshair(f64 x, f64 y, bool active);
|
||
const Crosshair& crosshair() const;
|
||
void clear_crosshair();
|
||
```
|
||
|
||
**Value lookup.** The readout's y for a trace is obtained by **linear interpolation between the two samples adjacent to the crosshair's x**, and is documented as interpolated rather than exact — the opposite convention from `Series::reduce`/`sum`/`sumsq`, which are exact over a sample range. The distinction matters, so it is stated on both. A sample lookup uses `snap`-style index arithmetic, is O(1), and never scans: the crosshair updates on every pointer move and must stay cheap.
|
||
|
||
**Synced across plots.** The crosshair's x is a *data* coordinate, so one hover can drive every row: `Figure::set_crosshair(f64 x, bool active)` applies the same x to every row, and each row's readout lists its own traces at that x. That is what "synced if multiple plots available" means — one pointer position, one time, N rows of values. A figure with rows at different sample rates still shares the x, because the x is the shared axis.
|
||
|
||
**Painting.** Lines in a `style.crosshair` colour (default distinct from `cursor`), thin and distinguishable from the grid; a small marker at each trace's intersection; the readout panel placed inside `axes`, offset from the pointer, flipped to stay within the axes rect near an edge, with a swatch and value per visible named trace, reusing the legend's row measurement rather than growing a second one. An unnamed trace still contributes a value row (it has a colour); the legend's rule of skipping unnamed traces does not apply here, since the readout is about what is under the pointer, not about identity. Clip everything to `axes` per §13, and place the panel so it cannot fall outside.
|
||
|
||
**Pointer leaving the axes clears it**, and a middle-drag or a wheel keeps it active but re-anchored — the crosshair follows the pointer, so any interaction that does not move the pointer leaves it where the pointer is.
|
||
|
||
The controller owns translating motion into `set_crosshair`; the plot owns the data; the renderer owns only painting. Tests: the crosshair x tracks motion and clears on leave; the interpolated value matches a hand-computed interpolation at a non-sample x; the readout lists one row per visible trace including unnamed ones; a `Figure` applies one x to every row; no crosshair ink outside `axes`.
|