#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; } 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; 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); } 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; } 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; x_axes_[axis].range = r; x_axes_[axis].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; } 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)) x_axes_[i].auto_scale = true; } } 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) { if (x_axes_[i].auto_scale) 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(); } 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() { // 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. autoscale_axes(); const f32 pad = 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 as wide as the // widest y title, since a y title is a horizontal run that stacks down the strip // and so needs width rather than another row of it (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(); 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. for (Axis& a : x_axes_) a.refresh(layout_.axes.w, style_.tick_min_px, true); for (Axis& a : y_axes_) a.refresh(layout_.axes.h, 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; } 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(i) * s.dx(); } return 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 { if (text_ == nullptr) return 0.0f; f32 widest = 0.0f; for (const Axis& a : y_axes_) { if (a.label.empty()) continue; widest = std::max(widest, text_->measure(a.label.c_str())); } return widest > 0.0f ? widest + 2.0f * style_.grid_label_pad : 0.0f; } 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_.grid_label_pad; } } // namespace plot