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

+22
View File
@@ -124,6 +124,18 @@ using plot::RegionGrab;
/// the same answer for a host that wants it directly -- a resize cursor, a tooltip,
/// a status line.
///
/// ## The crosshair (spec §15)
///
/// A pointer over the data area places the plot's crosshair at the data coordinates under
/// it (`Plot::set_crosshair()`), and a pointer that leaves `axes` clears it
/// (`Plot::clear_crosshair()`). That is a *hover*, not a gesture: no button is involved, and
/// a press, a wheel notch or a key stroke that moves the data under the pointer re-anchors
/// the crosshair to the same pointer position rather than clearing it -- it follows the
/// pointer, so anything the pointer did not move leaves it where the pointer is. The
/// per-signal values a readout shows are the plot's (`PlotLayout::crosshair`), not this
/// layer's: the controller says where the pointer is and nothing about what a crosshair
/// means.
///
/// ## Zoom and pan on a live x axis (spec §12)
///
/// An x axis armed with `XFollow::Live` keeps following through every gesture
@@ -262,6 +274,16 @@ class Controller {
/// leave alone, the bounds and the grabbed data x a body move needs to preserve
/// the width -- so a changed pointer motion never accumulates error.
bool begin_handle_drag(const PointerEvent& e);
/// Places the plot's crosshair under the pointer, or clears it when the pointer is not
/// over the data area (spec §15). Called after every event that can move the pointer *or*
/// the data under it -- a pointer event, a wheel notch, a key stroke, a viewport change --
/// so the crosshair is where the pointer is and reads the data the frame will draw, and
/// never at a coordinate a zoom has already moved out from under it.
///
/// A press does not clear it: the crosshair follows the pointer, and a pointer that is
/// still over the axes is still pointing at whatever gesture just started. The pointer is
/// the only thing that clears it, by leaving `axes`.
void update_crosshair(f32 px, f32 py);
/// Publishes what the pointer is on to the plot, for the renderer to highlight
/// (see HotHandles). Called after every event that can move the geometry under the
/// pointer -- a pointer event, a wheel notch, a key stroke -- so hover and grab are
+1
View File
@@ -48,6 +48,7 @@
#include <plot/plot/annotation.hpp>
#include <plot/plot/axis.hpp>
#include <plot/plot/crosshair.hpp>
#include <plot/plot/cursor.hpp>
#include <plot/plot/figure.hpp>
#include <plot/plot/grid.hpp>
+198
View File
@@ -0,0 +1,198 @@
#pragma once
#include <string>
#include <vector>
#include <plot/core/rect.hpp>
#include <plot/core/types.hpp>
#include <plot/data/series.hpp>
#include <plot/math/vec2.hpp>
namespace plot {
/// The crosshair's state: where it is, which of its two lines it draws, and whether the
/// per-signal readout is wanted.
///
/// It is a *hover* instrument, not a cursor: a cursor is put where the caller wants it and
/// stays there, while the crosshair follows the pointer and exists only while the pointer
/// is over the data area. So it is plain state rather than an element of a collection --
/// there is at most one per plot, and `Plot::set_crosshair()` is what places it.
///
/// **Its coordinates are data coordinates of the primary axis pair** (x axis 0, y axis 0),
/// like a `Region`'s bounds and the grid: one hover is one instant and one value, whatever
/// the strips around the axes happen to be. `vertical`/`horizontal` say which lines are
/// drawn -- the single-coordinate `Plot::set_crosshair()` leaves the vertical one only,
/// and a `Figure` is always vertical-only because its rows share x and not y.
///
/// **The readout's values are interpolated, and deliberately so.** The y a row reports for
/// a trace is *linear interpolation between the two samples adjacent to `x`*, not a sample
/// and not an interval statistic -- see `crosshair_span()` for the rule stated in full, and
/// for why it is the opposite convention from `Series::reduce()`/`sum()`/`sumsq()`.
struct Crosshair {
/// Whether the crosshair is drawn at all. Set by `Plot::set_crosshair()`; cleared by
/// `Plot::clear_crosshair()`, which `input::Controller` calls when the pointer leaves the
/// axes.
bool active = false;
/// Draw the vertical line (the column of `x`). On for both `set_crosshair()` overloads.
bool vertical = true;
/// Draw the horizontal line (the row of `y`). On for the two-coordinate overload only:
/// a crosshair placed at one coordinate has no second value to mark a row with.
bool horizontal = true;
/// List the visible traces and their interpolated values in a panel beside the pointer.
/// On by default, and free to leave on: a plot with no visible trace has no rows to
/// measure and the panel is then not placed at all.
bool show_readout = true;
/// The data x of the vertical line, on x axis 0.
f64 x = 0.0;
/// The data y of the horizontal line, on y axis 0. Meaningless for a vertical-only
/// crosshair, which draws no row and anchors its readout at the top of the axes.
f64 y = 0.0;
};
/// The two samples of a uniform-grid series that a data `x` lies between: what the
/// crosshair's lookup needs, and all it needs.
///
/// `begin` is the index of the last sample at or before `x`, `end` the index of the first
/// sample at or after it, and `t` where `x` sits between them (`0` at `begin`, `1` at
/// `end`). `end - begin` is therefore 0 when `x` lands exactly on a sample -- including the
/// two ends of the window, which are samples -- and 1 otherwise, so a caller reads **at
/// most two samples** and never scans: the span is one division and one floor of index
/// arithmetic, which is O(1) in the series' length. The crosshair runs on every pointer
/// move, so that is a requirement rather than a nicety.
///
/// **Interpolated, not exact.** The value a caller builds from this span by
/// `crosshair_interpolate()` between the two samples is the signal *at* `x`, which is
/// generally between two samples -- the opposite convention from `Series::reduce()`,
/// `sum()` and `sumsq()`, which are **exact over a sample range**. The two conventions sit
/// next to each other in this library on purpose and they do not agree: `reduce()` answers
/// "what are the samples' extremes over this interval", this answers "what is the signal at
/// this instant". A readout that pretended to be exact would be quoting an extreme or a
/// mean of a window as if it were the value under the pointer.
///
/// `valid` is false -- and every field left at its default -- for an **empty series**, a
/// non-positive `dx()`, a non-finite `x`, and an `x` strictly outside the series' window
/// `[x0, x0 + (size-1)*dx]`. An out-of-window x yields *no* value rather than the nearest
/// sample's, which would be a clamped lie about the data under the pointer; the readout row
/// it belongs to says so instead (`CrosshairRow::valid`).
struct CrosshairSpan {
/// Index of the last sample whose x is at or before the query.
u64 begin = 0;
/// Index of the first sample whose x is at or after the query: `begin` when the query
/// lands on a sample, `begin + 1` between two of them.
u64 end = 0;
/// The query's position between the two samples, in `[0, 1]`.
f64 t = 0.0;
bool valid = false;
};
/// The sample span `x` falls in, on `series`; see `CrosshairSpan` for the rule, for the
/// interpolation convention it is half of, and for the cases that yield `valid == false`.
///
/// O(1) and allocation-free: a subtraction, a division and a floor. The series' own sample
/// fetch is the caller's -- `Plot::crosshair_value()` reads the span through
/// `Series::gather()` into a scratch buffer it owns, so no lookup on the pointer path
/// allocates.
CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept;
/// The interpolated y at `t` between two samples: `y_begin` at `t == 0`, `y_end` at
/// `t == 1`. The one place the interpolation's arithmetic lives, so the lookup's rule and
/// its documentation cannot drift apart.
inline f64 crosshair_interpolate(f64 y_begin, f64 y_end, f64 t) noexcept {
return y_begin + (y_end - y_begin) * t;
}
/// One trace's line of the readout: which trace it is about, the value under the pointer
/// and the boxes the renderer stages.
///
/// **Every visible trace gets a row, named or not** -- the readout is about what is under
/// the pointer, so the legend's rule of skipping unnamed traces does not apply here. An
/// unnamed trace's row carries a stable positional label instead (`label`, built by
/// `crosshair_unnamed_label()`), so the rows stay identifiable without a name being
/// invented for the trace.
///
/// A row whose trace has no sample at the crosshair's x -- its series does not reach that
/// far, or holds nothing -- is `valid == false` and carries **no value text**: the trace is
/// still listed, because it *is* one of the signals under the pointer, but no number is
/// fabricated for it and no marker is drawn at a y that does not exist.
struct CrosshairRow {
/// Index of the trace this row is about: `Plot::trace(trace)` is what the label, the
/// swatch colour and the marker position come from.
u32 trace = 0;
/// False when the trace's series has no sample at the crosshair's x (see the struct
/// comment). `y`, `value` and `marker` are then meaningless.
bool valid = false;
/// True when the row's chrome was placed inside the panel. The panel is clamped to the
/// axes and drops rows from the end when they do not fit -- exactly as a legend drops
/// entries -- so a row that is not listed is still a listed *trace*, with a marker, just
/// not a line in a panel that has no room for it.
bool listed = false;
/// The trace's interpolated y at the crosshair's x, on the trace's own y axis. Only
/// meaningful when `valid`.
f64 y = 0.0;
/// The trace's name, or the positional placeholder of an unnamed one.
std::string label;
/// The formatted value, written in the trace's y axis' own tick format (`Axis::format_value()`,
/// so a readout reads exactly like the labels beside it), and empty when `!valid`.
std::string value;
/// The trace's intersection with the crosshair, in framebuffer pixels: the pen the
/// renderer draws the marker at. Placed through the *trace's own* axis pair, so the
/// marker is where that trace is actually drawn. Meaningless when `!valid`.
Vec2 marker{};
/// The swatch's box, `Legend::swatch_w` x `Legend::swatch_h` centred in the row. Empty
/// when `!listed`.
Rect swatch{};
/// The label's line box: its top-left is the pen the run is staged at and its height one
/// text line. Empty when `!listed`.
Rect label_box{};
/// The value's line box, one gutter (the legend's padding) after the label's. Empty when
/// `!listed` or `!valid`: a row with no value stages no run.
Rect value_box{};
};
/// The measured crosshair: the two lines' stroke boxes, the readout panel and one row per
/// visible trace, in the same device-pixel space as `PlotLayout` and filled alongside it by
/// `Plot::layout()`.
///
/// It is a member of `PlotLayout` rather than a second call, for the legend's reason: the
/// numbers a test asserts and the numbers the renderer draws are one set. Every rect is
/// **inside `PlotLayout::axes`** by construction -- a line's stroke box is held half a
/// stroke inside the rect, and the panel is placed beside the pointer, flipped when it
/// would cross an edge and then clamped, so no crosshair pixel can land on a label strip or
/// a neighbouring figure row. Task 1's axes clip is the second line of defence, not the
/// first.
///
/// Everything is default -- no lines, no panel, no rows -- when the crosshair is not
/// active, and the rows alone are empty when there is no visible trace.
struct CrosshairLayout {
/// True when the crosshair was active for this layout.
bool active = false;
/// The vertical line's stroke box: `Style::line_width_px` wide in device pixels, as tall
/// as `axes`, centred on the column of `Crosshair::x`. Zero-sized when the vertical line
/// is not drawn.
Rect vertical{};
/// The horizontal line's stroke box, the same instrument turned a quarter turn. Zero-sized
/// when the horizontal line is not drawn.
Rect horizontal{};
/// The readout panel, inside `axes`. Zero-sized when there is nothing to read out (no
/// visible trace, nothing to measure text with, no room for even one row), when
/// `Crosshair::show_readout` is off, or when the panel could not be placed inside the
/// axes at all.
Rect panel{};
/// One row per visible trace, in trace order -- see `CrosshairRow`. Present whether or
/// not the panel could list them, so the markers are drawn even in a plot too small to
/// hold a panel.
std::vector<CrosshairRow> rows;
bool empty() const noexcept { return !active; }
};
/// The label an **unnamed** trace's readout row carries: `#` and the trace's position, e.g.
/// `#2`.
///
/// Positional on purpose. The readout lists a trace because it is under the pointer, not
/// because it has an identity, so an unnamed trace needs *some* stable caption or its rows
/// would be indistinguishable -- and inventing a name for it (or borrowing a neighbour's)
/// would be a claim about the data that nothing supports. The position is derived from the
/// trace id alone, so the same trace keeps the same caption frame after frame.
std::string crosshair_unnamed_label(u32 trace);
} // namespace plot
+21
View File
@@ -139,6 +139,27 @@ class Figure {
const FigureLayout& layout();
const FigureLayout& layout() const noexcept { return layout_; }
// --- the crosshair ----------------------------------------------------
/// Places one crosshair x on **every** row (spec §15): the same data coordinate, so each
/// row's readout lists its own traces at that instant and the rows read as one hover
/// rather than N. That is what "synced if multiple plots available" means -- one pointer
/// position, one x, N rows of values -- and it is the figure's to do rather than a host's
/// because only the figure knows its rows.
///
/// Vertical-only, deliberately: the rows of a figure share *x* (`Figure`'s whole reason
/// for stacking them and linking their x windows) and not y -- row 1 may be volts and row
/// 2 degrees -- so a horizontal line across the figure would be a value that means
/// different things in each row. A row whose series has no sample at `x` lists the trace
/// with no value rather than inventing one.
///
/// A row is placed with `Plot::set_crosshair(x, active)`, which refuses a coordinate that
/// is not a number; such a call therefore leaves every row's crosshair as it was.
void set_crosshair(f64 x, bool active);
/// Clears the crosshair on every row. A row that never had one is unaffected.
void clear_crosshair();
// --- the x link -------------------------------------------------------
/// A new link group, empty. Groups are numbered from 1 -- 0 is the unlinked
+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_;
+12
View File
@@ -487,6 +487,18 @@ class Renderer {
/// them again, so the panel a test asserts and the panel this draws are the same
/// numbers.
void draw_legend(const Plot& plot, const PlotLayout& layout);
/// The crosshair: the two lines, a marker per trace, and the readout panel with a swatch and
/// a value per listed row. Every rect -- the two stroke boxes, the panel, the markers'
/// positions and the rows' boxes -- comes from `PlotLayout::crosshair`, which placed it
/// inside `axes`; this stages exactly that geometry, so the crosshair a test measures and the
/// crosshair a frame paints are one set of numbers. Clipped to `axes` with the rest of the
/// interior passes, which is what keeps a marker for an off-scale sample inside the rect.
///
/// The panel is a backplate like the legend's, so it is drawn after the lines and the
/// markers and before any of the frame's text. The rows' values were formatted by the plot
/// (in the trace's own y axis' format) and are staged here as they stand -- this layer
/// formats nothing.
void draw_crosshair(const Plot& plot, const PlotLayout& layout);
/// The built-in backend the renderer owns, and the face the frames draw with:
/// `set_text_backend()` writes `font_`, which starts here and comes back here
+7
View File
@@ -41,6 +41,13 @@ struct Style {
Color axis_text = Color::hex(0xC8D0DC);
Color cursor = Color::hex(0xFFB020);
Color measure_fill = Color::hex(0xFFB020, 0.15f);
/// The crosshair's colour: its two lines, its markers' own colour excepted, and the
/// readout panel's border (spec §15). Deliberately **not** `cursor`: the cursor accent is
/// an orange that a caller's own cursors, a region's bounds and its fill all already use,
/// while the crosshair is a passive hover instrument that has to read as *chrome* over a
/// dense trace rather than as another handle to grab. A pale cyan does that against the
/// dark default theme, and it is a colour no default element of a frame shares.
Color crosshair = Color::hex(0xB8E8FF);
// --- the scale ----------------------------------------------------------