feat(plot): horizontal rulers alongside the vertical cursors
This commit is contained in:
1 parent
2d819c4228
commit
63f9655ae1
11 files changed
+514
-29
No files matched your search
@@ -502,6 +502,21 @@ void Controller::move_cursor(const PointerEvent& e) {
|
||||
if (drag_cursor_ == kInvalid) return;
|
||||
const u32 id = static_cast<u32>(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<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)));
|
||||
}
|
||||
|
||||
@@ -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<f32>(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;
|
||||
|
||||
+11
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+83
-4
@@ -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_) {
|
||||
|
||||
+51
-10
@@ -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<u64>(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<f32>(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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user