crosshair: axis badges, clustered cursor labels, and the shared-y horizontal line
CI / debug (tests + all adapters) (push) Canceled after 0s
CI / coverage (floor + measured table) (push) Canceled after 0s
CI / asan (AddressSanitizer) (push) Canceled after 0s

- Axis badges: the y value in the y label strip and the x time in the x strip, each a
  full-opacity panel in the crosshair's colour, **centred on the line it names**. The tick
  text is flushed before the fill -- the over-text batch is flushed after every line batch,
  so without that flush the label behind printed through the panel (the "transparent
  backplate" defect).
- Cursor labels: collected across the cursor loop and clustered at its end, so two marks in
  one column share a panel with one line each instead of overprinting, and a label stays on
  its own mark's row.
- Figure: a split row's cells share one y (`set_share_row_y`), so the horizontal line is
  drawn in every cell of the hovered row -- one value, one scale, spanning the row -- and
  still in no other row.

Tests: `figure_crosshair_horizontal_spans_a_split_rows_cells`, plus the render-smoke
additions for the badge placement/flush and the clustered labels.
This commit is contained in:
Martino Ferrari committed 2026-11-05 17:58:49 +01:00
1 parent 2cb31cbb91
commit 1d5b286611
9 files changed
+469 -49

No files matched your search

+19
View File
@@ -91,6 +91,14 @@ struct Crosshair {
/// 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;
/// Write the two values the crosshair's lines meet the axes at as small badges in the
/// label strips -- the y value beside the y ticks, the x value beside the x ticks, each
/// in its axis' own format. On by default: it is the axis reading an oscilloscope puts
/// where the eye already is, and a host that does not want it turns it off
/// (`Plot::set_crosshair_axis_labels()`). The badges are placed only when the strip they
/// belong to exists and the line they belong to is drawn (a vertical-only crosshair has
/// no y to badge, and a row whose x strip went to a neighbour badges nothing).
bool axis_labels = 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
@@ -277,6 +285,17 @@ struct CrosshairLayout {
/// `Crosshair::show_readout` is off, or when the panel could not be placed inside the
/// axes at all.
Rect panel{};
/// The two axis badges, when `Crosshair::axis_labels` is on and the crosshair is active:
/// the y value in the **y label strip**, right-aligned on the axis line at the horizontal
/// line's height, and the x value in the **x label strip**, centred on the vertical
/// line's column -- each in its axis' own format, so a badge reads exactly like the tick
/// labels beside it. Zero-sized (and the text empty) when the flag is off, the crosshair
/// is inactive, the line they belong to is not drawn, their strip does not exist (a row
/// whose x strip went to the row below), or there is no text backend to measure them.
Rect y_axis_badge{};
std::string y_axis_text;
Rect x_axis_badge{};
std::string x_axis_text;
/// 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.
+6 -3
View File
@@ -247,9 +247,12 @@ class Figure {
/// Places the crosshair with the pointer's **plot** and that plot's local y (spec §17): the
/// shared x goes to every plot -- vertical lines everywhere, as the plain overload does -- and
/// the horizontal line is drawn **only in `hovered_row`** -- the **flattened plot index**, not
/// the figure-row index; they differ when a row holds several cells -- where its y is a value of
/// *that* plot's own scale. It is the fix for the reported defect -- a figure showing no horizontal
/// the horizontal line is drawn in `hovered_row` -- the **flattened plot index**, not the
/// figure-row index; they differ when a row holds several cells -- where its y is a value of
/// *that* plot's own scale. **When the row is a split whose cells share one y
/// (`set_share_row_y`), the line is drawn in every cell of that row**: it is one value on one
/// scale, so stopping it at the divider would hide the number the neighbouring cell is read
/// against. Rows never share y with each other, so no other row gets a line. It is the fix for the reported defect -- a figure showing no horizontal
/// line at all -- without inventing a figure-wide y, which would mean a different value in
/// every row: the line appears where the pointer is, and no row ever shows another row's y.
///
+16
View File
@@ -557,6 +557,14 @@ class Plot {
/// belongs to. An id that names no cursor is ignored, like every other setter here.
void set_cursor_color(u32 id, Color color);
/// A cursor's caption (see `Cursor::label`): a vertical marker's badge text -- a horizontal
/// ruler writes its own value and needs no caption to be readable. Rendered on a
/// backplate, and labels whose backplates meet are merged into one panel with one line per
/// label (see the renderer), which is what keeps a marker pair half a division apart
/// readable instead of a smear of two texts over one column. An empty string removes the
/// caption; an id that names no cursor is ignored, like every other setter here.
void set_cursor_label(u32 id, std::string label);
/// A cursor's stroke: its logical width (`<= 0` for the style's default) and its dash
/// (`dash.off <= 0` for solid). The instrument's own, like its colour -- see `Cursor::width`.
void set_cursor_stroke(u32 id, f32 width, Vec2 dash);
@@ -888,6 +896,14 @@ class Plot {
/// **not** part of `set_crosshair()`, so placing a hover cannot silently change it.
void set_crosshair_readout(bool on) noexcept { crosshair_.show_readout = on; }
/// Writes or clears the two **axis badges** (see `Crosshair::axis_labels`): the y value in
/// the y label strip and the x value in the x label strip, at the heights and columns the
/// crosshair's lines meet the axes at. Takes effect on the next `layout()`. **Not** part of
/// `set_crosshair()`, for the same reason the readout flag is not.
void set_crosshair_axis_labels(bool on) noexcept { crosshair_.axis_labels = on; }
/// The axis-badge flag, as set.
bool crosshair_axis_labels() const noexcept { return crosshair_.axis_labels; }
/// Overrides this plot's crosshair stroke and dash, in logical pixels, without touching the
/// global style every other plot draws with (spec §17): each field left negative in `s`
/// defers to the matching `Style::crosshair_*` metric, so `CrosshairStyle{}` (the default)
+46 -9
View File
@@ -95,6 +95,24 @@ struct FrameStats {
/// a guard -- and the frame reports it (see `last_frame_had_gl_error()`).
///
/// Not thread-safe, and confined to the thread that owns the current context.
/// One cursor label's measured text and placed badge: what `Renderer::collect_cursor_label()`
/// builds and `Renderer::draw_clustered_cursor_labels()` places and draws. A rendering
/// detail of the cursor labels' backplates, declared at namespace scope only because a
/// private nested type cannot be named in an out-of-class member definition's signature.
struct CursorLabel {
std::string text; ///< the one line the backplate carries
Color border{}; ///< the panel's border: the cursor's own colour
f32 anchor = 0.0f; ///< the coordinate the label belongs to (x for a marker, y for a ruler)
f32 w = 0.0f; ///< the measured text width
bool horizontal = false;
/// The placed pen and backplate: centred on the anchor across the mark, nudged onto a
/// free row when a close neighbour's badge is in the way, and clustered into one panel
/// by `draw_clustered_cursor_labels()`.
Vec2 pen{};
Rect box{};
};
class Renderer {
public:
Renderer() noexcept = default;
@@ -621,13 +639,22 @@ class Renderer {
/// the clip, and a range maximum that undershoots the edge by a ulp is not mistaken for
/// off the plot.
void draw_cursors(const Plot& plot, const PlotLayout& layout);
/// One cursor's label: the caller's caption for either orientation, and for a horizontal
/// ruler the value itself, written in its y axis' own tick format (`Axis::format_value()`)
/// -- the number a ruler exists to be read off. A vertical cursor's caption starts at the
/// top of the axes beside its marker; a ruler's text starts at the axes' left edge on its
/// own row, with the caption first on that line when there is one.
void draw_cursor_label(const Plot& plot, const PlotLayout& layout, const Cursor& cursor,
f32 at);
/// One cursor's label, measured rather than drawn: a horizontal ruler's line is the value
/// itself, written in its y axis' own tick format (`Axis::format_value()`) -- the number a
/// ruler exists to be read off -- with the caption first on the line when there is one; a
/// vertical cursor's line is its caption, beside the marker at the top of the axes. The
/// label is then placed (centred on its mark, stacked clear of close neighbours) and drawn
/// clustered (`draw_clustered_cursor_labels()`), which is what keeps a pair of readout
/// marks half a division apart readable: two texts over one column is a smear, one panel
/// with two lines is a readout.
void collect_cursor_label(const Plot& plot, const PlotLayout& layout, const Cursor& cursor,
f32 at, std::vector<CursorLabel>& labels);
/// Draws the placed labels' backplates -- one panel per cluster of meeting boxes, every
/// line at its own placed pen -- and stages the lines. Clustering is what keeps a pair of
/// readout marks half a division apart readable: two texts over one column is a smear, one
/// panel with two lines is a readout.
void draw_clustered_cursor_labels(const PlotLayout& layout,
const std::vector<CursorLabel>& labels);
/// The regions: the fill, one bound per edge, the optional label. Every rect comes
/// from `PlotLayout::regions` -- the same geometry the controller hit-tests -- so
/// a region is drawn where it can be grabbed, and neither side derives the other's
@@ -656,8 +683,12 @@ class Renderer {
/// outermost pixels the fill covers (the crisp-hairline rule -- a border drawn on the measured
/// rect's fractional edges is lit half-heartedly, and a frame bright on three sides and dim on the
/// fourth reads as a mistake). One helper, two callers: the border's colour is the only thing a
/// legend panel and a readout panel do not share.
void draw_readout_panel(const Rect& panel, Color border);
/// legend panel and a readout panel do not share. `background_alpha` scales the frame's own
/// background under the border: the legend and the crosshair's readout keep the shared
/// translucent backplate (what they cover stays faintly visible), while a *badge* -- a
/// cursor's value box, the crosshair's axis badges -- is read against whatever it sits on
/// (ticks, a noisy trace) and is drawn opaque.
void draw_readout_panel(const Rect& panel, Color border, float background_alpha);
/// One trace's colour swatch in `box`, opaque whatever the trace's own alpha is: a swatch drawn at
/// a trace's 15% would be the least legible thing in the frame, and the swatch is the one element
/// that says which colour an entry or a readout row *is*. The legend's entries and the crosshair's
@@ -687,6 +718,12 @@ class Renderer {
/// Drawn after every other interior pass -- the legend included -- because it is what the
/// pointer is on: a readout the legend covered would be the feature failing at its one job.
void draw_crosshair_readout(const Plot& plot, const PlotLayout& layout);
/// The crosshair's **axis badges** (`CrosshairLayout::y_axis_badge`/`x_axis_badge`): the
/// values the two lines meet the axes at, each on the shared readout backplate with its
/// border in the crosshair's colour, drawn over the tick labels they sit among. Called
/// from `draw_plot()` after the label strips are drawn, which is the only order in which a
/// badge in a strip can sit on the ticks and still be read.
void draw_crosshair_axis_badges(const PlotLayout& layout);
/// The host's box-zoom frame (`PlotLayout::selection`): a fill at `selection_fill` under a border at
/// `selection_edge`, or nothing at all when nothing was swept. Drawn last of the interior passes, so