Files
plotter/include/plot/plot/crosshair.hpp
T
Martino Ferrari c960decaea feat(plot): the readout marker snaps to the sample it quotes, and the value mode is a choice
The mark was placed at the *pointer's* x with the quoted sample's y, so with the
nearest-sample readout it sat off the datum horizontally -- and further off the deeper
the zoom. `CrosshairRow` now carries the data `x` the value belongs to and the marker is
placed from it, so in `NearestSample` the mark lands on the sample the panel is quoting.
`Style::crosshair_marker_px` (9) and `crosshair_marker_width` (1.5) replace the
renderer's hard-coded 3 px arm and 1 px stroke: a snapped mark lands among a trace's own
sample dots (`marker_px`, 3) and at that size would read as one more sample rather than
as the answer to the question the hover asks.

`CrosshairValue` makes the previous behaviour reachable rather than deleted. The two
modes answer different questions -- `NearestSample` "which datum is under the pointer",
`Interpolated` "what is the signal at this instant" -- and only the caller knows which it
is asking, so the plot offers both with the sample as the default.
`crosshair_interpolate()` is restored for it.

`Plot::crosshair_point()` is the lookup with both coordinates, public because a host that
draws instruments of its own needs the same snap and a second rule for it would drift
from this one; `crosshair_value()` delegates to it.
2026-10-05 14:36:47 +02:00

309 lines
18 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#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 {
/// A per-`Crosshair` override of the three style knobs spec §17 adds, in **logical**
/// pixels, so one plot's crosshair can differ -- be thicker, or fatter-dashed -- without a
/// caller touching the global `render::Style` the rest of the frame draws with.
///
/// A negative value means "not overridden": the field falls back to the matching
/// `Style::crosshair_*` metric, so a default-constructed `CrosshairStyle` (every field
/// `-1`) is exactly the global style and the common case costs nothing. `width`, `dash_on`
/// and `dash_off` are independent, so a caller may override only the dash, or only the
/// width, and leave the rest to the style.
struct CrosshairStyle {
f32 width = -1.0f; ///< logical stroke width; `< 0` takes `Style::crosshair_width`
///< (the default). Only a *negative* value defers: `0` is used
///< literally and draws an invisible line.
f32 dash_on = -1.0f; ///< logical ink length; `< 0` takes `Style::crosshair_dash_on`.
f32 dash_off = -1.0f; ///< logical gap; `< 0` takes `Style::crosshair_dash_off`, and `0`
///< (once resolved) means solid.
};
/// What a crosshair readout's number *is*: one of the series' own samples, or a value
/// computed between two of them.
///
/// The two answer different questions and the right one is a property of the data, which the
/// plot cannot know — so it is the caller's to state.
///
/// * `NearestSample` answers **"which datum is under the pointer"**. The row reports the y of
/// whichever bracketing sample is nearer the crosshair's x (a tie reading the earlier), and
/// the marker is placed at **that sample's own x**, so the mark says *which* sample the
/// number came from. This is the default, and it is the honest mode for a source whose
/// samples are already averaged or decimated before they arrive: a number between two of
/// those is a second estimate of a signal that nothing measured, and at a zoom past the
/// sample spacing the polyline between two samples is a line the renderer drew, so a
/// reading off it reports the drawing.
/// * `Interpolated` answers **"what is the signal at this instant"**, by linear interpolation
/// between the two bracketing samples (`crosshair_interpolate()`). The marker stays on the
/// crosshair's own x, on the polyline, because that is where the number is. Right for a
/// continuous quantity sampled finely enough that the line between two samples is a model
/// the caller believes — a smooth analytical curve, an interpolating instrument — and
/// wrong wherever that line is an artefact of plotting.
///
/// Set per plot through `Plot::set_crosshair_value_mode()`. It reaches the readout's numbers,
/// the markers' positions and `Plot::crosshair_value()` alike, so a host and the panel beside
/// the pointer can never disagree about what the value means.
enum class CrosshairValue : u8 {
NearestSample, ///< The nearer bracketing sample's y, marked at that sample's x. Default.
Interpolated, ///< A linear blend of the two, marked at the crosshair's own x.
};
/// 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 samples by default** -- the y a row reports is the nearer of
/// the two samples adjacent to `x`, and its marker sits on that sample. `value_mode` is what
/// chooses otherwise; see `CrosshairValue` for the two questions it picks between, and
/// `crosshair_span()` for how both differ 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 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;
/// Whether a row's value is one of the series' samples or a blend of two -- see
/// `CrosshairValue`, which also says where each mode puts the marker. Set through
/// `Plot::set_crosshair_value_mode()`.
CrosshairValue value_mode = CrosshairValue::NearestSample;
/// This plot's own stroke and dash, when it wants to differ from the global style; a
/// default-constructed `CrosshairStyle` defers every field to `render::Style` (see
/// `CrosshairStyle`). Set through `Plot::set_crosshair_style()`.
CrosshairStyle style{};
};
/// 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.
///
/// **`t` is a position, not yet an answer.** It says where the query sits between the two
/// samples and nothing more; what a caller makes of it is `CrosshairValue`'s to decide. In
/// `NearestSample` -- the default -- it *selects*, `t <= 0.5` taking `begin`, so the readout
/// quotes a sample the series actually holds. In `Interpolated` it *blends*, through
/// `crosshair_interpolate()`. A plot cannot tell which is right, because the question is
/// about the data and not about the drawing; `CrosshairValue` is where that is argued.
///
/// Either way this is a different convention from `Series::reduce()`, `sum()` and `sumsq()`,
/// which are **exact over a sample range**: those answer "what are the samples'
/// extremes/total over this interval", a crosshair answers "what is the signal at this one
/// x". What differs is a statistic of many samples against a reading at one place.
///
/// `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]`. Whether it selects one of
/// them or blends both is `CrosshairValue`'s -- see the struct comment.
f64 t = 0.0;
bool valid = false;
};
/// The sample span `x` falls in, on `series`; see `CrosshairSpan` for what `t` means, for the
/// convention it is half of, and for the cases that yield `valid == false`.
///
/// O(1) and allocation-free: a subtraction, a division and a truncation. 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 linear blend of a span's two end samples at `t`: `y_begin + t * (y_end - y_begin)`,
/// which is what `CrosshairValue::Interpolated` reports.
///
/// Written as that form rather than `(1 - t) * a + t * b` so `t == 0` returns `y_begin`
/// exactly and `t == 1` returns `y_end` exactly, with no rounding of its own at the ends: a
/// query that landed on a sample must read that sample's value back unchanged, whichever mode
/// is in force. `NearestSample` does not call it at all.
f64 crosshair_interpolate(f64 y_begin, f64 y_end, f64 t) noexcept;
/// 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 data x the row's value belongs to, on the trace's own x axis: in
/// `CrosshairValue::NearestSample` the **quoted sample's own x**, which is generally not the
/// crosshair's; in `Interpolated` the crosshair's x itself, since that is where an
/// interpolated number is. `marker` is placed from this and from `y`. Only meaningful when
/// `valid`.
f64 x = 0.0;
/// The row's value, on the trace's own y axis: the nearer sample's y, or the blend of the
/// two -- see `CrosshairValue`. Only meaningful when `valid`.
f64 y = 0.0;
/// The trace's name, or the positional placeholder of an unnamed one.
std::string label;
/// What the row's value column shows. For a `valid` row it is the formatted number, written in
/// the trace's y axis' own tick format (`Axis::format_value()`, so a readout reads exactly like
/// the labels beside it); for a row whose trace has **no sample at the crosshair's x** it is
/// `kCrosshairNoValue`, the em dash. The absence is therefore *legible* -- a blank cell reads as
/// "still loading" rather than as "nothing here" -- and nothing numeric is fabricated for it.
std::string value;
/// Where the renderer draws the marker, in framebuffer pixels: the pen for `(x, y)`, placed
/// through the *trace's own* axis pair, so a trace on a secondary y axis is marked on the
/// scale it is drawn against.
///
/// It marks **the point the row reports**, which is the whole job: in
/// `CrosshairValue::NearestSample` it is *snapped to the quoted sample*, sitting on the datum
/// rather than beside it on the crosshair's column, so the mark says which of the samples
/// under the pointer the number came from. (Placing it at the pointer's x with the sample's y
/// put it off the data in both coordinates at a deep zoom -- a mark on nothing.) In
/// `Interpolated` it stays on the crosshair's column, on the polyline, which is equally where
/// *that* mode's number is. One `(x, y)` feeding both the mark and the text is what keeps them
/// from ever disagreeing.
///
/// It is drawn `Style::crosshair_marker_px` across -- **bigger than a trace's own sample
/// markers** on purpose, since a snapped mark lands among them and has to read as "this one"
/// rather than as one more sample. A mark for a sample outside the visible range is staged
/// where that sample is and left to the axes clip, rather than being dropped by a range test
/// of the drawing layer's own. 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, right-aligned on the panel's inner edge and one gutter's worth of
/// measurement away from the label's own box. Empty when `!listed`: a row that is not in the panel
/// stages neither run, while a listed row always stages something -- the number, or
/// `kCrosshairNoValue`.
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 stroke width the two lines are drawn at, in **device** pixels: the crosshair's own
/// `CrosshairStyle::width` when it overrides it, else `Style::crosshair_width`, both through
/// `Style::device()` -- resolved here once, so the stroke boxes above and the renderer's
/// strokes are one number rather than two that must agree.
f32 stroke = 0.0f;
/// The dash pattern in **device** pixels: `x` the ink length, `y` the gap. `y <= 0` means
/// solid -- the state whenever no dash is asked for, which is what keeps the shared line
/// pipeline drawing every other stroke in the library unchanged. The dash is a fragment-shader
/// effect on `v_along`, so this is the only CPU-side change it makes (spec §17).
Vec2 dash{};
/// 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; }
};
/// What a listed readout row shows in its value column when its trace has **no sample at the
/// crosshair's x**: an em dash, not a blank.
///
/// The row is listed either way -- the trace *is* under the pointer, and a panel that grew and
/// shrank with the pointer would be worse to read than one that says "nothing here" -- so the
/// absence has to be *said*: a blank value cell reads as a value that has not arrived yet. The em
/// dash is in the embedded font's coverage (spec §2 covers U+2013/U+2014), it is not a number, and
/// it cannot be mistaken for one.
inline constexpr const char* kCrosshairNoValue = "\u2014";
/// 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