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

+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));
}
/// 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) {
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.
+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
}
/// 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) {
// All-negative trace bounds: nothing to show on a log axis, so the fit is
// refused and the caller's (positive) range survives untouched.