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 /// 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 /// 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 /// 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()` /// 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 /// 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 /// 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_; } DragMode drag_mode() const noexcept { return mode_; }
/// The current box-zoom selection in pixels; meaningful while BoxZoom. /// The current box-zoom selection in pixels; meaningful while BoxZoom.
Rect selection_rect() const noexcept { return selection_; } Rect selection_rect() const noexcept { return selection_; }
/// The cursor under the pointer, or `kInvalid` when the pointer is outside the /// The cursor under the pointer -- a vertical marker or a horizontal ruler, whichever
/// plot area or no cursor is within a few pixels. /// 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; u64 hovered_cursor() const;
/// The cursor a drag is moving, or `kInvalid` when no cursor drag is open. The two /// 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 /// 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 /// `format` -- `TickFormat::Time` relabels the majors with `format_time`, which
/// `generate_ticks` cannot know about. /// `generate_ticks` cannot know about.
void refresh(f32 px, f32 min_px, bool include_minors = true); 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 } // 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. 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 /// Which of a cursor's two coordinates is live, and how it reads: the instrument
/// `x` is in; `label` is an optional caption drawn next to the marker. /// 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 { struct Cursor {
f64 x = 0.0; CursorOrientation orientation = CursorOrientation::Vertical;
SnapMode snap = SnapMode::Free; 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); Color color = Color::hex(0xFFB020);
std::string label; 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; bool visible = true;
}; };
+59 -3
View File
@@ -197,6 +197,16 @@ class Plot {
u32 add_y_axis(const AxisDesc& desc); u32 add_y_axis(const AxisDesc& desc);
u32 add_trace(const TraceDesc& desc); u32 add_trace(const TraceDesc& desc);
u32 add_cursor(f64 x, Color color); 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`. /// 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_measure(u32 cursor_a, u32 cursor_b, u32 trace = 0);
u32 add_annotation(const Annotation& annotation); u32 add_annotation(const Annotation& annotation);
@@ -233,6 +243,14 @@ class Plot {
const Axis& y_axis(u32 axis) const; const Axis& y_axis(u32 axis) const;
const Trace& trace(u32 id) const; const Trace& trace(u32 id) const;
const Cursor& cursor(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; const Annotation& annotation(u32 id) const;
/// The region with that id, or a default-constructed one: as everywhere else on /// 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. /// this class, a stale id reads a default rather than out of bounds.
@@ -361,12 +379,43 @@ class Plot {
// --- cursors and measurements ----------------------------------------- // --- cursors and measurements -----------------------------------------
/// Moves a cursor and invalidates the measurements that depend on cursors. /// Moves a **vertical** cursor along x, and invalidates the measurements that depend
/// A SnapMode::NearestSample cursor is pulled onto the nearest sample of the /// on cursors. A `SnapMode::NearestSample` cursor is pulled onto the nearest sample of
/// first visible trace on its x axis. /// 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); 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); 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 /// 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 /// 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 /// 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 /// `c.x` pulled onto the nearest sample when the cursor asks to snap: the same one
/// rule, applied to the axis the cursor names. /// rule, applied to the axis the cursor names.
f64 snapped_x(const Cursor& c) const; 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 /// 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 /// 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 /// 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 /// The data helpers take it too; they read the axes rect and the transforms it
/// pins. /// pins.
void draw_traces(const Plot& plot, const PlotLayout& layout); 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); 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 /// 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 /// 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 /// a region is drawn where it can be grabbed, and neither side derives the other's
+23 -2
View File
@@ -502,6 +502,21 @@ void Controller::move_cursor(const PointerEvent& e) {
if (drag_cursor_ == kInvalid) return; if (drag_cursor_ == kInvalid) return;
const u32 id = static_cast<u32>(drag_cursor_); const u32 id = static_cast<u32>(drag_cursor_);
const Cursor& cursor = plot_->cursor(id); const Cursor& cursor = plot_->cursor(id);
// A grabbed cursor mutates the coordinate its own orientation owns and nothing else, and
// each half names the one setter for it -- so a ruler dragged along y can never disturb a
// marker's x, and the dead coordinate of either is untouched by construction.
if (cursor.orientation == CursorOrientation::Horizontal) {
// Clamped to the ruler's own y axis: a pointer that has left the axes has no data under
// it, and the coordinate it extrapolates to is past the end of the axis the ruler reads
// values off -- a position where the ruler would either vanish (the renderer skips a row
// outside `axes`) or, worse, have to be dragged back by a zoom nobody asked for. Clamping
// keeps it readable at the edge it was dragged to instead. `set_cursor_y()` itself stays
// verbatim, so a *caller* that means to park a ruler off range still can.
const Axis& axis = plot_->y_axis(cursor.axis_y);
const f64 y = plot_->y_transform(cursor.axis_y).to_data_y(static_cast<f64>(e.y));
plot_->set_cursor_y(id, std::clamp(y, axis.range.min, axis.range.max));
return;
}
plot_->set_cursor_x(id, plot_->x_transform(cursor.axis_x).to_data_x(static_cast<f64>(e.x))); plot_->set_cursor_x(id, plot_->x_transform(cursor.axis_x).to_data_x(static_cast<f64>(e.x)));
} }
@@ -590,8 +605,14 @@ u64 Controller::cursor_at(f32 px, f32 py) const {
for (u32 i = 0; i < plot_->cursor_count(); ++i) { for (u32 i = 0; i < plot_->cursor_count(); ++i) {
const Cursor& cursor = plot_->cursor(i); const Cursor& cursor = plot_->cursor(i);
if (!cursor.visible) continue; if (!cursor.visible) continue;
const f32 sx = static_cast<f32>(plot_->x_transform(cursor.axis_x).to_screen_x(cursor.x)); // One hit test for both orientations, because a cursor *is* one line across the axes:
const f32 d = std::fabs(sx - px); // what the pointer has to be near is that line's own coordinate -- the column of a
// vertical marker, the row of a horizontal ruler -- measured with the same radius
// either way, which is what keeps hover and grab agreeing about a ruler exactly as
// they do about a marker. A point within reach of one of each resolves to the nearer,
// so neither orientation widens the other's target.
const bool horizontal = cursor.orientation == CursorOrientation::Horizontal;
const f32 d = std::fabs(plot_->cursor_screen_coordinate(i) - (horizontal ? py : px));
if (d > radius) continue; if (d > radius) continue;
if (best == kInvalid || d < best_d) { if (best == kInvalid || d < best_d) {
best = i; best = i;
+11 -2
View File
@@ -2,15 +2,24 @@
namespace plot { namespace plot {
void Axis::format_value(f64 v, char* buf, usize n) const {
if (format == TickFormat::Time) {
format_time(v, buf, n);
return;
}
format_si(v, buf, n);
}
void Axis::refresh(f32 px, f32 min_px, bool include_minors) { void Axis::refresh(f32 px, f32 min_px, bool include_minors) {
generate_ticks(range, px, min_px, ticks, include_minors); generate_ticks(range, px, min_px, ticks, include_minors);
if (format != TickFormat::Time) return; if (format != TickFormat::Time) return;
// generate_ticks labels with format_si; a time axis relabels its majors here, // generate_ticks labels with format_si; a time axis relabels its majors here,
// because the tick generator has no business knowing about a clock. // because the tick generator has no business knowing about a clock -- through the one
// function that says how a value on this axis is written.
char buf[64]; char buf[64];
for (Tick& tick : ticks) { for (Tick& tick : ticks) {
if (!tick.major) continue; if (!tick.major) continue;
format_time(tick.value, buf, sizeof buf); format_value(tick.value, buf, sizeof buf);
tick.label = buf; tick.label = buf;
} }
} }
+83 -4
View File
@@ -160,6 +160,8 @@ u32 Plot::add_trace(const TraceDesc& desc) {
u32 Plot::add_cursor(f64 x, Color color) { u32 Plot::add_cursor(f64 x, Color color) {
Cursor cursor; Cursor cursor;
// The default orientation: a marker at a data x. `set_cursor_x()` writes `x` for this
// orientation and refuses it for the other (see CursorOrientation).
cursor.x = x; cursor.x = x;
cursor.color = color; cursor.color = color;
cursor.x = snapped_x(cursor); cursor.x = snapped_x(cursor);
@@ -168,6 +170,19 @@ u32 Plot::add_cursor(f64 x, Color color) {
return static_cast<u32>(cursors_.size() - 1); return static_cast<u32>(cursors_.size() - 1);
} }
u32 Plot::add_h_ruler(f64 y, Color color) {
Cursor ruler;
ruler.orientation = CursorOrientation::Horizontal;
ruler.y = y;
ruler.color = color;
ruler.y = snapped_y(ruler);
cursors_.push_back(ruler);
// No mark_measures_dirty(): a measurement is read from two *x* coordinates (see
// MeasureSlot, and `measurement()`'s own sample fetch), and a ruler holds neither, so
// no cached result can have gone stale by adding one.
return static_cast<u32>(cursors_.size() - 1);
}
u32 Plot::add_measure(u32 cursor_a, u32 cursor_b, u32 trace) { u32 Plot::add_measure(u32 cursor_a, u32 cursor_b, u32 trace) {
MeasureSlot slot; MeasureSlot slot;
slot.cursor_a = cursor_a; slot.cursor_a = cursor_a;
@@ -221,6 +236,18 @@ const Cursor& Plot::cursor(u32 id) const {
return id < cursors_.size() ? cursors_[id] : fallback; return id < cursors_.size() ? cursors_[id] : fallback;
} }
f32 Plot::cursor_screen_coordinate(u32 id) const {
if (id >= cursors_.size()) return 0.0f;
const Cursor& cursor = cursors_[id];
// Which coordinate is the marker's, decided once, here: the axis reached for is the one
// the cursor's own orientation names, and the other orientation's coordinate is not
// consulted at all -- a ruler is never placed by a dead x.
if (cursor.orientation == CursorOrientation::Horizontal) {
return static_cast<f32>(y_transform(cursor.axis_y).to_screen_y(cursor.y));
}
return static_cast<f32>(x_transform(cursor.axis_x).to_screen_x(cursor.x));
}
const Annotation& Plot::annotation(u32 id) const { const Annotation& Plot::annotation(u32 id) const {
static const Annotation fallback{}; static const Annotation fallback{};
return id < annotations_.size() ? annotations_[id] : fallback; return id < annotations_.size() ? annotations_[id] : fallback;
@@ -473,18 +500,46 @@ void Plot::autoscale_axes() {
void Plot::set_cursor_x(u32 id, f64 x) { void Plot::set_cursor_x(u32 id, f64 x) {
if (id >= cursors_.size()) return; if (id >= cursors_.size()) return;
cursors_[id].x = x; Cursor& cursor = cursors_[id];
cursors_[id].x = snapped_x(cursors_[id]); // `x` is the vertical cursor's coordinate and nobody else's: a ruler's `x` is dead, and
// writing it would leave the struct holding two coordinates that disagree about where the
// cursor is (see CursorOrientation).
if (cursor.orientation != CursorOrientation::Vertical) return;
cursor.x = x;
cursor.x = snapped_x(cursor);
mark_measures_dirty(); mark_measures_dirty();
} }
void Plot::set_cursor_snap(u32 id, SnapMode mode) { void Plot::set_cursor_snap(u32 id, SnapMode mode) {
if (id >= cursors_.size()) return; if (id >= cursors_.size()) return;
cursors_[id].snap = mode; Cursor& cursor = cursors_[id];
cursors_[id].x = snapped_x(cursors_[id]); // The vertical snap, on a vertical cursor: a ruler's snap is `HRulerSnap`, and the two
// must not drift into meaning each other's job -- `NearestSample` is a sample of an x
// grid, which a ruler has no notion of.
if (cursor.orientation != CursorOrientation::Vertical) return;
cursor.snap = mode;
cursor.x = snapped_x(cursor);
mark_measures_dirty(); mark_measures_dirty();
} }
void Plot::set_cursor_y(u32 id, f64 y) {
if (id >= cursors_.size()) return;
Cursor& ruler = cursors_[id];
if (ruler.orientation != CursorOrientation::Horizontal) return;
ruler.y = y;
ruler.y = snapped_y(ruler);
// No mark_measures_dirty(), for `add_h_ruler()`'s reason: a measurement is read from two
// x coordinates, and a ruler's y is not one of them.
}
void Plot::set_cursor_h_snap(u32 id, HRulerSnap mode) {
if (id >= cursors_.size()) return;
Cursor& ruler = cursors_[id];
if (ruler.orientation != CursorOrientation::Horizontal) return;
ruler.h_snap = mode;
ruler.y = snapped_y(ruler);
}
const Measurement& Plot::measurement(u32 id) const { const Measurement& Plot::measurement(u32 id) const {
if (id >= measures_.size()) return invalid_measurement(); if (id >= measures_.size()) return invalid_measurement();
const MeasureSlot& slot = measures_[id]; const MeasureSlot& slot = measures_[id];
@@ -799,6 +854,30 @@ f64 Plot::snapped_x(const Cursor& c) const {
return c.snap == SnapMode::NearestSample ? snapped_x(c.x, c.axis_x) : c.x; return c.snap == SnapMode::NearestSample ? snapped_x(c.x, c.axis_x) : c.x;
} }
f64 Plot::snapped_y(const Cursor& c) const {
if (c.h_snap != HRulerSnap::NearestTick) return c.y;
if (!std::isfinite(c.y)) return c.y; // no tick is nearer than a value that is not one
if (c.axis_y >= y_axes_.size()) return c.y;
// The tick set of the *last* layout(), never a second tick computation: `refresh_ticks()`
// is the one place ticks are generated, and the controller lays the plot out before every
// pointer event, so on the drag path this is the frame's own set (see the header's note on
// the one-frame staleness a direct setter can see).
const std::vector<Tick>& ticks = y_axes_[c.axis_y].ticks;
if (ticks.empty()) return c.y;
const Tick* nearest = &ticks.front();
f64 best = std::fabs(nearest->value - c.y);
for (const Tick& tick : ticks) {
const f64 d = std::fabs(tick.value - c.y);
// Strictly nearer, so a value exactly between two ticks lands on the lower one rather
// than on whichever the iteration happened to visit last.
if (d < best) {
best = d;
nearest = &tick;
}
}
return nearest->value;
}
u32 Plot::titled_x_axis_count() const noexcept { u32 Plot::titled_x_axis_count() const noexcept {
u32 n = 0; u32 n = 0;
for (const Axis& a : x_axes_) { for (const Axis& a : x_axes_) {
+51 -10
View File
@@ -920,7 +920,9 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
} }
void Renderer::draw_cursors(const Plot& plot, const PlotLayout& layout) { void Renderer::draw_cursors(const Plot& plot, const PlotLayout& layout) {
const Range x_range = plot.x_range(0);
const Range y_range = plot.y_range(0); const Range y_range = plot.y_range(0);
const bool log_x = plot.x_axis(0).log;
const bool log_y = plot.y_axis(0).log; const bool log_y = plot.y_axis(0).log;
// The handles the input layer last found under the pointer: a marker hovered or // The handles the input layer last found under the pointer: a marker hovered or
// being dragged is drawn in the style's cursor colour and as a *tab*, so the cue // being dragged is drawn in the style's cursor colour and as a *tab*, so the cue
@@ -933,20 +935,59 @@ void Renderer::draw_cursors(const Plot& plot, const PlotLayout& layout) {
if (!cursor.visible) continue; if (!cursor.visible) continue;
const u64 id = static_cast<u64>(i); const u64 id = static_cast<u64>(i);
const bool on = id == hot.cursor || id == hot.grabbed_cursor; const bool on = id == hot.cursor || id == hot.grabbed_cursor;
const Transform transform = plot.x_transform(cursor.axis_x); // The two orientations differ in exactly two things: which coordinate places the
const f32 px = static_cast<f32>(transform.to_screen_x(cursor.x)); // stroke (and which axis maps the span it is drawn along), and where the label is
// A cursor scrolled out of the visible x range is off the plot: drawing it // written. Everything else -- the skip at the far edge of the axes, the highlight, the
// would put a marker over the label strip. // text path -- is this one loop's, which is the point of one type rather than two.
if (px < layout.axes.x || px >= layout.axes.right()) continue; const bool horizontal = cursor.orientation == CursorOrientation::Horizontal;
const f32 at = plot.cursor_screen_coordinate(i);
// A cursor at the *far* edge of its own range is off the plot rather than on it: its
// stroke straddles that edge by construction, so drawing it would put half a marker
// over the label strip beside it. The rule reads the same on both axes -- a marker
// scrolled past the right of its x range, a ruler dragged to the bottom of its y range
// -- and the axes clip would hold the ink in anyway, so this is about not drawing
// something that is a half-stroke and a half-nothing.
if (horizontal ? (at < layout.axes.y || at >= layout.axes.bottom())
: (at < layout.axes.x || at >= layout.axes.right())) {
continue;
}
stage_segment(cursor.x, y_range.min, cursor.x, y_range.max, plot.x_axis(cursor.axis_x).log, if (horizontal) {
log_y); stage_segment(x_range.min, cursor.y, x_range.max, cursor.y, log_x,
flush_lines(mapping(plot, cursor.axis_x, 0), plot.y_axis(cursor.axis_y).log);
} else {
stage_segment(cursor.x, y_range.min, cursor.x, y_range.max, plot.x_axis(cursor.axis_x).log,
log_y);
}
flush_lines(horizontal ? mapping(plot, 0, cursor.axis_y) : mapping(plot, cursor.axis_x, 0),
on ? style_.cursor : cursor.color, on ? style_.cursor : cursor.color,
style_.device(on ? style_.handle_width_px : style_.line_width_px)); style_.device(on ? style_.handle_width_px : style_.line_width_px));
if (!cursor.label.empty()) { draw_cursor_label(plot, layout, cursor, at);
stage_text({px + style_.device(3.0f), layout.axes.y + style_.device(2.0f)}, cursor.label.c_str()); }
}
void Renderer::draw_cursor_label(const Plot& plot, const PlotLayout& layout, const Cursor& cursor,
f32 at) {
const f32 gap = style_.device(3.0f);
if (cursor.orientation == CursorOrientation::Horizontal) {
// A ruler's text is the value it is *for*, written the way the y axis writes its own
// tick labels, so a value read off a ruler reads exactly like the tick beside it. The
// caller's caption, when there is one, is staged first on the same line, so neither the
// caption nor the value has to be dropped -- and both stay one text run's worth of
// glyphs, since the pen advances per glyph either way.
const Vec2 pen{layout.axes.x + gap, at + style_.device(2.0f)};
char value[32];
plot.y_axis(cursor.axis_y).format_value(cursor.y, value, sizeof value);
if (cursor.label.empty()) {
stage_text(pen, value);
return;
} }
stage_text(pen, cursor.label.c_str());
stage_text({pen.x + font_backend_.measure(cursor.label.c_str()) + gap, pen.y}, value);
return;
}
if (!cursor.label.empty()) {
stage_text({at + gap, layout.axes.y + style_.device(2.0f)}, cursor.label.c_str());
} }
} }
+100
View File
@@ -759,6 +759,14 @@ f32 column_of(const plot::Plot& p, u32 axis, f64 data) {
return static_cast<f32>(p.x_transform(axis).to_screen_x(data)); return static_cast<f32>(p.x_transform(axis).to_screen_x(data));
} }
/// The y twins of `data_at()`/`column_of()`: what a horizontal ruler is placed by and hit in.
f64 data_at_row(const plot::Plot& p, u32 axis, f32 row) {
return p.y_transform(axis).to_data_y(static_cast<f64>(row));
}
f32 row_of(const plot::Plot& p, u32 axis, f64 data) {
return static_cast<f32>(p.y_transform(axis).to_screen_y(data));
}
void hover(plot::input::Controller& c, f32 px, f32 py = 200.0f, u32 mods = 0) { void hover(plot::input::Controller& c, f32 px, f32 py = 200.0f, u32 mods = 0) {
c.on_pointer({px, py, 0.0f, 0.0f, plot::input::Button::Left, mods, false, false, false}); c.on_pointer({px, py, 0.0f, 0.0f, plot::input::Button::Left, mods, false, false, false});
} }
@@ -823,6 +831,98 @@ PLOT_TEST(cursor_is_grabbed_without_a_modifier) {
release(f.controller, moved_to - 40.0f, 200.0f, kCtrl); release(f.controller, moved_to - 40.0f, 200.0f, kCtrl);
} }
/// Spec §14 on the real pointer path: a horizontal ruler is grabbed by the same hit test as a
/// marker -- no modifier, the same radius -- and the drag writes the coordinate its own
/// orientation owns. The ruler's y follows the pointer's row; its x stays the dead field it is;
/// a vertical cursor elsewhere on the plot is not disturbed; and the view does not pan.
PLOT_TEST(horizontal_ruler_is_grabbed_and_dragged_along_y) {
Fixture f;
f.ay = f.plot.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
const u32 marker = f.plot.add_cursor(50.0, plot::Color::hex(0x00FF00));
const u32 ruler = f.plot.add_h_ruler(40.0, plot::Color::hex(0x35D0A8));
f.plot.layout();
const f32 column = screen_x_of(f.plot, f.ax, 70.0); // clear of the marker's own column
const f32 row = row_of(f.plot, f.ay, 40.0);
// Hover first: what the renderer highlights and what a press grabs are one answer.
hover(f.controller, column, row);
PLOT_CHECK(f.controller.hovered_cursor() == ruler);
PLOT_CHECK(f.plot.hot_handles().cursor == ruler);
press(f.controller, column, row);
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor);
PLOT_CHECK(f.controller.grabbed_cursor() == ruler);
PLOT_CHECK(f.plot.hot_handles().grabbed_cursor == ruler);
// Up is more y: the ruler follows the pointer's row through its own y axis.
move_to(f.controller, column, row - 60.0f);
PLOT_CHECK_NEAR(f.plot.cursor(ruler).y, data_at_row(f.plot, f.ay, row - 60.0f), 1e-9);
PLOT_CHECK(f.plot.cursor(ruler).y > 40.0);
// The coordinate the ruler does not own, and the marker that owns it, are both untouched.
PLOT_CHECK_NEAR(f.plot.cursor(ruler).x, 0.0, 1e-12);
PLOT_CHECK_NEAR(f.plot.cursor(marker).x, 50.0, 1e-12);
// The gesture belonged to the ruler, not to the view: no range moved.
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 0.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), 100.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, 0.0, 1e-9);
release(f.controller, column, row - 60.0f);
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::None);
PLOT_CHECK(f.controller.grabbed_cursor() == plot::kInvalid);
PLOT_CHECK(f.plot.hot_handles().grabbed_cursor == plot::kInvalid);
// And it stays where the drag left it.
PLOT_CHECK(f.plot.cursor(ruler).y > 40.0);
}
/// Nearest-first resolution *across* orientations: a pointer within reach of both a marker and a
/// ruler takes the nearer of the two, so neither widens the other's target -- which is what lets
/// one hit test serve two instruments. Each press below is 1 px from its winner and 4 px from the
/// other, both inside the 5 logical px grab radius. What the winner's drag writes is its own
/// coordinate and nothing else.
PLOT_TEST(vertical_and_horizontal_cursors_do_not_confuse_each_other) {
Fixture f;
f.ay = f.plot.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
const u32 marker = f.plot.add_cursor(50.0, plot::Color::hex(0x00FF00));
const u32 ruler = f.plot.add_h_ruler(40.0, plot::Color::hex(0x35D0A8));
f.plot.layout();
const f32 marker_x = screen_x_of(f.plot, f.ax, 50.0);
const f32 ruler_y = row_of(f.plot, f.ay, 40.0);
const f32 near = 1.0f;
const f32 far = 4.0f;
// Nearer the marker: the point is on both, and the marker is what hovers and what a press
// grabs -- the ruler's y does not move with the drag that follows.
const f32 marker_side_x = marker_x + near;
const f32 ruler_side_y = ruler_y + far;
hover(f.controller, marker_side_x, ruler_side_y);
PLOT_CHECK(f.controller.hovered_cursor() == marker);
press(f.controller, marker_side_x, ruler_side_y);
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor);
PLOT_CHECK(f.controller.grabbed_cursor() == marker);
move_to(f.controller, marker_side_x + 40.0f, ruler_side_y);
PLOT_CHECK_NEAR(f.plot.cursor(marker).x, data_at(f.plot, f.ax, marker_side_x + 40.0f), 1e-9);
PLOT_CHECK_NEAR(f.plot.cursor(ruler).y, 40.0, 1e-12);
PLOT_CHECK_NEAR(f.plot.cursor(ruler).x, 0.0, 1e-12);
release(f.controller, marker_side_x + 40.0f, ruler_side_y);
// The symmetric case: the marker put back under the pointer, and the press 1 px from the ruler
// and 4 px from the marker. The ruler takes it, and its y alone moves.
f.plot.set_cursor_x(marker, data_at(f.plot, f.ax, marker_x));
const f32 near_ruler_x = marker_x + far;
const f32 near_ruler_y = ruler_y - near;
hover(f.controller, near_ruler_x, near_ruler_y);
PLOT_CHECK(f.controller.hovered_cursor() == ruler);
press(f.controller, near_ruler_x, near_ruler_y);
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor);
PLOT_CHECK(f.controller.grabbed_cursor() == ruler);
const f64 marker_before = f.plot.cursor(marker).x;
move_to(f.controller, near_ruler_x, near_ruler_y + 30.0f);
PLOT_CHECK_NEAR(f.plot.cursor(ruler).y, data_at_row(f.plot, f.ay, near_ruler_y + 30.0f), 1e-9);
PLOT_CHECK_NEAR(f.plot.cursor(marker).x, marker_before, 1e-12);
release(f.controller, near_ruler_x, near_ruler_y + 30.0f);
PLOT_CHECK(f.plot.cursor(ruler).y < 40.0);
}
/// The other half of "a superset of the old gesture": a press that is on no handle /// The other half of "a superset of the old gesture": a press that is on no handle
/// reaches the pan exactly as it did before, with and without ctrl, and neither the /// reaches the pan exactly as it did before, with and without ctrl, and neither the
/// cursor nor the region under the view is disturbed. /// cursor nor the region under the view is disturbed.
+105
View File
@@ -501,6 +501,111 @@ PLOT_TEST(inverted_and_out_of_range_measurements_are_invalid) {
PLOT_CHECK(!p.measurement(99u).valid); // unknown measure id PLOT_CHECK(!p.measurement(99u).valid); // unknown measure id
} }
/// Spec §14: a cursor is one instrument in two orientations, so it owns exactly one coordinate
/// and exactly one snap. The dead field of the other orientation is *ignored*, not honoured -- a
/// setter that wrote it would leave the struct holding two coordinates that could disagree about
/// where the cursor is, and would let the two snaps drift into meaning each other's job
/// (`SnapMode::NearestSample` is a sample of an x grid; `HRulerSnap::NearestTick` is a tick of a
/// y axis). The store's half of the ruler, with no pointer and no renderer involved.
PLOT_TEST(a_cursor_owns_only_the_coordinate_and_snap_of_its_orientation) {
plot::data::ArraySeries s(ramp(100), 0.0, 1.0);
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ax = p.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {0.0, 99.0}, false, plot::AxisPlace::Left, false});
p.add_trace({&s, plot::Color::hex(0xFFFFFF), 1.0f, ax, ay});
const u32 marker = p.add_cursor(10.0, plot::Color::hex(0xFFB020));
const u32 ruler = p.add_h_ruler(50.0, plot::Color::hex(0x35D0A8));
// The orientation each constructor claims, and the coordinate it stored in.
PLOT_CHECK(p.cursor(marker).orientation == plot::CursorOrientation::Vertical);
PLOT_CHECK(p.cursor(ruler).orientation == plot::CursorOrientation::Horizontal);
PLOT_CHECK_NEAR(p.cursor(marker).x, 10.0, 1e-12);
PLOT_CHECK_NEAR(p.cursor(ruler).y, 50.0, 1e-12);
// The coordinate the other orientation owns, twice over: `y` is not a marker's to move and
// `x` is not a ruler's, so neither field can be written behind the orientation's back.
p.set_cursor_y(marker, 77.0);
PLOT_CHECK_NEAR(p.cursor(marker).y, 0.0, 1e-12); // still the default it was born with
PLOT_CHECK_NEAR(p.cursor(marker).x, 10.0, 1e-12);
p.set_cursor_x(ruler, 77.0);
PLOT_CHECK_NEAR(p.cursor(ruler).x, 0.0, 1e-12);
PLOT_CHECK_NEAR(p.cursor(ruler).y, 50.0, 1e-12);
// `SnapMode::NearestSample` on a ruler is ignored too: 33.37 is no sample of the x grid and no
// tick of the y axis, so an honoured sample-snap -- onto 33 or onto the nearest y tick -- would
// show up as a move, and neither did.
p.set_cursor_y(ruler, 33.37);
p.set_cursor_snap(ruler, plot::SnapMode::NearestSample);
PLOT_CHECK(p.cursor(ruler).snap == plot::SnapMode::Free);
PLOT_CHECK_NEAR(p.cursor(ruler).y, 33.37, 1e-12);
// The marker's half of the same rule: `NearestTick` does not touch its x, while its own
// `NearestSample` does -- so the two modes are live on exactly one orientation each.
p.set_cursor_x(marker, 12.37);
PLOT_CHECK_NEAR(p.cursor(marker).x, 12.37, 1e-12); // Free: stored verbatim
p.set_cursor_h_snap(marker, plot::HRulerSnap::NearestTick);
PLOT_CHECK(p.cursor(marker).h_snap == plot::HRulerSnap::Free);
PLOT_CHECK_NEAR(p.cursor(marker).x, 12.37, 1e-12);
p.set_cursor_snap(marker, plot::SnapMode::NearestSample);
PLOT_CHECK(p.cursor(marker).snap == plot::SnapMode::NearestSample);
PLOT_CHECK_NEAR(p.cursor(marker).x, 12.0, 1e-12);
}
/// `NearestTick` lands on a tick of the ruler's own y axis, and `Free` stores the value it was
/// handed -- so the two modes are told apart by what the store holds, not by a flag. The tick set
/// is the one `layout()` refreshed: ticks are generated there and nowhere else, which is why a
/// range moved without a layout snaps against the previous set. That one-frame staleness is
/// pinned here, so a later change cannot quietly reintroduce a second tick computation.
PLOT_TEST(horizontal_ruler_snaps_to_the_y_ticks_when_asked) {
plot::data::ArraySeries s(ramp(100), 0.0, 1.0);
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ay = p.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
const u32 ruler = p.add_h_ruler(0.0, plot::Color::hex(0x35D0A8));
p.layout(); // the tick set every snap below reads
const std::vector<plot::Tick>& ticks = p.y_axis(ay).ticks;
PLOT_CHECK(ticks.size() >= 3u);
// Two *adjacent* ticks, so the value between them is equal to no entry of the list at all --
// majors and minors alike: a ruler snaps onto the whole set, and no tick lies between two
// neighbours.
const f64 lo = ticks[1].value;
const f64 hi = ticks[2].value;
PLOT_CHECK(lo < hi);
const f64 between = lo + 0.6 * (hi - lo); // nearer `hi`, and equal to neither
PLOT_CHECK(!tick_at(ticks, between));
p.set_cursor_h_snap(ruler, plot::HRulerSnap::NearestTick);
p.set_cursor_y(ruler, between);
PLOT_CHECK_NEAR(p.cursor(ruler).y, hi, 1e-12); // pulled onto the nearer tick
PLOT_CHECK(tick_at(ticks, p.cursor(ruler).y));
// The same value with `Free` is not moved, and is equal to no tick: the mode is the snap, not
// the value.
p.set_cursor_h_snap(ruler, plot::HRulerSnap::Free);
p.set_cursor_y(ruler, between);
PLOT_CHECK_NEAR(p.cursor(ruler).y, between, 1e-12);
PLOT_CHECK(!tick_at(p.y_axis(ay).ticks, p.cursor(ruler).y));
// A range moved without a layout leaves the previous tick set in force -- ticks come from
// `layout()` and these stores do not recompute them -- so a y the axis now *shows* still
// snaps onto the old set. 550 lies in [500, 600] and the stale set ends at 100, so the
// snap pulls it clear out of the range the axis is drawing; letting `layout()` refresh the
// set is what makes the same y land where the axis is labelled.
const std::vector<plot::Tick> previous = p.y_axis(ay).ticks;
const f64 stale = previous.back().value;
PLOT_CHECK(stale < 500.0); // the old set has nothing to offer near 550
p.set_y_range(ay, {500.0, 600.0});
p.set_cursor_h_snap(ruler, plot::HRulerSnap::NearestTick);
p.set_cursor_y(ruler, 550.0);
PLOT_CHECK_NEAR(p.cursor(ruler).y, stale, 1e-12); // the stale set, not the axis' range
p.layout();
p.set_cursor_y(ruler, 550.0);
PLOT_CHECK(p.cursor(ruler).y >= 500.0 && p.cursor(ruler).y <= 600.0);
PLOT_CHECK(tick_at(p.y_axis(ay).ticks, p.cursor(ruler).y));
}
PLOT_TEST(auto_fit_log_axis_stays_positive_and_ordered) { PLOT_TEST(auto_fit_log_axis_stays_positive_and_ordered) {
// All-negative trace bounds: nothing to show on a log axis, so the fit is // All-negative trace bounds: nothing to show on a log axis, so the fit is
// refused and the caller's (positive) range survives untouched. // refused and the caller's (positive) range survives untouched.