Files
plotter/src/plot/plot.cpp
T
Martino Ferrari a08b184481 fix(plot,render,gl): live streaming measurements, handed-back GL state
Final whole-branch review fixes.

1 (Important). Plot::measurement() cached on (series pointer, sample count)
only, so a saturated StreamSeries -- whose size() stays pinned while x0()
advances every acquire() -- froze its readout on the first saturated frame
until a cursor moved. The cache key now carries the series' x0() (new
MeasureSlot::last_x0), so a sliding window recomputes and a static series
still does not: asserted with a new macro-gated counter hook
(plot/detail/measure_count.hpp, PLOT_COUNT_MEASURE_RECOMPUTES, wired like the
pyramid and ring counters, absent from the release library). New case
saturated_stream_measurement_tracks_the_sliding_window: saturated ring,
stationary cursors, ramp in absolute x -- zero recomputes for repeated reads
with nothing moving, one per acquire while it slides.

2-3, 9. Spec: 4.3 described a GL_LINE_STRIP path that does not exist (there is
one rasteriser -- instanced anti-aliased quads -- and dense_threshold_spp
selects the gather level, not a primitive); 8 named a PLOT_GL_CHECK() macro
that exists nowhere, and stated no exception policy. The table, the field's
comment and 8 now describe the real gather-level threshold, the real
glGetError() drain plus the stores' drop-to-orphan fallbacks, and the real
policy: library failures return Status/bool, allocating entry points propagate
std::bad_alloc.

4. The frame handed back neither GL_UNPACK_ALIGNMENT (init() sets 1 for the
atlas) nor the clear colour (queried once with a new glGetFloatv entry).
end_frame() restores both and renderer.hpp lists them in the residue
contract.

5. draw() kept a host TextBackend for layout while drawing with its own font.
The renderer now installs its font unconditionally -- strips and labels measure
and draw with one backend -- with a debug-build diagnostic if a different one
was set, and Plot::set_text_backend() is documented as the no-renderer path.
New case renderer_measures_the_labels_it_draws asserts layout, measuring and
the drawn label's ink agree.

6-10. draw()'s style overwrite stated at the call site; the load-sensitive
wall-clock bound in test_measure.cpp replaced by a counter assertion (same
interval, same fold count over a 512x length ratio); dump_frame_ppm drops
noexcept and says why it may allocate; error.txt ignored (not this project's,
not generated here) rather than deleted.

Verified: debug and release build clean (-Wall -Wextra -Wpedantic -Wconversion
-Wshadow), 4/4 ctest entries pass, 130 cases pass (was 127), umbrella and
header self-containment checks pass, and no counter symbol in the release
archive.
2026-09-28 03:42:34 +02:00

462 lines
17 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;
}
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;
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);
}
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<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)) 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<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();
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<f64>(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