Files
plotter/src/plot/plot.cpp
T

951 lines
40 KiB
C++

#include <plot/plot/plot.hpp>
#include <plot/plot/detail/measure_count.hpp>
#include <algorithm>
#include <cmath>
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<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;
}
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<u32>(x_axes_.size() - 1);
}
u32 Plot::add_y_axis(const AxisDesc& desc) {
y_axes_.push_back(axis_from(desc));
return static_cast<u32>(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<u32>(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<u32>(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<u32>(measures_.size() - 1);
}
u32 Plot::add_annotation(const Annotation& annotation) {
annotations_.push_back(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();
}
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<f64>(r.x),
static_cast<f64>(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<f64>(r.bottom()),
static_cast<f64>(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<std::ptrdiff_t>(id));
// The cache is keyed by region index, so the removed region's entries name nothing
// and every entry above the hole names a region one slot further down. The
// surviving regions' own intervals are untouched -- erase keeps their order -- so
// their cached statistics stay true and are only re-indexed, not dropped.
std::erase_if(region_stats_,
[id](const RegionStatsSlot& slot) { return slot.region == id; });
for (RegionStatsSlot& slot : region_stats_) {
if (slot.region > id) slot.region -= 1u;
}
}
const RegionStats& Plot::region_stats(u32 region, u32 trace) const {
static const RegionStats none{};
if (region >= regions_.size() || trace >= traces_.size()) return none;
RegionStatsSlot* slot = nullptr;
for (RegionStatsSlot& candidate : region_stats_) {
if (candidate.region == region && candidate.trace == trace) {
slot = &candidate;
break;
}
}
if (slot == nullptr) {
// Filled on demand, one entry per pair a caller asks about: a plot with a dozen
// regions and a dozen traces never pays for the cross product it never queried.
region_stats_.push_back(RegionStatsSlot{});
slot = &region_stats_.back();
slot->region = region;
slot->trace = trace;
}
const data::Series* series = traces_[trace].series;
const u64 samples = series != nullptr ? series->size() : 0;
// A saturated ring keeps `samples` constant while its window slides: `x0()` is
// what moves, and with it which samples the region's data interval covers. Caching
// on (pointer, count) alone would freeze the readout at the first saturated frame
// -- the same defect the measurement cache avoids with this same third key.
const f64 x0 = series != nullptr ? series->x0() : 0.0;
if (slot->dirty || series != slot->last_series || samples != slot->last_size ||
x0 != slot->last_x0) {
slot->result = series != nullptr ? evaluate_region_stats(regions_[region], *series)
: RegionStats{};
slot->last_series = series;
slot->last_size = samples;
slot->last_x0 = x0;
slot->dirty = false;
}
return slot->result;
}
RegionStats Plot::evaluate_region_stats(const Region& region, const data::Series& series) {
RegionStats stats;
u64 begin = 0;
u64 end = 0;
if (!covered_samples(series, region.x0, region.x1, begin, end)) return stats;
// Exact interval queries on Series, never the pyramid's *_buckets forms: this
// interval is not bucket-aligned in general (that is the whole point of a free
// region), and a bucket-grained answer would silently include samples outside it.
const Range extremes = series.reduce(begin, end);
const f64 total = series.sum(begin, end);
const f64 squared = series.sumsq(begin, end);
const f64 n = static_cast<f64>(end - begin);
stats.x0 = region.x0;
stats.x1 = region.x1;
stats.width = region.x1 - region.x0;
stats.min = extremes.min;
stats.max = extremes.max;
stats.mean = total / n;
stats.rms = std::sqrt(squared / n);
stats.integral = total * series.dx(); // left-Riemann, the measurement's convention
stats.count = end - begin;
stats.valid = true;
return stats;
}
MeasureSpan Plot::measure_span(u32 id) const noexcept {
// An id that names nothing, or a measure built from cursor ids that were never
// 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<f32>(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<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 {
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<f32>(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<u32>(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<f32>(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<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