wip(plot): region layout, hot handles and draggable handles (build restored)
This commit is contained in:
1 parent
7d2349555d
commit
e4d48cc6c3
9 files changed
+915
-37
No files matched your search
+199
-14
@@ -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
@@ -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 = ®ion_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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user