#include #include #include #include namespace plot { namespace { /// Layout passes. The left strip's width depends on the tick labels, which depend /// on the plot width the strip leaves; the loop stops as soon as the strip stops /// changing, so a normal plot converges on the second pass and never reaches this. constexpr u32 kMaxLayoutPasses = 4; /// Share of the viewport the axes rect keeps however much chrome the label and /// title strips ask for. A long SI label or a title wider than a narrow panel can /// ask for the whole viewport, and an axes rect with no width or no height draws /// nothing at all -- no grid, no tick, no trace -- without reporting anything, so /// the strips give way instead. The label strip is what makes the tick values /// readable, so it is served first and the title strip takes what is left; a title /// longer than its share of the strip is clipped to it rather than allowed to grow /// it (see `Renderer::draw_titles()`). constexpr f32 kMinAxesShare = 0.5f; const Axis& default_axis() noexcept { static const Axis axis{}; 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(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; } Axis axis_from(const AxisDesc& desc) { Axis axis; axis.label = desc.label; axis.range = desc.range; axis.auto_scale = desc.auto_scale; axis.place = desc.place; axis.log = desc.log; axis.format = desc.format; return axis; } /// Fits `axis`' range to the union of its traces' bounds, returning false when the /// union cannot be fitted and the range is then left untouched. /// /// A linear axis takes 2% of the span on both ends; a zero-span union (a /// single-sample series) is widened instead of padded, because a degenerate range /// has no span to take 2% of and would leave the transform's inverse ambiguous. /// /// A log axis needs strictly positive data, and this is where a log axis could /// otherwise be left broken: an all-negative union has no positive value to show, /// and padding it would produce `min > max` once the lower edge was clamped to a /// positive bound. Instead the positive part of the union is used -- `[hi*1e-3, /// hi]` when the union straddles or starts below zero, since zero has no place on /// a log axis -- the pad is multiplicative (2% of the span in log space), and a /// union with no positive value at all is refused outright. The caller keeps its /// range and is free to retry, which is what lets a stream that starts at zero /// begin fitting by itself once it goes positive. bool fit_axis_range(Axis& axis, Range bounds) { const f64 lo = std::min(bounds.min, bounds.max); const f64 hi = std::max(bounds.min, bounds.max); if (!std::isfinite(lo) || !std::isfinite(hi)) return false; if (axis.log) { if (!(hi > 0.0)) return false; // no positive sample: not auto-fittable const f64 lower = lo > 0.0 ? lo : hi * 1e-3; if (!(lower > 0.0) || lower > hi) return false; if (lower == hi) { axis.range = Range{lower / 10.0, hi * 10.0}; // one decade either side return true; } const f64 factor = std::pow(hi / lower, 0.02); // 2% of the span, in log space axis.range = Range{lower / factor, hi * factor}; return true; } if (hi > lo) { const f64 pad = (hi - lo) * 0.02; axis.range = Range{lo - pad, hi + pad}; return true; } const f64 pad = std::fabs(lo) * 1e-3 + 0.5; axis.range = Range{lo - pad, hi + pad}; return true; } } // namespace u32 Plot::add_x_axis(const AxisDesc& desc) { x_axes_.push_back(axis_from(desc)); return static_cast(x_axes_.size() - 1); } u32 Plot::add_y_axis(const AxisDesc& desc) { y_axes_.push_back(axis_from(desc)); return static_cast(y_axes_.size() - 1); } u32 Plot::add_trace(const TraceDesc& desc) { Trace trace; trace.series = desc.series; trace.color = desc.color; trace.width = desc.width; trace.axis_x = desc.axis_x; trace.axis_y = desc.axis_y; trace.name = desc.name; traces_.push_back(trace); mark_measures_dirty(); return static_cast(traces_.size() - 1); } u32 Plot::add_cursor(f64 x, Color color) { Cursor cursor; cursor.x = x; cursor.color = color; cursor.x = snapped_x(cursor); cursors_.push_back(cursor); mark_measures_dirty(); return static_cast(cursors_.size() - 1); } u32 Plot::add_measure(u32 cursor_a, u32 cursor_b, u32 trace) { MeasureSlot slot; slot.cursor_a = cursor_a; slot.cursor_b = cursor_b; slot.trace = trace; measures_.push_back(slot); return static_cast(measures_.size() - 1); } u32 Plot::add_annotation(const Annotation& annotation) { annotations_.push_back(annotation); return static_cast(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(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(); } const Axis& Plot::y_axis(u32 axis) const { return axis < y_axes_.size() ? y_axes_[axis] : default_axis(); } const Trace& Plot::trace(u32 id) const { static const Trace fallback{}; return id < traces_.size() ? traces_[id] : fallback; } const Cursor& Plot::cursor(u32 id) const { static const Cursor fallback{}; return id < cursors_.size() ? cursors_[id] : fallback; } const Annotation& Plot::annotation(u32 id) const { static const Annotation fallback{}; 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; } void Plot::set_x_range(u32 axis, Range r) { if (axis >= x_axes_.size()) return; Axis& a = x_axes_[axis]; Range live{}; if (a.follow == XFollow::Live && live_x_bounds(axis, live)) { // Under Live the caller is stating a *span* and where the window sits relative // to the live edge, not a pair of absolute positions: the next layout() // recomputes the window from the series' newest x, so storing `r` verbatim // would be a position that survives exactly until the next frame. The offset is // what carries the position -- derived here from the live edge as it is *now*, // which is what makes a zoom keep its anchor still and a pan shift by the // drag's data delta -- and place_live_x_window() clamps it and stores the // window that follows from it, so the state is consistent before the next // layout rather than only after it. // // This branch is taken unconditionally on a live axis, and what it refuses to // store is what would strand it: `place_live_x_window()` needs a positive span, // and every gesture keeps a non-positive one out of its arithmetic, so a // degenerate range stored here would leave the axis following nothing, movable // by nothing, while `x_follow()` still said Live. An inverted range is a // caller's ordering slip, not a request to break the axis' `min <= max` // invariant -- the rest of the library reads a range as a range -- so it is // normalised; a span that is not a span is ignored outright, leaving the span // and offset the axis holds. `auto_fit_x()` is how a window is framed either // way. const f64 lo = std::min(r.min, r.max); const f64 hi = std::max(r.min, r.max); if (!std::isfinite(lo) || !std::isfinite(hi) || !(hi > lo)) return; a.range = Range{lo, hi}; a.offset = live.max - hi; place_live_x_window(axis); a.auto_scale = false; // as ever: a range a caller chose is not an auto-fit return; } // Off, or Live with nothing streaming to follow: the range is stored verbatim, // which is what every caller of this setter got before follow existed. a.range = r; a.auto_scale = false; } void Plot::set_y_range(u32 axis, Range r) { if (axis >= y_axes_.size()) return; y_axes_[axis].range = r; y_axes_[axis].auto_scale = false; } void Plot::set_x_auto_scale(u32 axis, bool on) { if (axis < x_axes_.size()) x_axes_[axis].auto_scale = on; } void Plot::set_y_auto_scale(u32 axis, bool on) { if (axis < y_axes_.size()) y_axes_[axis].auto_scale = on; } void Plot::set_x_follow(u32 axis, XFollow f) { if (axis >= x_axes_.size()) return; x_axes_[axis].follow = f; // Arming a live axis places its window at once, so x_range() reports the window // the next layout produces rather than the one the axis held while it was Off. // Nothing else follows from it: the follow state is only ever changed here, and // the range the axis already held is the span `Live` starts from (see the header). place_live_x_window(axis); } XFollow Plot::x_follow(u32 axis) const { return axis < x_axes_.size() ? x_axes_[axis].follow : XFollow::Off; } void Plot::set_x_offset(u32 axis, f64 units_behind_live) { if (axis >= x_axes_.size()) return; // No such offset: a NaN would place a window that is not a window, and the last // usable one is a better answer than that for a value no gesture can undo. if (!std::isfinite(units_behind_live)) return; x_axes_[axis].offset = std::max(0.0, units_behind_live); // The upper end of the clamp is what the traces still retain, which only they // know: place_live_x_window() applies it, and with it brings the visible window // along so x_range() and x_offset() agree before the next layout. A no-op on an // axis that is not Live -- the value waits there for when following is armed. place_live_x_window(axis); } f64 Plot::x_offset(u32 axis) const { return axis < x_axes_.size() ? x_axes_[axis].offset : 0.0; } bool Plot::x_live_edge(u32 axis, f64& newest) const { Range live{}; if (axis >= x_axes_.size() || !live_x_bounds(axis, live)) return false; newest = live.max; return true; } bool Plot::live_x_bounds(u32 axis, Range& out) const { bool any = false; for (const Trace& t : traces_) { // Only a streaming series has a live edge. An array's bounds cover everything // it holds and change only when the caller replaces the data, so `Live` on such // a trace would be an anchor that never moves -- the axis is left to its stored // range instead (see place_live_x_window). if (t.axis_x != axis || !t.visible() || !t.series->streaming()) continue; const Range b = t.series->x_bounds(); if (!std::isfinite(b.min) || !std::isfinite(b.max)) continue; if (!any) { out = b; any = true; } else { // The union of what the traces retain: the oldest x any of them still holds // and the newest any of them holds, so the window is clamped against the ring // that reaches furthest back and anchored to the sample that is newest. out.min = std::min(out.min, b.min); out.max = std::max(out.max, b.max); } } return any; } bool Plot::follows_live(u32 axis) const { if (x_axes_[axis].follow != XFollow::Live) return false; Range live{}; return live_x_bounds(axis, live); } bool Plot::place_live_x_window(u32 axis) { Axis& a = x_axes_[axis]; if (a.follow != XFollow::Live) return false; Range live{}; if (!live_x_bounds(axis, live)) return false; // The span is the axis' own -- what the user zoomed to, or what the last fit // produced -- and the follow window only moves it: the span is what makes this a // scroll rather than a rescale. A span that is not a span is nothing to place a // window from, so the range is left exactly as it is. const f64 span = a.range.span(); if (!std::isfinite(span) || !(span > 0.0)) return false; // The offset can outlive the window it names: a ring that dropped samples moves // the oldest x forward under it, so it is clamped against what the traces hold // *now* -- an offset past that would put the window's right edge behind the // oldest sample left, i.e. over data that is gone. const f64 retained = std::max(0.0, live.max - live.min); a.offset = std::clamp(a.offset, 0.0, retained); a.range = Range{live.max - a.offset - span, live.max - a.offset}; return true; } Transform Plot::x_transform(u32 axis) const { const Axis& a = x_axis(axis); const Rect r = layout_.axes; // Only the x half of the transform is meaningful; the dummy y range keeps the // returned Transform's y mapping an identity rather than a second, conflicting // axis mapping. A collapsed rect or range is widened inside from_range. return Transform::from_range(a.range, Range{}, static_cast(r.x), static_cast(r.right()), 0.0, 1.0, a.log, false); } Transform Plot::y_transform(u32 axis) const { const Axis& a = y_axis(axis); const Rect r = layout_.axes; // Screen y grows downwards, so the range's minimum maps to the rect's bottom // and its maximum to the top: py0/py1 are passed bottom-first on purpose. return Transform::from_range(Range{}, a.range, 0.0, 1.0, static_cast(r.bottom()), static_cast(r.y), false, a.log); } bool Plot::trace_bounds_union(bool x_side, u32 axis, Range& out) const { bool any = false; for (const Trace& t : traces_) { if ((x_side ? t.axis_x : t.axis_y) != axis || !t.visible()) continue; // null series: skip const Range bounds = x_side ? t.series->x_bounds() : t.series->y_bounds(); if (!std::isfinite(bounds.min) || !std::isfinite(bounds.max)) continue; if (!any) { out = bounds; any = true; } else { out.min = std::min(out.min, bounds.min); out.max = std::max(out.max, bounds.max); } } return any; } bool Plot::fit_x_axis(u32 axis) { Range union_range{}; if (!trace_bounds_union(/*x_side=*/true, axis, union_range)) return false; return fit_axis_range(x_axes_[axis], union_range); } bool Plot::fit_y_axis(u32 axis) { Range union_range{}; if (!trace_bounds_union(/*x_side=*/false, axis, union_range)) return false; return fit_axis_range(y_axes_[axis], union_range); } void Plot::auto_fit_x() { for (u32 i = 0; i < x_axes_.size(); ++i) { // Fitting re-arms auto scaling, so the next layout keeps the axis framed. An // axis with nothing bound to it has nothing to fit and nothing to re-arm. if (!fit_x_axis(i)) continue; x_axes_[i].auto_scale = true; // Under Live a fit is also the way *back* to the live edge: the span becomes the // one that was just fitted, and following means the window ends at the newest // sample, so the offset returns to zero. Following stays armed -- a fit is not // how a plot stops following, set_x_follow() is. if (follows_live(i)) x_axes_[i].offset = 0.0; place_live_x_window(i); } } void Plot::auto_fit_y(u32 axis) { if (axis >= y_axes_.size()) return; if (fit_y_axis(axis)) y_axes_[axis].auto_scale = true; } void Plot::autoscale_axes() { for (u32 i = 0; i < x_axes_.size(); ++i) { // A Live axis with a live edge is owned by the follow path, and auto scale is // inert on it: a fit here would overwrite the span the follow window is built // from -- the user's zoom -- on every frame, which is exactly the fight spec ยง12 // removes. auto_fit_x() is how a live axis is framed. if (x_axes_[i].auto_scale && !follows_live(i)) fit_x_axis(i); } for (u32 i = 0; i < y_axes_.size(); ++i) { if (y_axes_[i].auto_scale) fit_y_axis(i); } } 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]); 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]); mark_measures_dirty(); } const Measurement& Plot::measurement(u32 id) const { if (id >= measures_.size()) return invalid_measurement(); const MeasureSlot& slot = measures_[id]; const data::Series* series = nullptr; if (slot.trace < traces_.size()) series = traces_[slot.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 the sample each cursor snaps to and the interval they // measure. Caching on (pointer, count) alone would freeze the readout at the // first saturated frame until a cursor moved. const f64 x0 = series != nullptr ? series->x0() : 0.0; // Recompute only when someone moved a cursor, replaced the trace's series, grew // it, or slid the window under it (x0 above). Everything else is a cache hit, // which is what keeps a steady frame from re-evaluating every measure. if (slot.dirty || series != slot.last_series || samples != slot.last_size || x0 != slot.last_x0) { #ifdef PLOT_COUNT_MEASURE_RECOMPUTES detail::measure_recomputes().fetch_add(1, std::memory_order_relaxed); #endif slot.result = Measurement{}; if (series != nullptr && samples > 0 && slot.cursor_a < cursors_.size() && slot.cursor_b < cursors_.size()) { const u64 a = series->pyramid().snap(cursors_[slot.cursor_a].x, series->x0(), series->dx()); const u64 b = series->pyramid().snap(cursors_[slot.cursor_b].x, series->x0(), series->dx()); // snap() clamps to [0, size-1], so [a, b+1) is inside the series whenever // the cursor pair is not inverted. An inverted pair is not an interval. if (a <= b) slot.result = Measure::evaluate(*series, a, b + 1); } slot.last_series = series; slot.last_size = samples; slot.last_x0 = x0; slot.dirty = false; } return slot.result; } 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(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(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 // registered, is not a span between two markers: `valid` says so rather than // letting a caller shade a band between cursors 0 and 1 by accident. if (id >= measures_.size()) return MeasureSpan{}; const MeasureSlot& slot = measures_[id]; if (slot.cursor_a >= cursors_.size() || slot.cursor_b >= cursors_.size()) return MeasureSpan{}; return MeasureSpan{slot.cursor_a, slot.cursor_b, true}; } const PlotLayout& Plot::layout() { // Live x windows are placed first, so everything below that derives from a range // -- the ticks, the grid, the transforms the controller hit-tests through, and the // range a caller reads back -- describes the live edge of *this* frame rather than // the position the window had when the axis was last touched, and the renderer, // the controller and a caller all see one window per frame. O(1) per axis and // allocation-free, so a frame pays nothing for it. for (u32 i = 0; i < x_axes_.size(); ++i) place_live_x_window(i); // Axes still on auto scale refit here, once per layout, so a growing stream // stays framed. A caller gesture clears auto_scale (set_x_range/set_y_range), // which is precisely what stops this from fighting the range the user chose -- // and a live axis is not fitted at all, because the follow path above owns it. autoscale_axes(); // The label padding is a logical size, so it is converted once, here: every strip // and inset below reads this value in device pixels, like the backend metrics it // is added to. const f32 pad = style_.device(style_.grid_label_pad); // Seeds: PlotLayout's defaults when nobody can measure text, otherwise the // metrics the text backend does know (its line height, and just the padding // until the first tick list says how wide the labels are). f32 y_width = PlotLayout{}.y_axis_width; f32 x_height = PlotLayout{}.x_axis_height; if (text_ != nullptr) { y_width = y_axes_.empty() ? 0.0f : 2.0f * pad; x_height = text_->height() + 2.0f * pad; } // The title strips, though, are needed before the loop and never change, unlike // the label strips: a strip holds exactly one text line per titled axis and no // tick label can change that. Nothing at all is reserved without a text backend // -- there is then no title to measure, which is what keeps the two defaults // above meaningful for the no-text-backend case. The x strip is one text line // per titled x axis tall (they are stacked in it); the y strip is one text line // per titled y axis *wide*, because the y title is drawn rotated and so spends // its length along the axes' height and takes a column, not a row, per titled // axis (see y_title_strip_width). const u32 x_titles = titled_x_axis_count(); const f32 y_title_w = text_ != nullptr ? y_title_strip_width() : 0.0f; const f32 x_title_h = text_ != nullptr && x_titles != 0u ? static_cast(x_titles) * text_->height() + 2.0f * pad : 0.0f; for (u32 pass = 0;; ++pass) { // Chrome is measured against the viewport, then capped so the axes keeps // kMinAxesShare of it -- the label strip first, the title strip out of what is // left of the allowance -- which is what keeps axes.w and axes.h positive // however long the labels and titles are. `viewport_` is what a huge label set // is clamped to first, so neither strip can push the rect outside it. y_width = std::clamp(y_width, 0.0f, viewport_.w); x_height = std::clamp(x_height, 0.0f, viewport_.h); const f32 y_budget = (1.0f - kMinAxesShare) * viewport_.w; const f32 x_budget = (1.0f - kMinAxesShare) * viewport_.h; y_width = std::min(y_width, y_budget); x_height = std::min(x_height, x_budget); const f32 y_title_width = std::min(y_title_w, y_budget - y_width); const f32 x_title_height = std::min(x_title_h, x_budget - x_height); layout_.y_axis_width = y_width; layout_.x_axis_height = x_height; layout_.y_title_width = y_title_width; layout_.x_title_height = x_title_height; layout_.axes = Rect{viewport_.x + y_width + y_title_width, viewport_.y, viewport_.w - (y_width + y_title_width), viewport_.h - (x_height + x_title_height)}; layout_.y_title_area = Rect{viewport_.x, layout_.axes.y, y_title_width, layout_.axes.h}; layout_.y_label_area = Rect{layout_.y_title_area.right(), layout_.axes.y, y_width, layout_.axes.h}; layout_.x_label_area = Rect{layout_.axes.x, layout_.axes.bottom(), layout_.axes.w, x_height}; layout_.x_title_area = Rect{layout_.axes.x, layout_.x_label_area.bottom(), layout_.axes.w, x_title_height}; refresh_ticks(); if (text_ == nullptr || pass + 1 >= kMaxLayoutPasses) break; // The same caps as above, so this compares like with like and the loop still // reaches its fixed point on the second pass. const f32 need_y = std::min(std::clamp(y_label_width(), 0.0f, viewport_.w), y_budget); const f32 need_x = std::min(std::clamp(text_->height() + 2.0f * pad, 0.0f, viewport_.h), x_budget); if (need_y == y_width && need_x == x_height) break; // strips are a fixed point y_width = need_y; x_height = need_x; } build_grid(); // 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_; } void Plot::refresh_ticks() { // Ticks are generated for the final axes size: an x axis' density depends on // the plot's width and a y axis' on its height. The minimum spacing is a logical // size, so it is converted here: a 2x display therefore gets the same *count* of // major ticks over the same logical width rather than twice as many. for (Axis& a : x_axes_) a.refresh(layout_.axes.w, style_.device(style_.tick_min_px), true); for (Axis& a : y_axes_) a.refresh(layout_.axes.h, style_.device(style_.tick_min_px), true); } void Plot::build_grid() { if (x_axes_.empty() || y_axes_.empty()) { grid_.clear(); return; } grid_.build(x_axes_[0].ticks, x_axes_[0].range, y_axes_[0].ticks, y_axes_[0].range); } void Plot::mark_measures_dirty() noexcept { for (MeasureSlot& slot : measures_) slot.dirty = true; } void Plot::mark_region_stats_dirty(u32 region) noexcept { for (RegionStatsSlot& slot : region_stats_) { if (slot.region == region) slot.dirty = true; } } 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(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 { u32 n = 0; for (const Axis& a : x_axes_) { if (!a.label.empty()) ++n; } return n; } u32 Plot::titled_y_axis_count() const noexcept { u32 n = 0; for (const Axis& a : y_axes_) { if (!a.label.empty()) ++n; } return n; } f32 Plot::y_title_strip_width() const { // One text line per titled y axis, not the widest title's own width: the title // is drawn rotated to read bottom-to-top, so it spends the axes' height and // each titled axis needs a column of its own -- which is what keeps a Left/Right // pair of titles from being drawn over each other now that they no longer stack // down a taller strip. if (text_ == nullptr) return 0.0f; const u32 titles = titled_y_axis_count(); if (titles == 0u) return 0.0f; return static_cast(titles) * text_->height() + 2.0f * style_.device(style_.grid_label_pad); } f32 Plot::y_label_width() const { if (text_ == nullptr || y_axes_.empty()) return 0.0f; f32 widest = 0.0f; for (const Axis& a : y_axes_) { for (const Tick& tick : a.ticks) { if (!tick.major || tick.label.empty()) continue; widest = std::max(widest, text_->measure(tick.label.c_str())); } } return widest + 2.0f * style_.device(style_.grid_label_pad); } void Plot::build_legend() { LegendLayout& legend = layout_.legend; legend.rows.clear(); legend.panel = Rect{}; // Nothing to measure with, or nothing asked for: an empty layout, and with it a // plot that lays out exactly as it did before legends existed. if (text_ == nullptr || !legend_.visible) return; // Every one of these is a logical size -- the legend is chrome a user reads -- // while `text_->height()` and the name measures are device: converting each one // once, into a local, is what keeps the panel's arithmetic in device pixels // throughout, like the strips it is fitted into. const f32 pad = style_.device(legend_.pad); const f32 margin = style_.device(legend_.margin); const f32 swatch_w = style_.device(legend_.swatch_w); const f32 swatch_h = style_.device(legend_.swatch_h); const f32 row_gap = style_.device(legend_.row_gap); const f32 line_h = text_->height(); const f32 row_h = std::max(swatch_h, line_h); // Entry widths, prefix-maxed: dropping from the end then costs no re-measure, and // the panel's width after a drop is the width of an entry that is still listed. legend_name_px_.clear(); for (const Trace& trace : traces_) { // A trace with no series, no samples, or no name is not an entry -- the same // "visible" test every other consumer of a trace makes, plus the name. if (!trace.visible() || trace.name.empty()) continue; const f32 width = text_->measure(trace.name.c_str()); legend_name_px_.push_back(legend_name_px_.empty() ? width : std::max(legend_name_px_.back(), width)); } const u32 entries = static_cast(legend_name_px_.size()); if (entries == 0u) return; const f32 ellipsis_w = text_->measure(kLegendEllipsis); const f32 room_w = layout_.axes.w - 2.0f * margin; const f32 room_h = layout_.axes.h - 2.0f * margin; // Fit, from every entry down: the panel is a row per entry with `row_gap` between, // and, once an entry has been dropped, one more row for the marker that says so. // The marker is part of the panel, so it is part of what has to fit -- a legend // that dropped entries and then overhung the axes would be the defect the drop // exists to avoid. When even one entry plus the marker has no room, there is no // panel at all: a plot's data area is not a place to draw a legend that does not // fit it. u32 keep = entries; f32 panel_w = 0.0f; f32 panel_h = 0.0f; for (;;) { const bool truncated = keep < entries; const f32 widest = truncated ? std::max(legend_name_px_[keep - 1u], ellipsis_w) : legend_name_px_[keep - 1u]; const f32 rows = static_cast(keep + (truncated ? 1u : 0u)); panel_w = 2.0f * pad + swatch_w + pad + widest; panel_h = 2.0f * pad + rows * row_h + (rows - 1.0f) * row_gap; if (panel_w <= room_w && panel_h <= room_h) break; if (--keep == 0u) return; // not even one entry and its marker fit } // Anchored in the corner `place` names and held `margin` off both edges, which is // the inset the fit above was measured against -- so the panel is inside `axes` by // construction and can never cross it, whatever corner it was put in. f32 x = layout_.axes.x + margin; f32 y = layout_.axes.y + margin; if (legend_.place == LegendPlace::TopRight || legend_.place == LegendPlace::BottomRight) { x = layout_.axes.right() - margin - panel_w; } if (legend_.place == LegendPlace::BottomLeft || legend_.place == LegendPlace::BottomRight) { y = layout_.axes.bottom() - margin - panel_h; } legend.panel = Rect{x, y, panel_w, panel_h}; // The rows themselves: the swatch and the name's line box, both placed here rather // than left to a renderer to re-derive (one placement rule, one place to read it). // The name box starts one swatch and one gutter in -- exactly what the panel's // width allowed for -- and both boxes are centred in the row's height, so a swatch // shorter than the text line is centred on it instead of sitting on its baseline. const f32 name_x = x + pad + swatch_w + pad; f32 row_y = y + pad; for (u32 i = 0; i < traces_.size() && legend.rows.size() < keep; ++i) { const Trace& trace = traces_[i]; if (!trace.visible() || trace.name.empty()) continue; LegendRow row; row.trace = i; row.swatch = Rect{x + pad, row_y + 0.5f * (row_h - swatch_h), swatch_w, swatch_h}; row.name = Rect{name_x, row_y + 0.5f * (row_h - line_h), text_->measure(trace.name.c_str()), line_h}; legend.rows.push_back(row); row_y += row_h + row_gap; } if (keep < entries) { // Entries were dropped; the marker takes the row after the last kept one, with no // swatch -- there is no trace to colour -- and the name column's own x, so it // lines up with the names above it. LegendRow marker; marker.ellipsis = true; marker.name = Rect{name_x, row_y + 0.5f * (row_h - line_h), ellipsis_w, line_h}; legend.rows.push_back(marker); } } 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(tx.to_screen_x(region.x0)); const f32 hi = static_cast(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