feat(plot): crosshair with an interpolated per-signal hover readout

This commit is contained in:
Martino Ferrari committed 2026-09-28 18:57:33 +02:00
1 parent a153b73ead
commit fd0c471430
11 files changed
+809 -22

No files matched your search

+78
View File
@@ -6,6 +6,7 @@
#include <plot/math/transform.hpp>
#include <plot/plot/annotation.hpp>
#include <plot/plot/axis.hpp>
#include <plot/plot/crosshair.hpp>
#include <plot/plot/cursor.hpp>
#include <plot/plot/grid.hpp>
#include <plot/plot/legend.hpp>
@@ -96,6 +97,13 @@ struct PlotLayout {
/// controller's hit test reads the very same rects, so where a region is drawn and
/// where a pointer can grab it cannot drift apart.
std::vector<RegionLayout> regions;
/// The crosshair's geometry, when one is active: the two lines' stroke boxes, the
/// readout panel and one row per visible trace with the interpolated value the pointer
/// is over. Placed here rather than derived by the renderer, exactly as the legend and
/// the regions are -- and, like them, entirely inside `axes`: the panel is flipped and
/// clamped against the rect it was measured for, so nothing of the readout can be drawn
/// over a label strip or into a figure's neighbouring row.
CrosshairLayout crosshair;
};
/// What a pointer is on, as of the last pointer event: the cursor marker under it, the
@@ -530,6 +538,56 @@ class Plot {
/// a caller can move the legend -- or switch it off -- between frames.
void set_legend(const Legend& legend) noexcept { legend_ = legend; }
// --- crosshair --------------------------------------------------------
/// Places a **vertical-only** crosshair at a data x: the column of `x`, a marker on
/// every visible trace at that x, and the readout panel beside it (spec §15).
///
/// This is the form for a caller that has a coordinate and not a pointer -- "show me
/// every signal at this instant" -- and for `Figure::set_crosshair()`, which applies one
/// x to every row: a figure's rows share x and not y, so a horizontal line across them
/// would be a claim about y that the figure does not make.
///
/// **Refused** -- nothing changes -- when `x` is not a number: a crosshair there has no
/// column to draw and no value to read, and refusing it leaves the previous position
/// rather than a crosshair no gesture could undo. `active == false` clears it exactly as
/// `clear_crosshair()` does.
void set_crosshair(f64 x, bool active);
/// Places a crosshair at both coordinates: the vertical line and the readout of the
/// first form, plus the horizontal line at `y`. This is the form `input::Controller`
/// uses, because a pointer over the axes has both coordinates.
///
/// Refused, changing nothing, when either coordinate is not a number (see the first
/// form).
void set_crosshair(f64 x, f64 y, bool active);
/// Hides the crosshair, leaving where it was: the same state a plot has before any
/// crosshair is placed, so the next `set_crosshair()` re-anchors it. `input::Controller`
/// calls this when the pointer leaves the axes.
void clear_crosshair() noexcept { crosshair_.active = false; }
/// The crosshair's state: where it is, which lines it draws and whether the readout is
/// on. Read-only -- `set_crosshair()` and `clear_crosshair()` are how it moves -- so the
/// coordinates it holds and the geometry `layout()` places from them cannot disagree.
const Crosshair& crosshair() const noexcept { return crosshair_; }
/// The **interpolated** value of `trace`'s series at a data `x`: `crosshair_span()`
/// between the two adjacent samples, then `crosshair_interpolate()` -- the same lookup
/// the readout's rows are built from, exposed for a host that wants the number for
/// itself (a status line, a tooltip of its own).
///
/// False, leaving `out_y` untouched, for an id that names no trace, a trace with no
/// series, and an `x` the series has no sample at -- the cases `CrosshairSpan::valid`
/// documents, plus the trace itself. Never a clamped or nearest-sample answer: see
/// `crosshair_span()` for why an out-of-window x is not a value.
///
/// O(1) and allocation-free once the plot has looked a value up before: the span is
/// index arithmetic and the samples are read through one `Series::gather()` of at most
/// two samples into a buffer the plot owns. It is called on every layout -- and therefore
/// on every pointer move -- once per visible trace, so nothing here may scan a series.
bool crosshair_value(u32 trace, f64 x, f64& out_y) const;
private:
/// One measure plus its cache. The dirty flag is what keeps measurement() from
/// re-evaluating every frame; `last_series`, `last_size` and `last_x0` catch a
@@ -636,6 +694,12 @@ class Plot {
/// to list -- an empty legend is what keeps the feature free for a caller who
/// never names a trace.
void build_legend();
/// Measures the crosshair into `layout_.crosshair`: the two lines' stroke boxes, one row
/// per visible trace with its interpolated value and marker, and the readout panel --
/// placed beside the crosshair, flipped where it would cross an edge and clamped, so
/// every rect it produces is inside `axes`. A plot with no visible trace still gets its
/// lines: the rows are the readout's, not the crosshair's.
void build_crosshair();
Rect viewport_{};
render::Style style_{};
@@ -664,6 +728,20 @@ class Plot {
/// about to drop. A member rather than a local, so a steady-state frame's legend
/// build allocates nothing.
std::vector<f32> legend_name_px_;
/// The crosshair's state (see Crosshair): at most one per plot, placed by
/// `set_crosshair()` and read back by `crosshair()` and `layout()`.
Crosshair crosshair_{};
/// The samples `crosshair_value()` reads a span through: `Series::gather()` appends
/// `(x, y)` pairs and never clears, so the lookup clears this and refills it with the at
/// most two samples a span covers. Sized to hold them from construction -- four floats is
/// two pairs -- so a lookup on the pointer path reallocates nothing even the first time,
/// and `mutable` for the same reason `measures_` is: a lookup is not a mutation a caller
/// can observe.
mutable std::vector<f32> crosshair_scratch_{0.0f, 0.0f, 0.0f, 0.0f};
/// Scratch for build_crosshair(): the content width (label + gutter + value) of every row,
/// prefix-maxed in place, for `legend_name_px_`'s reason -- the panel's width after a row
/// was dropped from the end is the width of a row still listed, read in O(1).
std::vector<f32> crosshair_content_px_;
PlotLayout layout_{};
Grid grid_;