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
+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
|
||||
Reference in new issue
Block a user