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.
277 lines
13 KiB
C++
277 lines
13 KiB
C++
#pragma once
|
|
#include <vector>
|
|
|
|
#include <plot/core/rect.hpp>
|
|
#include <plot/core/types.hpp>
|
|
#include <plot/math/transform.hpp>
|
|
#include <plot/plot/annotation.hpp>
|
|
#include <plot/plot/axis.hpp>
|
|
#include <plot/plot/cursor.hpp>
|
|
#include <plot/plot/grid.hpp>
|
|
#include <plot/plot/measure.hpp>
|
|
#include <plot/plot/trace.hpp>
|
|
#include <plot/render/style.hpp>
|
|
#include <plot/render/text.hpp>
|
|
|
|
namespace plot {
|
|
|
|
/// Pixel geometry of a plot's chrome, all of it in the same pixel space as
|
|
/// `Rect` (origin top-left). `axes` is the area data is drawn into; the label
|
|
/// areas are the strips the tick labels live in and the title areas the strips
|
|
/// the axis titles live in, each outside `axes` and so never over the data.
|
|
///
|
|
/// `y_*` strips are left of `axes` and `x_*` ones below it, whatever an axis'
|
|
/// `place` says: an axis placed on the opposite edge still has its labels and
|
|
/// its title on the side the layout reserves, exactly as it always has.
|
|
struct PlotLayout {
|
|
Rect axes;
|
|
Rect y_label_area;
|
|
Rect x_label_area;
|
|
/// One text line each, just outside the matching label area. Zero-sized (and
|
|
/// unoccupied) when no axis of that direction has a title.
|
|
Rect y_title_area;
|
|
Rect x_title_area;
|
|
/// Left strip width. The default is used when no text backend has been set.
|
|
f32 y_axis_width = 56.0f;
|
|
/// Bottom strip height. The default is used when no text backend has been set.
|
|
f32 x_axis_height = 22.0f;
|
|
/// Width of `y_title_area` and height of `x_title_area`, both 0.0f when nothing
|
|
/// is reserved. Every titled axis of a direction gets its own text line inside
|
|
/// its strip -- which is what keeps a Left/Right (or Bottom/Top) pair from
|
|
/// drawing two titles over each other -- so `x_title_height` is one line per
|
|
/// titled x axis; `y_title_width` is the widest y title plus the padding, since
|
|
/// y titles are horizontal runs that stack down the axes-tall strip and need
|
|
/// width, not another row of it. With no text backend there is no title to
|
|
/// measure and both stay 0.0f.
|
|
f32 y_title_width = 0.0f;
|
|
f32 x_title_height = 0.0f;
|
|
};
|
|
|
|
/// The public façade: axes, traces, cursors, annotations and their layout.
|
|
///
|
|
/// It is pure CPU state -- no GL context, no font of its own. The renderer hands
|
|
/// it the font to measure labels with (set_text_backend) and reads layout(),
|
|
/// the transforms and the measurement results back out. Everything the renderer
|
|
/// draws (tick geometry, grid segments, cursor x in data space) is described in
|
|
/// data coordinates and pixels here; nothing is uploaded anywhere.
|
|
///
|
|
/// Owned collections are indexed by the `u32` id the matching `add_*` returned.
|
|
/// A stale or unknown id is not fatal: accessors return a default-constructed
|
|
/// object and measurement() returns an invalid result.
|
|
class Plot {
|
|
public:
|
|
Plot() = default;
|
|
|
|
// --- viewport, style, text metrics ------------------------------------
|
|
|
|
/// The rectangle this plot occupies on screen, in framebuffer pixels.
|
|
void set_viewport(Rect px) noexcept { viewport_ = px; }
|
|
const Rect& viewport() const noexcept { return viewport_; }
|
|
|
|
/// Set from the renderer, which owns presentation: `Renderer::draw()` copies
|
|
/// its own `style()` in here every frame, so a style set on the plot is what a
|
|
/// plot drawn *without* a renderer lays out with -- a frame drawn by one always
|
|
/// uses the renderer's values.
|
|
void set_style(const render::Style& s) noexcept { style_ = s; }
|
|
const render::Style& style() const noexcept { return style_; }
|
|
|
|
/// The font layout() measures tick labels with. Borrowed, not owned, and may be
|
|
/// null -- then the label areas fall back to PlotLayout's defaults and no string
|
|
/// is measured: a plot with no renderer lays out with whatever the host sets
|
|
/// here, which is for consumers that draw the plot themselves.
|
|
///
|
|
/// A plot drawn by a `Renderer` is the resolution the other way round: the
|
|
/// renderer draws the labels with its own bitmap font, so it installs that font
|
|
/// here on every `draw()` and the strips are measured with the metrics the
|
|
/// labels are actually drawn with. A backend installed before the first draw is
|
|
/// therefore replaced, with a debug-build diagnostic naming the mismatch.
|
|
void set_text_backend(const render::TextBackend* text) noexcept { text_ = text; }
|
|
|
|
/// The backend layout() measures labels with, or null when none is set. A
|
|
/// renderer replaces whatever it finds here with its own font (see
|
|
/// set_text_backend()); a plot nobody draws keeps it.
|
|
const render::TextBackend* text_backend() const noexcept { return text_; }
|
|
|
|
// --- construction -----------------------------------------------------
|
|
|
|
u32 add_x_axis(const AxisDesc& desc);
|
|
u32 add_y_axis(const AxisDesc& desc);
|
|
u32 add_trace(const TraceDesc& desc);
|
|
u32 add_cursor(f64 x, Color color);
|
|
/// Measures the interval between two cursors over the series of `trace`.
|
|
u32 add_measure(u32 cursor_a, u32 cursor_b, u32 trace = 0);
|
|
u32 add_annotation(const Annotation& annotation);
|
|
|
|
// --- accessors --------------------------------------------------------
|
|
// An id that does not name an object yields a default-constructed one, so a
|
|
// stale id can never read out of bounds.
|
|
|
|
u32 x_axis_count() const noexcept { return static_cast<u32>(x_axes_.size()); }
|
|
u32 y_axis_count() const noexcept { return static_cast<u32>(y_axes_.size()); }
|
|
/// Axes of that direction that carry a title, i.e. that `layout()` reserves a
|
|
/// text line for and `Renderer::draw_titles()` draws one for. Zero when no
|
|
/// backend can measure a title, whatever the labels say.
|
|
u32 titled_x_axis_count() const noexcept;
|
|
u32 titled_y_axis_count() const noexcept;
|
|
u32 trace_count() const noexcept { return static_cast<u32>(traces_.size()); }
|
|
u32 cursor_count() const noexcept { return static_cast<u32>(cursors_.size()); }
|
|
u32 measure_count() const noexcept { return static_cast<u32>(measures_.size()); }
|
|
u32 annotation_count() const noexcept { return static_cast<u32>(annotations_.size()); }
|
|
|
|
const Axis& x_axis(u32 axis) const;
|
|
const Axis& y_axis(u32 axis) const;
|
|
const Trace& trace(u32 id) const;
|
|
const Cursor& cursor(u32 id) const;
|
|
const Annotation& annotation(u32 id) const;
|
|
|
|
// --- ranges and transforms --------------------------------------------
|
|
|
|
Range x_range(u32 axis) const;
|
|
Range y_range(u32 axis) const;
|
|
|
|
/// Sets an explicit range. Choosing a range is a user decision, so it also
|
|
/// clears that axis' auto_scale: a later layout() must not snap the range back.
|
|
/// auto_fit_x()/auto_fit_y() re-arm it.
|
|
void set_x_range(u32 axis, Range r);
|
|
void set_y_range(u32 axis, Range r);
|
|
|
|
/// Re-arms (or disarms) the per-layout auto scaling of one axis.
|
|
void set_x_auto_scale(u32 axis, bool on);
|
|
void set_y_auto_scale(u32 axis, bool on);
|
|
|
|
/// Data -> pixels for one axis, built from the axis' range, log flag and the
|
|
/// current layout. Always invertible: a collapsed range or a collapsed axes
|
|
/// rect is widened by math::Transform, so to_data_*(to_screen_*(v)) == v.
|
|
Transform x_transform(u32 axis) const;
|
|
Transform y_transform(u32 axis) const;
|
|
|
|
/// Fits every x axis to the union of the series bounds of the traces bound to
|
|
/// it, padded by 2% of the span, and re-arms auto scale on the axes it fitted.
|
|
/// An axis with no visible trace is left exactly as it was.
|
|
///
|
|
/// A log axis is fitted in log space and needs a positive value to show. When
|
|
/// its traces hold none (every sample negative or zero) the fit is refused: the
|
|
/// axis keeps its current range, auto scale is not re-armed, and the next
|
|
/// auto-fit tries again -- so a stream that starts at zero begins fitting on its
|
|
/// own once it goes positive. A log axis is therefore never left with `min >=
|
|
/// max` or a non-positive bound by an auto-fit.
|
|
void auto_fit_x();
|
|
/// Same for one y axis, over that axis' traces' y bounds.
|
|
void auto_fit_y(u32 axis);
|
|
|
|
// --- cursors and measurements -----------------------------------------
|
|
|
|
/// Moves a cursor and invalidates the measurements that depend on cursors.
|
|
/// A SnapMode::NearestSample cursor is pulled onto the nearest sample of the
|
|
/// first visible trace on its x axis.
|
|
void set_cursor_x(u32 id, f64 x);
|
|
void set_cursor_snap(u32 id, SnapMode mode);
|
|
|
|
/// The measurement for `id`. Re-evaluated only when a cursor moved, when the
|
|
/// measure was added, or when the series underneath it changed: a different
|
|
/// series, a longer one, or a sliding window whose sample count stayed the same
|
|
/// (`x0()` is the third key -- see MeasureSlot). Never once per frame. A
|
|
/// saturated stream therefore keeps reporting live values with a stationary
|
|
/// cursor pair. An unknown id, a null-series trace, an inverted cursor pair or
|
|
/// an empty series yields `valid == false`. const because recomputing fills a
|
|
/// cache: nothing a caller can observe changes.
|
|
const Measurement& measurement(u32 id) const;
|
|
|
|
/// The cursor pair `id` was created over. The renderer needs it to place the
|
|
/// shaded band the measurement readout belongs to; the statistics themselves
|
|
/// are in measurement().
|
|
MeasureSpan measure_span(u32 id) const noexcept;
|
|
|
|
/// Drops every cached measurement. Needed after a series is mutated in place
|
|
/// (data::ArraySeries::commit() with the same sample count), which a pointer
|
|
/// and size check cannot detect.
|
|
void invalidate_measurements() noexcept;
|
|
|
|
// --- layout -----------------------------------------------------------
|
|
|
|
/// Recomputes the layout, every axis' ticks and the grid, then returns the
|
|
/// layout. The label strips come from the text backend's metrics when one is
|
|
/// set (`measure()` of the widest y tick label, `height()` for the x strip) and
|
|
/// from PlotLayout's defaults otherwise.
|
|
///
|
|
/// `axes` is inset by the label strip of each occupied edge and, when that
|
|
/// direction has a titled axis, by the strip that title lives in (`y_title_width`
|
|
/// and `x_title_height` say how much that is): a title can then only be drawn
|
|
/// outside the data, whatever the title says.
|
|
///
|
|
/// The chrome is capped so `axes` keeps half the viewport on each dimension
|
|
/// whatever the labels and titles ask for: a plot with a title wider than a
|
|
/// narrow panel shrinks, it does not vanish -- an axes rect with no width or no
|
|
/// height selects no level, stages no segment and draws no trace, and says
|
|
/// nothing about why. The label strip is what makes the tick values readable, so
|
|
/// it is served first and the title strip takes what is left of the allowance.
|
|
///
|
|
/// Call this once per frame -- it rebuilds the ticks and the grid -- and pass
|
|
/// the result down.
|
|
const PlotLayout& layout();
|
|
/// The layout as of the last refresh. The const overload never recomputes, so
|
|
/// a lookup on a const Plot cannot move the axes out from under the caller.
|
|
const PlotLayout& layout() const noexcept { return layout_; }
|
|
|
|
/// Grid segments for the primary axis pair (x axis 0, y axis 0), in data space.
|
|
const Grid& grid() const noexcept { return grid_; }
|
|
|
|
private:
|
|
/// One measure plus its cache. The dirty flag is what keeps measurement() from
|
|
/// re-evaluating every frame; `last_series`, `last_size` and `last_x0` catch a
|
|
/// trace being pointed at a different series, a series that grew, and a ring
|
|
/// whose window slid under a constant sample count -- the last of which neither
|
|
/// of the other two sees, since a saturated stream keeps its size while every
|
|
/// sample it holds changes.
|
|
struct MeasureSlot {
|
|
u32 cursor_a = 0;
|
|
u32 cursor_b = 0;
|
|
u32 trace = 0;
|
|
mutable Measurement result;
|
|
mutable bool dirty = true;
|
|
mutable const data::Series* last_series = nullptr;
|
|
mutable u64 last_size = 0;
|
|
mutable f64 last_x0 = 0.0;
|
|
};
|
|
|
|
/// Fit one axis to its traces' bounds, leaving auto_scale alone. False when the
|
|
/// axis cannot be fitted (no visible trace bound to it, or a log axis whose
|
|
/// traces hold no positive value), in which case the range is untouched.
|
|
bool fit_x_axis(u32 axis);
|
|
bool fit_y_axis(u32 axis);
|
|
/// Union of the bounds of the visible traces bound to one axis (`x_side`
|
|
/// selects x or y). False when there is no such trace; `out` then retains
|
|
/// whatever it held.
|
|
bool trace_bounds_union(bool x_side, u32 axis, Range& out) const;
|
|
/// Refit every axis that is still on auto scale. layout() runs this, so a
|
|
/// growing stream stays framed until a caller gesture turns auto scale off.
|
|
void autoscale_axes();
|
|
void refresh_ticks();
|
|
void build_grid();
|
|
void mark_measures_dirty() noexcept;
|
|
/// `c.x` pulled onto the nearest sample when the cursor asks to snap.
|
|
f64 snapped_x(const Cursor& c) const;
|
|
/// Widest y tick label plus the label padding on both sides: the left strip the
|
|
/// text backend asks for. Zero with no backend or no y axis.
|
|
f32 y_label_width() const;
|
|
/// Widest y axis title plus the label padding on both sides: the vertical strip
|
|
/// a horizontal y title fits in. Zero with no backend or no titled y axis.
|
|
f32 y_title_strip_width() const;
|
|
|
|
Rect viewport_{};
|
|
render::Style style_{};
|
|
const render::TextBackend* text_ = nullptr;
|
|
|
|
std::vector<Axis> x_axes_;
|
|
std::vector<Axis> y_axes_;
|
|
std::vector<Trace> traces_;
|
|
std::vector<Cursor> cursors_;
|
|
std::vector<MeasureSlot> measures_;
|
|
std::vector<Annotation> annotations_;
|
|
|
|
PlotLayout layout_{};
|
|
Grid grid_;
|
|
};
|
|
|
|
} // namespace plot
|