wip(plot): region layout, hot handles and draggable handles (build restored)

This commit is contained in:
Martino Ferrari committed 2026-09-28 13:28:51 +02:00
1 parent 7d2349555d
commit e4d48cc6c3
9 files changed
+915 -37

No files matched your search

+199 -14
View File
@@ -33,6 +33,14 @@ constexpr f32 kCursorHitRadiusLogical = 5.0f;
/// A box-zoom selection narrower than this on either side is a click. Logical for
/// the same reason: the gesture is the same hand movement on any display.
constexpr f32 kMinBoxLogical = 4.0f;
/// The narrowest region whose bounds are grabbable, in **logical** pixels. Closer
/// together than this, the two bounds are one target the pointer cannot separate:
/// an edge would win the hit test every time and the region could never be moved
/// again, so a *narrower* region is grabbed by its body -- widened to this same
/// minimum around its bounds, which keeps a collapsed region something a pointer can
/// still take hold of rather than a line. Logical like the two tolerances above, and
/// converted through the same one call.
constexpr f32 kMinRegionEdgeGrabLogical = 3.0f;
/// An axis endpoint in transform space: log10 when the axis is logarithmic, the
/// value itself otherwise. Pan and zoom arithmetic runs in this space so a log
@@ -96,6 +104,9 @@ void Controller::set_viewport(Rect px) {
// transforms read.
plot_->set_viewport(px);
plot_->layout();
// The hover the layout is part of: a handle under the pointer may be somewhere
// else now, and the highlight the renderer draws reads what this publishes.
publish_hot();
}
void Controller::on_pointer(const PointerEvent& raw) {
@@ -107,13 +118,15 @@ void Controller::on_pointer(const PointerEvent& raw) {
if (e.pressed && e.down) {
begin_drag(e);
return;
}
if (e.released) {
} else if (e.released) {
end_drag(e);
return;
} else if (e.down && mode_ != DragMode::None) {
update_drag(e);
}
if (e.down && mode_ != DragMode::None) update_drag(e);
// Every exit above ends here: a hover that moved, a gesture that opened, and a
// gesture that closed all change what is under the pointer, and the publish is
// what makes the renderer's highlight (and a host's resize cursor) follow.
publish_hot();
}
void Controller::on_wheel(const WheelEvent& raw) {
@@ -180,27 +193,112 @@ u64 Controller::hovered_cursor() const {
return cursor_at(pointer_x_, pointer_y_);
}
u64 Controller::hovered_region() const {
if (!has_pointer_) return kInvalid;
return region_hot(pointer_x_, pointer_y_).region;
}
RegionGrab Controller::region_grab(u32 region) const {
if (!has_pointer_) return RegionGrab::None;
for (const RegionLayout& candidate : std::as_const(*plot_).layout().regions) {
if (candidate.region == region) return zone_hit(candidate, pointer_x_, pointer_y_).zone;
}
return RegionGrab::None; // an id that names no visible region
}
Controller::ZoneHit Controller::zone_hit(const RegionLayout& region, f32 px, f32 py) const {
ZoneHit hit;
// Off the data area nothing is hot, whether the pointer is over a label strip or
// off the plot entirely. A bound that has scrolled just outside `axes` is still
// within reach of a pointer inside it -- the slack the cursor hit test has too --
// but the strips are not a place a region can be grabbed from.
if (!std::as_const(*plot_).layout().axes.contains(px, py)) return hit;
const f32 left_x = handle_column(region.left_handle);
const f32 right_x = handle_column(region.right_handle);
const f32 min_w = plot_->style().device(kMinRegionEdgeGrabLogical);
// Grabbable bounds: the nearer of the two, when it is within the radius every
// other handle in this controller is hit-tested with -- one tolerance, so what
// `hovered_cursor()` calls a hit and what a region bound calls a hit agree.
if (right_x - left_x >= min_w) {
const f32 dl = std::fabs(px - left_x);
const f32 dr = std::fabs(px - right_x);
const f32 d = std::min(dl, dr);
if (d <= plot_->style().device(kCursorHitRadiusLogical)) {
hit.zone = dl <= dr ? RegionGrab::Left : RegionGrab::Right;
hit.distance = d;
return hit;
}
}
// No bound in range: the body, widened by half the minimum on each side so a
// region too narrow to have grabbable bounds is still something a pointer can take
// hold of rather than a line it cannot hit.
const f32 grow = 0.5f * min_w;
if (px >= left_x - grow && px <= right_x + grow) hit.zone = RegionGrab::Body;
return hit;
}
Controller::RegionHot Controller::region_hot(f32 px, f32 py) const {
RegionHot hot;
u64 body = kInvalid;
f32 nearest = 0.0f;
for (const RegionLayout& candidate : std::as_const(*plot_).layout().regions) {
const ZoneHit hit = zone_hit(candidate, px, py);
if (hit.zone == RegionGrab::None) continue;
if (hit.zone == RegionGrab::Body) {
// Remembered rather than chosen: a body only wins when no bound *anywhere* is
// in range, and the last region holding the pointer is the one on top --
// regions are drawn in order, so a later one covers an earlier one's interior.
body = candidate.region;
continue;
}
if (hot.region == kInvalid || hit.distance < nearest) {
hot.region = candidate.region;
hot.zone = hit.zone;
nearest = hit.distance;
}
}
if (hot.region != kInvalid) return hot;
return body == kInvalid ? RegionHot{} : RegionHot{body, RegionGrab::Body};
}
void Controller::publish_hot() {
HotHandles hot;
if (has_pointer_) {
hot.cursor = cursor_at(pointer_x_, pointer_y_);
if (drag_region_ != kInvalid) {
// An open region drag keeps its own zone hot for as long as it runs, so the
// highlight follows the grab rather than the pointer: a body dragged out from
// under the pointer still reads as the region being moved, and a bound keeps
// its emphasis after the pointer has left it.
hot.region = drag_region_;
hot.zone = drag_zone_;
} else {
const RegionHot region = region_hot(pointer_x_, pointer_y_);
hot.region = region.region;
hot.zone = region.zone;
}
}
hot.grabbed_cursor = drag_cursor_;
plot_->set_hot_handles(hot);
}
// --- drag dispatch --------------------------------------------------------
void Controller::begin_drag(const PointerEvent& e) {
origin_x_ = e.x;
origin_y_ = e.y;
drag_cursor_ = kInvalid;
drag_region_ = kInvalid;
drag_zone_ = RegionGrab::None;
// Shift first, and only on the left: it is a modifier gesture of its own, and a
// selection box is what it means even when it starts over a handle.
if (e.button == Button::Left && has_mod(e.mods, Mod::Shift)) {
mode_ = DragMode::BoxZoom;
selection_ = Rect{e.x, e.y, 0.0f, 0.0f};
return;
}
if (e.button == Button::Left && has_mod(e.mods, Mod::Ctrl)) {
const u64 hit = cursor_at(e.x, e.y);
if (hit != kInvalid) {
mode_ = DragMode::MoveCursor;
drag_cursor_ = hit;
return;
}
// Ctrl far from any cursor falls through to pan, like an unmodified drag.
}
if (e.button == Button::Middle) {
const PlotLayout& lay = std::as_const(*plot_).layout();
if (lay.x_label_area.contains(e.x, e.y)) {
@@ -212,14 +310,68 @@ void Controller::begin_drag(const PointerEvent& e) {
}
return;
}
// Everything else -- the left and the right button both, since a host that
// reserves the left one still drives its drags with the right -- grabs the handle
// under the press before it pans. Ctrl needs no special case: a press on a cursor
// does exactly this with or without it, and a ctrl press on nothing still pans.
if (begin_handle_drag(e)) return;
mode_ = DragMode::Pan; // left and right drag both pan
}
bool Controller::begin_handle_drag(const PointerEvent& e) {
// Cursor markers first: they are what a measure's band edges *are* (a band
// between two cursors is resized by dragging those cursors), and a marker is drawn
// over everything else, so it is the thing a press on it means.
const u64 cursor = cursor_at(e.x, e.y);
if (cursor != kInvalid) {
mode_ = DragMode::MoveCursor;
drag_cursor_ = cursor;
return true;
}
const RegionHot hot = region_hot(e.x, e.y);
if (hot.region == kInvalid) return false;
drag_region_ = hot.region;
drag_zone_ = hot.zone;
const Region& region = plot_->region(static_cast<u32>(hot.region));
switch (hot.zone) {
case RegionGrab::Left:
// The other bound is held for the whole gesture: each motion is then the two
// numbers this drag is made of, not a step from the previous one, so a pointer
// that moves back and forth cannot accumulate a drift -- and when it crosses
// the held bound the store orders them, which swaps the interval instead of
// inverting it (see Plot::set_region).
mode_ = DragMode::ResizeRegionLeft;
drag_fixed_x_ = region.x1;
return true;
case RegionGrab::Right:
mode_ = DragMode::ResizeRegionRight;
drag_fixed_x_ = region.x0;
return true;
case RegionGrab::Body:
// A body drag preserves the width exactly by construction: both bounds move by
// the same delta from where they were when the gesture opened.
mode_ = DragMode::MoveRegion;
drag_x0_ = region.x0;
drag_x1_ = region.x1;
drag_grab_x_ = plot_->x_transform(0u).to_data_x(static_cast<f64>(e.x));
return true;
case RegionGrab::None:
break;
}
drag_region_ = kInvalid;
drag_zone_ = RegionGrab::None;
return false;
}
void Controller::update_drag(const PointerEvent& e) {
switch (mode_) {
case DragMode::Pan: pan(e); break;
case DragMode::BoxZoom: box_zoom_update(e); break;
case DragMode::MoveCursor: move_cursor(e); break;
case DragMode::MoveRegion: move_region(e); break;
case DragMode::ResizeRegionLeft:
case DragMode::ResizeRegionRight: resize_region(e); break;
case DragMode::AxisZoomX:
case DragMode::AxisZoomY: axis_zoom(e); break;
case DragMode::None: break;
@@ -235,9 +387,15 @@ void Controller::end_drag(const PointerEvent& e) {
box_zoom_update(e);
apply_box_zoom();
}
// A region's snap is applied exactly once, here, on the gesture's final bounds:
// the drag itself stores them verbatim, so they follow the pointer instead of
// sticking to the sample grid as it moves (see Plot::snap_region()).
if (drag_region_ != kInvalid) plot_->snap_region(static_cast<u32>(drag_region_));
mode_ = DragMode::None;
selection_ = Rect{};
drag_cursor_ = kInvalid;
drag_region_ = kInvalid;
drag_zone_ = RegionGrab::None;
}
// --- gestures -------------------------------------------------------------
@@ -315,6 +473,33 @@ void Controller::move_cursor(const PointerEvent& e) {
plot_->set_cursor_x(id, plot_->x_transform(cursor.axis_x).to_data_x(static_cast<f64>(e.x)));
}
void Controller::move_region(const PointerEvent& e) {
if (drag_region_ == kInvalid) return;
// The pointer's own data coordinate, not the event's delta: the delta a host
// reports for one motion is not always the difference between the positions it
// reported, and a region's two bounds must move by exactly the same amount or the
// width changes. Both come from the bounds the gesture opened with plus this one
// offset, so nothing accumulates over a long drag.
const f64 grabbed = plot_->x_transform(0u).to_data_x(static_cast<f64>(e.x));
const f64 delta = grabbed - drag_grab_x_;
plot_->set_region(static_cast<u32>(drag_region_), drag_x0_ + delta, drag_x1_ + delta);
}
void Controller::resize_region(const PointerEvent& e) {
if (drag_region_ == kInvalid) return;
const f64 x = plot_->x_transform(0u).to_data_x(static_cast<f64>(e.x));
// The bound the gesture did not grab stays where it was for the whole drag, and
// the grabbed one is wherever the pointer says. When the two cross, the *store*
// orders them (`set_region` keeps min and max), so the interval swaps rather than
// inverting -- and the pointer keeps hold of its bound: the next motion is still
// "the other end plus this pointer", with no state to get stuck in.
if (mode_ == DragMode::ResizeRegionLeft) {
plot_->set_region(static_cast<u32>(drag_region_), x, drag_fixed_x_);
} else {
plot_->set_region(static_cast<u32>(drag_region_), drag_fixed_x_, x);
}
}
void Controller::axis_zoom(const PointerEvent& e) {
// A pointer drag reports pixels, not wheel notches, so it uses its own
// sensitivity: 200 px is one decade. The gain is a physical distance the hand
+245 -12
View File
@@ -27,6 +27,44 @@ const Axis& default_axis() noexcept {
return axis;
}
/// The sample range a data interval covers: `[begin, end)` over the samples whose
/// x lies inside `[lo, hi]`, both ends included -- exactly the samples a region's
/// statistics are over. The nearest-sample snap seeds the walk and the two
/// corrections after it move by the one or two samples a rounded index can be off
/// by, so which side of a bound a sample falls on is decided by the same doubles
/// the series carries rather than by a division that is exact only on a grid that
/// is. Still O(1): the seed is never more than half a sample from the answer.
///
/// False when the interval holds no sample at all, which is a real case: an
/// interval between two adjacent samples on a sparse series, one entirely before or
/// after the window, a non-finite bound, and a series with no usable `dx()`.
bool covered_samples(const data::Series& series, f64 lo, f64 hi, u64& begin, u64& end) {
const u64 n = series.size();
const f64 x0 = series.x0();
const f64 dx = series.dx();
if (n == 0u || !(dx > 0.0) || !std::isfinite(lo) || !std::isfinite(hi) || !(lo <= hi)) {
return false;
}
const auto x_of = [x0, dx](u64 i) { return x0 + static_cast<f64>(i) * dx; };
// The first sample at or after `lo`: walk left while the one before is also
// inside, then right while this one is not yet.
u64 b = series.pyramid().snap(lo, x0, dx);
while (b > 0u && x_of(b - 1u) >= lo) --b;
while (b < n && x_of(b) < lo) ++b;
// One past the last sample at or before `hi`, from the same seed.
const u64 seed = series.pyramid().snap(hi, x0, dx);
u64 e = seed == n - 1u ? n : seed + 1u;
while (e < n && x_of(e) <= hi) ++e;
while (e > 0u && x_of(e - 1u) > hi) --e;
if (e <= b) return false;
begin = b;
end = e;
return true;
}
const Measurement& invalid_measurement() noexcept {
static const Measurement measurement{};
return measurement;
@@ -136,6 +174,21 @@ u32 Plot::add_annotation(const Annotation& annotation) {
return static_cast<u32>(annotations_.size() - 1);
}
u32 Plot::add_region(const Region& region) {
// Ordered on the way in, so no caller -- and no drag -- can leave `x0 > x1`
// behind: the store is the one place this invariant is enforced.
Region stored = region;
stored.x0 = std::min(region.x0, region.x1);
stored.x1 = std::max(region.x0, region.x1);
regions_.push_back(stored);
const u32 id = static_cast<u32>(regions_.size() - 1);
// A region that asks to snap lands on the sample grid as it is added, exactly as
// `add_cursor()` places a snapping cursor: what a caller hands over is then on the
// samples of the traces the plot already holds.
snap_region(id);
return id;
}
const Axis& Plot::x_axis(u32 axis) const {
return axis < x_axes_.size() ? x_axes_[axis] : default_axis();
}
@@ -159,6 +212,11 @@ const Annotation& Plot::annotation(u32 id) const {
return id < annotations_.size() ? annotations_[id] : fallback;
}
const Region& Plot::region(u32 id) const {
static const Region fallback{};
return id < regions_.size() ? regions_[id] : fallback;
}
Range Plot::x_range(u32 axis) const { return x_axis(axis).range; }
Range Plot::y_range(u32 axis) const { return y_axis(axis).range; }
@@ -438,7 +496,112 @@ const Measurement& Plot::measurement(u32 id) const {
return slot.result;
}
void Plot::invalidate_measurements() noexcept { mark_measures_dirty(); }
void Plot::invalidate_measurements() noexcept {
mark_measures_dirty();
// The region statistics key on the same series identity, so a mutation those keys
// cannot see has to invalidate them too (see the contract).
for (RegionStatsSlot& slot : region_stats_) slot.dirty = true;
}
void Plot::set_region(u32 id, f64 x0, f64 x1) {
if (id >= regions_.size()) return;
// min/max, so an inverted pair is stored ordered rather than clamped: a drag that
// crosses one bound over the other *swaps* the interval, which is what keeps the
// bound the pointer holds movable instead of pinned at the other one.
regions_[id].x0 = std::min(x0, x1);
regions_[id].x1 = std::max(x0, x1);
mark_region_stats_dirty(id);
}
void Plot::snap_region(u32 id) {
if (id >= regions_.size()) return;
const Region& region = regions_[id];
if (region.snap != RegionSnap::NearestSample) return;
// A region names no axis, so its coordinates are on the plot's primary x axis --
// the axis its fill and the grid are drawn against (see Region).
set_region(id, snapped_x(region.x0, 0u), snapped_x(region.x1, 0u));
}
void Plot::remove_region(u32 id) {
if (id >= regions_.size()) return;
regions_.erase(regions_.begin() + static_cast<std::ptrdiff_t>(id));
// The cache is keyed by region index, so the removed region's entries name nothing
// and every entry above the hole names a region one slot further down. The
// surviving regions' own intervals are untouched -- erase keeps their order -- so
// their cached statistics stay true and are only re-indexed, not dropped.
std::erase_if(region_stats_,
[id](const RegionStatsSlot& slot) { return slot.region == id; });
for (RegionStatsSlot& slot : region_stats_) {
if (slot.region > id) slot.region -= 1u;
}
}
const RegionStats& Plot::region_stats(u32 region, u32 trace) const {
static const RegionStats none{};
if (region >= regions_.size() || trace >= traces_.size()) return none;
RegionStatsSlot* slot = nullptr;
for (RegionStatsSlot& candidate : region_stats_) {
if (candidate.region == region && candidate.trace == trace) {
slot = &candidate;
break;
}
}
if (slot == nullptr) {
// Filled on demand, one entry per pair a caller asks about: a plot with a dozen
// regions and a dozen traces never pays for the cross product it never queried.
region_stats_.push_back(RegionStatsSlot{});
slot = &region_stats_.back();
slot->region = region;
slot->trace = trace;
}
const data::Series* series = traces_[trace].series;
const u64 samples = series != nullptr ? series->size() : 0;
// A saturated ring keeps `samples` constant while its window slides: `x0()` is
// what moves, and with it which samples the region's data interval covers. Caching
// on (pointer, count) alone would freeze the readout at the first saturated frame
// -- the same defect the measurement cache avoids with this same third key.
const f64 x0 = series != nullptr ? series->x0() : 0.0;
if (slot->dirty || series != slot->last_series || samples != slot->last_size ||
x0 != slot->last_x0) {
slot->result = series != nullptr ? evaluate_region_stats(regions_[region], *series)
: RegionStats{};
slot->last_series = series;
slot->last_size = samples;
slot->last_x0 = x0;
slot->dirty = false;
}
return slot->result;
}
RegionStats Plot::evaluate_region_stats(const Region& region, const data::Series& series) {
RegionStats stats;
u64 begin = 0;
u64 end = 0;
if (!covered_samples(series, region.x0, region.x1, begin, end)) return stats;
// Exact interval queries on Series, never the pyramid's *_buckets forms: this
// interval is not bucket-aligned in general (that is the whole point of a free
// region), and a bucket-grained answer would silently include samples outside it.
const Range extremes = series.reduce(begin, end);
const f64 total = series.sum(begin, end);
const f64 squared = series.sumsq(begin, end);
const f64 n = static_cast<f64>(end - begin);
stats.x0 = region.x0;
stats.x1 = region.x1;
stats.width = region.x1 - region.x0;
stats.min = extremes.min;
stats.max = extremes.max;
stats.mean = total / n;
stats.rms = std::sqrt(squared / n);
stats.integral = total * series.dx(); // left-Riemann, the measurement's convention
stats.count = end - begin;
stats.valid = true;
return stats;
}
MeasureSpan Plot::measure_span(u32 id) const noexcept {
// An id that names nothing, or a measure built from cursor ids that were never
@@ -535,9 +698,12 @@ const PlotLayout& Plot::layout() {
}
build_grid();
// The legend is measured last, from the finished `axes`: it lives inside the data
// area and reserves nothing, so it can neither move the strips above nor depend on
// anything but the rect they settled on.
// The regions and the legend are measured last, from the finished `axes`: both
// live inside the data area and reserve nothing, so neither can move the strips
// above nor depend on anything but the rect they settled on. The regions are
// measured from the *placed* live windows, which is what makes a region's data
// interval map to the pixel columns the frame will actually use.
build_regions();
build_legend();
return layout_;
}
@@ -563,15 +729,29 @@ void Plot::mark_measures_dirty() noexcept {
for (MeasureSlot& slot : measures_) slot.dirty = true;
}
f64 Plot::snapped_x(const Cursor& c) const {
if (c.snap != SnapMode::NearestSample) return c.x;
for (const Trace& t : traces_) {
if (t.axis_x != c.axis_x || !t.visible()) continue;
const data::Series& s = *t.series;
const u64 i = s.pyramid().snap(c.x, s.x0(), s.dx());
return s.x0() + static_cast<f64>(i) * s.dx();
void Plot::mark_region_stats_dirty(u32 region) noexcept {
for (RegionStatsSlot& slot : region_stats_) {
if (slot.region == region) slot.dirty = true;
}
return c.x;
}
const data::Series* Plot::snap_series(u32 axis_x) const {
for (const Trace& t : traces_) {
if (t.axis_x != axis_x || !t.visible()) continue; // null series: skip
return t.series;
}
return nullptr;
}
f64 Plot::snapped_x(f64 x, u32 axis_x) const {
const data::Series* s = snap_series(axis_x);
if (s == nullptr) return x;
const u64 i = s->pyramid().snap(x, s->x0(), s->dx());
return s->x0() + static_cast<f64>(i) * s->dx();
}
f64 Plot::snapped_x(const Cursor& c) const {
return c.snap == SnapMode::NearestSample ? snapped_x(c.x, c.axis_x) : c.x;
}
u32 Plot::titled_x_axis_count() const noexcept {
@@ -714,4 +894,57 @@ void Plot::build_legend() {
}
}
void Plot::build_regions() {
layout_.regions.clear();
if (regions_.empty()) return;
const Rect& axes = layout_.axes;
// A region's coordinates are on the primary x axis (see Region). The transform is
// taken *here*, after the live windows have been placed and the strips have
// settled, so the columns this produces are the ones the frame draws with -- and
// the controller hit-tests these very numbers rather than mapping the bounds
// through the transform a second time.
const Transform tx = x_transform(0u);
const f32 handle_w = style_.device(style_.handle_width_px);
const f32 pad = style_.device(style_.grid_label_pad);
const f32 line_h = text_ != nullptr ? text_->height() : 0.0f;
for (u32 i = 0; i < regions_.size(); ++i) {
const Region& region = regions_[i];
// An invisible region is not in the layout at all: the layout is exactly what a
// frame draws and what a pointer can hit, so the two cannot disagree about
// whether a region is there.
if (!region.visible) continue;
RegionLayout out;
out.region = i;
const f32 lo = static_cast<f32>(tx.to_screen_x(region.x0));
const f32 hi = static_cast<f32>(tx.to_screen_x(region.x1));
// One tab per bound, centred on it, so the bound's column is the tab's centre
// line (`handle_column()`) rather than a second number that could drift from it.
out.left_handle = Rect{lo - 0.5f * handle_w, axes.y, handle_w, axes.h};
out.right_handle = Rect{hi - 0.5f * handle_w, axes.y, handle_w, axes.h};
// The fill is the interval clipped to the axes' columns: a region that has
// scrolled out of the window has nothing to shade, and one wider than the window
// is not allowed to paint over the label strips.
const f32 first = std::clamp(lo, axes.x, axes.right());
const f32 last = std::clamp(hi, axes.x, axes.right());
out.fill = Rect{first, axes.y, last - first, axes.h};
// The label goes on the first text line inside the fill, clamped to `axes` -- the
// measurement readout's rule, and for its reason: a caption starting at a fill
// edge near the right of the plot would otherwise run over the axis and out of
// the panel. A label with no room across the axes is dropped rather than
// overhanging them, and a zero-width fill (an off-window region) draws none.
if (!region.label.empty() && line_h > 0.0f && out.fill.w > 0.0f) {
const f32 width = text_->measure(region.label.c_str());
if (width + 2.0f * pad <= axes.w) {
out.label = Rect{std::clamp(out.fill.x + pad, axes.x + pad, axes.right() - width - pad),
axes.y + pad, width, line_h};
}
}
layout_.regions.push_back(out);
}
}
} // namespace plot
+52 -1
View File
@@ -348,6 +348,9 @@ void Renderer::draw(Plot& plot) {
draw_tick_labels(plot, layout);
draw_titles(plot, layout);
draw_traces(plot, layout);
// Regions over the traces and under the cursors: an area of interest the data is
// still legible through, with the markers that bound it on top of it.
draw_regions(plot, layout);
draw_cursors(plot, layout);
draw_measures(plot, layout);
draw_annotations(plot);
@@ -764,10 +767,17 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
void Renderer::draw_cursors(const Plot& plot, const PlotLayout& layout) {
const Range y_range = plot.y_range(0);
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
// does not depend on the marker's own colour differing from the accent -- a cursor
// created in the accent colour is otherwise indistinguishable from an idle one.
const HotHandles hot = plot.hot_handles();
for (u32 i = 0; i < plot.cursor_count(); ++i) {
const Cursor& cursor = plot.cursor(i);
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
@@ -776,13 +786,54 @@ void Renderer::draw_cursors(const Plot& plot, const PlotLayout& layout) {
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), cursor.color, style_.device(style_.line_width_px));
flush_lines(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());
}
}
}
void Renderer::draw_regions(const Plot& plot, const PlotLayout& layout) {
const HotHandles hot = plot.hot_handles();
const f32 edge_w = style_.device(style_.line_width_px);
for (const RegionLayout& region : layout.regions) {
const Region& config = plot.region(region.region);
// The fill first, so both bounds are drawn on top of it: one instance of the
// line pipeline (see fill_pixels), which blends at the region's own alpha and
// therefore leaves the trace under it readable.
if (region.fill.w > 0.0f) fill_pixels(region.fill, config.fill);
// A bound is emphasized only when that bound is the hot zone: a body hover draws
// no edge highlight -- the fill is the body, and the host's own cursor says what
// a drag there would do (Controller::region_grab()).
const bool hot_region = static_cast<u64>(region.region) == hot.region;
const bool hot_left = hot_region && hot.zone == RegionGrab::Left;
const bool hot_right = hot_region && hot.zone == RegionGrab::Right;
// A hot bound is drawn in the cursor colour and at its tab's own width: the tab is
// the target the controller hit-tested, so the highlight a grab or a hover lights up
// is exactly the thing a drag was aimed at, and the grab column and the drawn column
// are one number rather than two that would have to be kept in step.
draw_region_edge(region.left_handle, layout.axes, hot_left ? style_.cursor : config.edge,
hot_left ? region.left_handle.w : edge_w);
draw_region_edge(region.right_handle, layout.axes, hot_right ? style_.cursor : config.edge,
hot_right ? region.right_handle.w : edge_w);
if (!config.label.empty() && region.label.w > 0.0f) {
stage_text({region.label.x, region.label.y}, config.label.c_str());
}
}
}
void Renderer::draw_region_edge(const Rect& tab, const Rect& axes, Color color, f32 width) {
const f32 x = handle_column(tab);
if (x < axes.x || x >= axes.right()) return; // scrolled out: the strip is not ours
lines_.add_segment({x, tab.y}, {x, tab.bottom()});
flush_lines(pixel_mapping(), color, width);
}
void Renderer::draw_measures(const Plot& plot, const PlotLayout& layout) {
const Rect& axes = layout.axes;
const Range y_range = plot.y_range(0);