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

+83 -4
View File
@@ -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<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) {
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<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 {
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<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 n = 0;
for (const Axis& a : x_axes_) {