feat(plot): horizontal rulers alongside the vertical cursors

This commit is contained in:
Martino Ferrari committed 2026-09-28 17:18:43 +02:00
1 parent 2d819c4228
commit 63f9655ae1
11 files changed
+514 -29

No files matched your search

+12 -3
View File
@@ -81,7 +81,15 @@ using plot::RegionGrab;
/// nearest one winning. A marker is drawn on top of everything and is hit-tested
/// the same way: the shaded band between two measurement cursors is grabbed by
/// dragging either of the cursors that bound it, because its edges *are* those
/// cursors -- no second path, no second tolerance.
/// cursors -- no second path, no second tolerance. A *horizontal* ruler is the same
/// target turned a quarter turn: the distance measured is the pointer's to the
/// ruler's row rather than to a marker's column, and it is the same radius, so a
/// point within reach of one of each resolves to the nearer and neither orientation
/// widens the other's reach. The drag that follows writes only the coordinate the
/// grabbed cursor's orientation owns -- `set_cursor_x()` for a marker,
/// `set_cursor_y()` for a ruler -- and a dragged ruler is clamped to its own y
/// axis' range, because a pointer past the top or bottom of `axes` has no data
/// under it to follow (see `move_cursor()`).
/// 2. **Regions next**, resolved nearest-first against the geometry `Plot::layout()`
/// measured: a bound within the grab radius beats the body (so the bound of a
/// wide region is grabbable even though the pointer is inside the fill), the
@@ -184,8 +192,9 @@ class Controller {
DragMode drag_mode() const noexcept { return mode_; }
/// The current box-zoom selection in pixels; meaningful while BoxZoom.
Rect selection_rect() const noexcept { return selection_; }
/// The cursor under the pointer, or `kInvalid` when the pointer is outside the
/// plot area or no cursor is within a few pixels.
/// The cursor under the pointer -- a vertical marker or a horizontal ruler, whichever
/// line the pointer is nearer -- or `kInvalid` when the pointer is outside the plot area
/// or no cursor is within a few pixels.
u64 hovered_cursor() const;
/// The cursor a drag is moving, or `kInvalid` when no cursor drag is open. The two
/// are the same answer the plot publishes for the renderer (see HotHandles), for a
+8
View File
@@ -84,6 +84,14 @@ struct Axis {
/// `format` -- `TickFormat::Time` relabels the majors with `format_time`, which
/// `generate_ticks` cannot know about.
void refresh(f32 px, f32 min_px, bool include_minors = true);
/// Writes `v` the way this axis' own tick labels are written: `format_time` on a
/// `Time` axis, `format_si` otherwise. The one rule for "how a value on this axis
/// reads", exposed so a consumer that has to caption a value the axis carries -- a
/// horizontal ruler's label -- writes it exactly as the tick beside it is written.
/// `buf` is NUL-terminated and never written past `n` bytes (both formatters
/// guarantee that).
void format_value(f64 v, char* buf, usize n) const;
};
} // namespace plot
+50 -5
View File
@@ -12,14 +12,59 @@ enum class SnapMode {
NearestSample, ///< the cursor is pulled onto the nearest sample of the trace's x grid.
};
/// A vertical marker at data x. `axis_x` names the x axis whose data coordinate
/// `x` is in; `label` is an optional caption drawn next to the marker.
/// Which of a cursor's two coordinates is live, and how it reads: the instrument
/// the plot has always had, and the same one turned a quarter turn (spec §14).
///
/// `Vertical` is a marker at a data `x` spanning the axes' *height*; `Horizontal` is
/// a ruler at a data `y` spanning their *width*. One type rather than two, because
/// the two differ in exactly one thing -- which coordinate a drag writes -- and every
/// other part of the machinery (the hit radius, the drag, the hover highlight, the
/// label path) is then one path instead of two that would have to be kept in step.
///
/// The orientation is what says which fields of `Cursor` mean anything:
///
/// | orientation | live coordinate | live snap | dead fields |
/// |--------------|-------------------|------------------------------|-------------------------|
/// | `Vertical` | `x`, on `axis_x` | `snap` (`SnapMode`) | `y`, `h_snap`, `axis_y` |
/// | `Horizontal` | `y`, on `axis_y` | `h_snap` (`HRulerSnap`) | `x`, `snap`, `axis_x` |
///
/// A dead field is *ignored*, not honoured: `Plot::set_cursor_y()` on a vertical
/// cursor and `Plot::set_cursor_x()` on a horizontal ruler change nothing, and neither
/// does the other orientation's snap setter (see `Plot::set_cursor_h_snap()`). A dead
/// field that could be written would leave the struct holding two coordinates that
/// could disagree about where the cursor is, and the two snaps free to drift into
/// meaning each other's job -- `SnapMode::NearestSample` is a sample of an *x* grid,
/// while `HRulerSnap::NearestTick` is a tick of a *y* axis.
enum class CursorOrientation {
Vertical, ///< a marker at a data x, spanning the axes' height.
Horizontal, ///< a ruler at a data y, spanning the axes' width.
};
/// How a horizontal ruler's `y` relates to its y axis' tick set.
///
/// Deliberately not a `SnapMode`: a vertical cursor snaps onto a *sample* of the
/// trace's x grid, and a series is sampled in x, so "the nearest y sample" is not a
/// thing that exists. What a y ruler has to land on is a *tick* -- one of the values
/// the y axis is labelled with.
enum class HRulerSnap {
Free, ///< the ruler stays exactly where the caller or the drag put it.
NearestTick, ///< the ruler is pulled onto the nearest tick of its y axis' tick set.
};
/// A marker at a data coordinate. Which coordinate -- and which snap -- is live, and
/// which fields are dead, is `orientation`'s to say (see CursorOrientation); `label`
/// is an optional caption drawn beside the marker, and a horizontal ruler
/// additionally draws its own value, written in its y axis' own tick format.
struct Cursor {
f64 x = 0.0;
SnapMode snap = SnapMode::Free;
CursorOrientation orientation = CursorOrientation::Vertical;
f64 x = 0.0; ///< the data x. Live for `Vertical`, dead for `Horizontal`.
f64 y = 0.0; ///< the data y. Live for `Horizontal`, dead for `Vertical`.
SnapMode snap = SnapMode::Free; ///< the vertical snap. Dead for `Horizontal`.
HRulerSnap h_snap = HRulerSnap::Free; ///< the horizontal snap. Dead for `Vertical`.
Color color = Color::hex(0xFFB020);
std::string label;
u32 axis_x = 0;
u32 axis_x = 0; ///< the x axis `x` is in. Dead for `Horizontal`.
u32 axis_y = 0; ///< the y axis `y` is in. Dead for `Vertical`.
bool visible = true;
};
+59 -3
View File
@@ -197,6 +197,16 @@ class Plot {
u32 add_y_axis(const AxisDesc& desc);
u32 add_trace(const TraceDesc& desc);
u32 add_cursor(f64 x, Color color);
/// Adds a horizontal ruler at a data y (spec §14): the same instrument as `add_cursor()`
/// turned a quarter turn. It spans the axes' width at `y`, is grabbable and draggable
/// along y with the very same hit radius, and reports `CursorOrientation::Horizontal`.
///
/// `y` is on y axis 0, as `add_cursor()`'s `x` is on x axis 0. Its snap is `HRulerSnap`,
/// not `SnapMode`: a series is sampled in x, so the nearest *tick* of the y axis is the
/// only grid a y ruler can land on (`set_cursor_h_snap()`). It takes the next free id in
/// the one `cursors_` sequence both orientations share, so a vertical cursor added after
/// it is that id plus one.
u32 add_h_ruler(f64 y, Color color);
/// Measures the interval between two cursors over the series of `trace`.
u32 add_measure(u32 cursor_a, u32 cursor_b, u32 trace = 0);
u32 add_annotation(const Annotation& annotation);
@@ -233,6 +243,14 @@ class Plot {
const Axis& y_axis(u32 axis) const;
const Trace& trace(u32 id) const;
const Cursor& cursor(u32 id) const;
/// The device coordinate a cursor's marker sits at: the column of its `x` for a vertical
/// cursor, the row of its `y` for a horizontal ruler -- the one coordinate its own
/// orientation owns, mapped through the axis that coordinate is in. The one place that
/// choice is made, so the hit test the controller runs and the stroke the renderer stages
/// cannot disagree about where a cursor is (see CursorOrientation), and a host can place
/// its own tooltip or status line on the same number. 0.0f for an id that names no cursor,
/// like every other accessor here.
f32 cursor_screen_coordinate(u32 id) const;
const Annotation& annotation(u32 id) const;
/// The region with that id, or a default-constructed one: as everywhere else on
/// this class, a stale id reads a default rather than out of bounds.
@@ -361,12 +379,43 @@ class Plot {
// --- cursors and measurements -----------------------------------------
/// Moves a cursor and invalidates the measurements that depend on cursors.
/// A SnapMode::NearestSample cursor is pulled onto the nearest sample of the
/// first visible trace on its x axis.
/// Moves a **vertical** cursor along x, and invalidates the measurements that depend
/// on cursors. A `SnapMode::NearestSample` cursor is pulled onto the nearest sample of
/// the first visible trace on its x axis.
///
/// A horizontal ruler's `x` is not a coordinate of its: this is *ignored* for one, so
/// the dead field of an orientation can never be written behind its back (see
/// CursorOrientation). `set_cursor_y()` is the ruler's half of the pair.
void set_cursor_x(u32 id, f64 x);
/// Sets the snap of a **vertical** cursor, re-placing it under the new rule at once.
/// Ignored for a horizontal ruler, whose snap is `HRulerSnap` -- see
/// `set_cursor_h_snap()`.
void set_cursor_snap(u32 id, SnapMode mode);
/// Moves a **horizontal** ruler along y, re-placing it under its own snap rule.
///
/// Ignored for a vertical cursor -- `y` is not its coordinate -- and stored verbatim for
/// a ruler, exactly as `set_cursor_x()` stores a `Free` cursor's x: a y outside the axis'
/// range is a position the *renderer* skips, not one this store refuses, which is what
/// lets a caller park a ruler off range on purpose. The *drag* is what clamps: a pointer
/// that has left the axes has no data under it, so `input::Controller` keeps a dragged
/// ruler on its axis' range rather than storing a coordinate no pointer could reach
/// (see `Controller::move_cursor()`).
void set_cursor_y(u32 id, f64 y);
/// Sets the snap of a **horizontal** ruler: `NearestTick` pulls its y onto the nearest
/// tick of its y axis' tick set -- majors and minors alike, the one list the axis
/// refreshed -- and `Free` leaves it where it is.
///
/// The tick set it snaps to is the *last* `layout()`'s, because ticks are generated there
/// and nowhere else: snapping does not recompute them, so the library keeps one tick
/// computation rather than two. On the drag path that is the frame's own set --
/// `input::Controller::on_pointer()` lays the plot out before every event it applies --
/// while a caller that moves a y range and then sets a ruler's y *without* laying out
/// snaps against the previous set, one frame stale. Laying the plot out refreshes it.
///
/// Ignored for a vertical cursor, whose snap is `SnapMode` -- see `set_cursor_snap()`.
void set_cursor_h_snap(u32 id, HRulerSnap mode);
/// The measurement for `id`. Re-evaluated only when a cursor moved, when the
/// measure was added, or when the series underneath it changed: a different
/// series, a longer one, or a sliding window whose sample count stayed the same
@@ -559,6 +608,13 @@ class Plot {
/// `c.x` pulled onto the nearest sample when the cursor asks to snap: the same one
/// rule, applied to the axis the cursor names.
f64 snapped_x(const Cursor& c) const;
/// `c.y` pulled onto the nearest tick of `c.axis_y`'s tick set when the ruler asks to
/// snap, else `c.y` -- `HRulerSnap`'s one rule and the only place a ruler's snap is
/// decided. The tick set is the last `layout()`'s (see `set_cursor_h_snap()`), and the
/// nearest of it wins with a tie going to the lower tick; an axis that has not been laid
/// out yet, one holding no ticks, and a `y` that is not a number all leave the value as
/// it is rather than inventing a tick to land on.
f64 snapped_y(const Cursor& c) const;
/// Measures every visible region into `layout_.regions`: the fill's clipped
/// interval, one grab tab per bound, and the label's line box. Also the place a
/// region's pixel geometry is derived -- the controller hit-tests what this
+12
View File
@@ -452,7 +452,19 @@ class Renderer {
/// The data helpers take it too; they read the axes rect and the transforms it
/// pins.
void draw_traces(const Plot& plot, const PlotLayout& layout);
/// The cursors, both orientations: one stroke per cursor, placed at the coordinate its own
/// orientation owns (`Plot::cursor_screen_coordinate()`) and spanning the other axis' whole
/// range, in its own colour -- or in the accent at handle width while it is hovered or
/// grabbed, which is the same cue a region's hot bound gets. A cursor at the far edge of
/// its range is skipped rather than drawn as half a stroke over the strip beside it.
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);
/// 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