From 63f9655ae158a418bf1faf49510fc39c402fbdf8 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 28 Sep 2026 17:18:43 +0200 Subject: [PATCH] feat(plot): horizontal rulers alongside the vertical cursors --- include/plot/input/controller.hpp | 15 ++++- include/plot/plot/axis.hpp | 8 +++ include/plot/plot/cursor.hpp | 55 ++++++++++++++-- include/plot/plot/plot.hpp | 62 +++++++++++++++++- include/plot/render/renderer.hpp | 12 ++++ src/input/controller.cpp | 25 ++++++- src/plot/axis.cpp | 13 +++- src/plot/plot.cpp | 87 +++++++++++++++++++++++-- src/render/renderer.cpp | 61 ++++++++++++++--- tests/test_controller.cpp | 100 ++++++++++++++++++++++++++++ tests/test_plot.cpp | 105 ++++++++++++++++++++++++++++++ 11 files changed, 514 insertions(+), 29 deletions(-) diff --git a/include/plot/input/controller.hpp b/include/plot/input/controller.hpp index bcae5b1..806ce1d 100644 --- a/include/plot/input/controller.hpp +++ b/include/plot/input/controller.hpp @@ -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 diff --git a/include/plot/plot/axis.hpp b/include/plot/plot/axis.hpp index cce4d7d..411f259 100644 --- a/include/plot/plot/axis.hpp +++ b/include/plot/plot/axis.hpp @@ -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 diff --git a/include/plot/plot/cursor.hpp b/include/plot/plot/cursor.hpp index f9171ea..438d33c 100644 --- a/include/plot/plot/cursor.hpp +++ b/include/plot/plot/cursor.hpp @@ -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; }; diff --git a/include/plot/plot/plot.hpp b/include/plot/plot/plot.hpp index 46f16f1..6ca7b50 100644 --- a/include/plot/plot/plot.hpp +++ b/include/plot/plot/plot.hpp @@ -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 diff --git a/include/plot/render/renderer.hpp b/include/plot/render/renderer.hpp index f716a02..cae20fd 100644 --- a/include/plot/render/renderer.hpp +++ b/include/plot/render/renderer.hpp @@ -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 diff --git a/src/input/controller.cpp b/src/input/controller.cpp index 8d46b47..ae30b1a 100644 --- a/src/input/controller.cpp +++ b/src/input/controller.cpp @@ -502,6 +502,21 @@ void Controller::move_cursor(const PointerEvent& e) { if (drag_cursor_ == kInvalid) return; const u32 id = static_cast(drag_cursor_); 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(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(e.x))); } @@ -590,8 +605,14 @@ u64 Controller::cursor_at(f32 px, f32 py) const { for (u32 i = 0; i < plot_->cursor_count(); ++i) { const Cursor& cursor = plot_->cursor(i); if (!cursor.visible) continue; - const f32 sx = static_cast(plot_->x_transform(cursor.axis_x).to_screen_x(cursor.x)); - const f32 d = std::fabs(sx - px); + // One hit test for both orientations, because a cursor *is* one line across the axes: + // 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 (best == kInvalid || d < best_d) { best = i; diff --git a/src/plot/axis.cpp b/src/plot/axis.cpp index 1564c00..5ccffb7 100644 --- a/src/plot/axis.cpp +++ b/src/plot/axis.cpp @@ -2,15 +2,24 @@ 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) { generate_ticks(range, px, min_px, ticks, include_minors); if (format != TickFormat::Time) return; // 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]; for (Tick& tick : ticks) { if (!tick.major) continue; - format_time(tick.value, buf, sizeof buf); + format_value(tick.value, buf, sizeof buf); tick.label = buf; } } diff --git a/src/plot/plot.cpp b/src/plot/plot.cpp index 9dccba7..179358a 100644 --- a/src/plot/plot.cpp +++ b/src/plot/plot.cpp @@ -160,6 +160,8 @@ u32 Plot::add_trace(const TraceDesc& desc) { u32 Plot::add_cursor(f64 x, Color color) { 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.color = color; cursor.x = snapped_x(cursor); @@ -168,6 +170,19 @@ u32 Plot::add_cursor(f64 x, Color color) { return static_cast(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(cursors_.size() - 1); +} + u32 Plot::add_measure(u32 cursor_a, u32 cursor_b, u32 trace) { MeasureSlot slot; slot.cursor_a = cursor_a; @@ -221,6 +236,18 @@ const Cursor& Plot::cursor(u32 id) const { 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(y_transform(cursor.axis_y).to_screen_y(cursor.y)); + } + return static_cast(x_transform(cursor.axis_x).to_screen_x(cursor.x)); +} + const Annotation& Plot::annotation(u32 id) const { static const Annotation 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) { if (id >= cursors_.size()) return; - cursors_[id].x = x; - cursors_[id].x = snapped_x(cursors_[id]); + Cursor& cursor = 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(); } void Plot::set_cursor_snap(u32 id, SnapMode mode) { if (id >= cursors_.size()) return; - cursors_[id].snap = mode; - cursors_[id].x = snapped_x(cursors_[id]); + Cursor& cursor = 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(); } +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 { if (id >= measures_.size()) return invalid_measurement(); 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; } +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& 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 n = 0; for (const Axis& a : x_axes_) { diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index cbcd32b..76ba25a 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -920,7 +920,9 @@ void Renderer::draw_traces(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 bool log_x = plot.x_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 // 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; const u64 id = static_cast(i); const bool on = id == hot.cursor || id == hot.grabbed_cursor; - const Transform transform = plot.x_transform(cursor.axis_x); - const f32 px = static_cast(transform.to_screen_x(cursor.x)); - // A cursor scrolled out of the visible x range is off the plot: drawing it - // would put a marker over the label strip. - if (px < layout.axes.x || px >= layout.axes.right()) continue; + // The two orientations differ in exactly two things: which coordinate places the + // stroke (and which axis maps the span it is drawn along), and where the label is + // written. Everything else -- the skip at the far edge of the axes, the highlight, the + // text path -- is this one loop's, which is the point of one type rather than two. + 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, - log_y); - flush_lines(mapping(plot, cursor.axis_x, 0), + if (horizontal) { + stage_segment(x_range.min, cursor.y, x_range.max, cursor.y, log_x, + 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, style_.device(on ? style_.handle_width_px : style_.line_width_px)); - if (!cursor.label.empty()) { - stage_text({px + style_.device(3.0f), layout.axes.y + style_.device(2.0f)}, cursor.label.c_str()); + draw_cursor_label(plot, layout, cursor, at); + } +} + +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()); } } diff --git a/tests/test_controller.cpp b/tests/test_controller.cpp index 7b17af2..3f8eb18 100644 --- a/tests/test_controller.cpp +++ b/tests/test_controller.cpp @@ -759,6 +759,14 @@ f32 column_of(const plot::Plot& p, u32 axis, f64 data) { return static_cast(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(row)); +} +f32 row_of(const plot::Plot& p, u32 axis, f64 data) { + return static_cast(p.y_transform(axis).to_screen_y(data)); +} + 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}); } @@ -823,6 +831,98 @@ PLOT_TEST(cursor_is_grabbed_without_a_modifier) { 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 /// reaches the pan exactly as it did before, with and without ctrl, and neither the /// cursor nor the region under the view is disturbed. diff --git a/tests/test_plot.cpp b/tests/test_plot.cpp index fe754dc..c3e4543 100644 --- a/tests/test_plot.cpp +++ b/tests/test_plot.cpp @@ -501,6 +501,111 @@ PLOT_TEST(inverted_and_out_of_range_measurements_are_invalid) { 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& 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 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) { // All-negative trace bounds: nothing to show on a log axis, so the fit is // refused and the caller's (positive) range survives untouched.