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.
309 lines
18 KiB
C++
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
|