feat(plot): nice-number ticks, axes, grid, traces, cursors, measurement queries

This commit is contained in:
Martino Ferrari committed 2026-09-27 22:21:22 +02:00
1 parent b4e88fe0c1
commit 93fc06f050
18 files changed
+1582

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <string>
#include <plot/core/color.hpp>
#include <plot/core/types.hpp>
namespace plot {
/// A pinned point with a text caption. `x`/`y` are data coordinates on the
/// `axis_x`/`axis_y` pair; the text is UTF-8 and is measured through the plot's
/// text backend, exactly like an axis label.
struct Annotation {
f64 x = 0.0;
f64 y = 0.0;
std::string text;
Color color = Color::hex(0xE8E8E8);
u32 axis_x = 0;
u32 axis_y = 0;
bool visible = true;
};
} // namespace plot
+45
View File
@@ -0,0 +1,45 @@
#pragma once
#include <string>
#include <vector>
#include <plot/core/types.hpp>
#include <plot/plot/ticks.hpp>
namespace plot {
/// Where an axis sits around the plot area.
enum class AxisPlace { Bottom, Top, Left, Right };
/// Everything a caller can say about an axis at creation time. The six fields are
/// the five of the design spec plus the label format; the trailing default keeps
/// the spec's five-field aggregate initialisation working unchanged.
struct AxisDesc {
std::string label;
Range range{0.0, 1.0};
bool auto_scale = true;
AxisPlace place = AxisPlace::Bottom;
bool log = false;
TickFormat format = TickFormat::Si;
};
/// A live axis: its range and presentation flags, plus the tick list `refresh()`
/// regenerates for the current pixel size. `auto_scale` and `log` are public
/// because the input controller and the renderer both read and write them.
struct Axis {
std::string label;
Range range{0.0, 1.0};
bool auto_scale = true;
AxisPlace place = AxisPlace::Bottom;
bool log = false;
TickFormat format = TickFormat::Si;
/// Ticks for the last `refresh()`, ascending, majors and (when asked) minors.
std::vector<Tick> ticks;
/// Regenerates `ticks` for an axis `px` pixels long, majors at least `min_px`
/// apart. `include_minors` additionally emits the grid subdivisions. Labels use
/// `format` -- `TickFormat::Time` relabels the majors with `format_time`, which
/// `generate_ticks` cannot know about.
void refresh(f32 px, f32 min_px, bool include_minors = true);
};
} // namespace plot
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include <string>
#include <plot/core/color.hpp>
#include <plot/core/types.hpp>
namespace plot {
/// Where a cursor sits relative to the data grid.
enum class SnapMode {
Free, ///< the cursor stays exactly where the caller put it.
NearestSample, ///< the cursor is pulled onto the nearest sample of the trace's x grid.
};
/// A vertical marker at data x. `axis_x` names the x axis whose data coordinate
/// `x` is in; `label` is an optional caption drawn next to the marker.
struct Cursor {
f64 x = 0.0;
SnapMode snap = SnapMode::Free;
Color color = Color::hex(0xFFB020);
std::string label;
u32 axis_x = 0;
bool visible = true;
};
} // namespace plot
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <vector>
#include <plot/core/types.hpp>
#include <plot/plot/axis.hpp>
#include <plot/plot/ticks.hpp>
namespace plot {
/// One grid line as a segment in DATA coordinates. The renderer maps the
/// endpoints through its transform, so pan and zoom move grid lines for free --
/// baking screen coordinates in here would break that uniform-only contract.
struct GridLine {
f64 x0 = 0.0, y0 = 0.0, x1 = 0.0, y1 = 0.0;
bool major = true;
};
/// Tick-aligned grid geometry: one segment per tick, spanning the perpendicular
/// axis' full range.
class Grid {
public:
bool vertical = true; ///< emit lines at x ticks
bool horizontal = true; ///< emit lines at y ticks
bool show_minor = true; ///< keep minor-tick lines
/// Rebuilds the segments from each axis' ticks and range. Ticks outside the
/// perpendicular range (or non-finite) are skipped, so a stale tick list can
/// never draw a line off the plot area. `out`-style: clears then appends.
void build(const std::vector<Tick>& x_ticks, Range x_range,
const std::vector<Tick>& y_ticks, Range y_range);
const std::vector<GridLine>& lines() const noexcept { return lines_; }
usize size() const noexcept { return lines_.size(); }
void clear() noexcept { lines_.clear(); }
private:
std::vector<GridLine> lines_;
};
} // namespace plot
+47
View File
@@ -0,0 +1,47 @@
#pragma once
#include <plot/core/types.hpp>
#include <plot/data/series.hpp>
namespace plot {
/// Interval statistics over the half-open sample range [begin, end).
///
/// `dx` is the x distance between the first and the last sample of the interval
/// (0 for a single-sample interval), `dy` the corresponding y difference,
/// `slope` = `dy / dx`, `mean` and `rms` over the `end - begin` samples, `ymin`
/// / `ymax` the exact extremes, and `integral` the area under the samples.
///
/// `integral` is deliberately a LEFT-RIEMANN sum, `sum(begin, end) * dx`: the
/// series' samples are taken as the value at the left edge of each step. A
/// trapezoid rule would be equally defensible and compute a different number for
/// every reported interval, so this choice is part of the result's contract, not
/// an implementation detail. Do not "fix" it without changing every documented
/// value and every test that pins one.
///
/// `valid` is false (and every field zero) for an interval that is empty,
/// inverted or out of range; such a call never reads the series.
struct Measurement {
f64 dx = 0.0;
f64 dy = 0.0;
f64 slope = 0.0;
f64 rms = 0.0;
f64 mean = 0.0;
f64 ymin = 0.0;
f64 ymax = 0.0;
f64 integral = 0.0;
bool valid = false;
};
/// Stateless interval measurement over a data::Series.
struct Measure {
/// Evaluates [begin, end). Every quantity comes from `Series::reduce`/`sum`/
/// `sumsq` -- never from the pyramid's `*_buckets` forms, which exclude partial
/// edge buckets and would silently return wrong extrema and sums for a range
/// that is not bucket-aligned. Each of those queries is O(kBucket) worst case
/// (bucket-aligned interior from the pyramid, at most two partial edge buckets
/// read raw), so a measurement never scans the series and its cost does not
/// grow with series length.
static Measurement evaluate(const data::Series& s, u64 begin, u64 end);
};
} // namespace plot
+195
View File
@@ -0,0 +1,195 @@
#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 two label
/// areas are the strips the tick labels live in.
struct PlotLayout {
Rect axes;
Rect y_label_area;
Rect x_label_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;
};
/// 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_; }
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. The caller (the renderer) owns the backend; Plot never
/// creates one and never touches GL.
void set_text_backend(const render::TextBackend* text) noexcept { text_ = 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()); }
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.
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 its series changed (its pointer or its sample
/// count) -- never once per frame. 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;
/// 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.
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` catch a trace
/// being pointed at a different (or grown) series without anyone telling us.
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;
};
/// Fit one axis to its traces' bounds, leaving auto_scale alone. False when no
/// visible trace is bound to the axis, in which case the range is untouched.
bool fit_x_axis(u32 axis);
bool fit_y_axis(u32 axis);
/// 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;
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
+68
View File
@@ -0,0 +1,68 @@
#pragma once
#include <string>
#include <vector>
#include <plot/core/types.hpp>
namespace plot {
/// One axis tick. `value` is in data coordinates; `major` marks the labelled
/// ticks (minor ticks carry an empty label); `label` is UTF-8.
struct Tick {
f64 value = 0.0;
bool major = true;
std::string label;
};
/// How an axis renders its tick labels.
enum class TickFormat {
Si, ///< SI-prefixed numbers, e.g. `1.5u`, `2.5G`, `1.23k`.
Time, ///< clock strings, e.g. `1:30`, `1:00:00`.
};
/// Writes an SI-prefixed rendering of `v` to `buf`, always NUL-terminated and
/// never writing past `n` bytes (a too-small buffer truncates, it does not
/// overflow). ASCII plus the UTF-8 micro sign U+00B5 (0xC2 0xB5) for 1e-6, so
/// `plot::render::BitmapFont` renders it instead of mojibake.
///
/// `format_si(0.0)` is exactly `"0"`. Values use three significant digits, so
/// `1234.0` renders as `1.23k`. The sign of a negative value is kept. A value
/// whose engineering exponent falls outside the prefix table (or a label that
/// would exceed 12 characters) falls back to `"%.3g"`, and a non-finite value
/// always does -- `nan`/`inf` are never silently dropped, they are printed by the
/// fallback exactly as `%.3g` prints them.
///
/// `unit` is appended after the prefix when non-null, which is how a caller
/// spells a unit the library cannot know: `format_si(v, buf, n, "\xC2\xB0")` for
/// degrees, `"\xCE\xA9"` for ohms.
void format_si(f64 v, char* buf, usize n, const char* unit = nullptr);
/// Writes `seconds` as a compact clock string to `buf`, NUL-terminated and never
/// past `n` bytes. Shapes: `500ms`, `1.5s`, `1:30`, `1:00:00`, `2.5d`; negatives
/// keep their sign. A non-finite value falls back to `"%.3g"`.
void format_time(f64 seconds, char* buf, usize n);
/// The hard cap on `generate_ticks` output, minor ticks included. A range dense
/// enough to need more is truncated rather than allowed to grow without bound.
inline constexpr usize kMaxTicks = 256;
/// Nice-number ticks for the closed interval `range` on an axis `px` pixels long,
/// with major ticks at least `min_px` pixels apart. Steps are 1/2/5 x 10^n, chosen
/// by rounding the ideal step *up* to the next nice value so major spacing really
/// does meet `min_px`. Ticks are appended to `out`, which is cleared first, in
/// ascending value order and labelled by `format_si`.
///
/// `include_minors` adds, for grid geometry, the minor ticks subdividing each
/// major step by 5 (by 4 when the step's leading digit is 2). Without it `out`
/// holds exactly the labelled major ticks -- the labelled set a caller draws
/// numbers for, with no unlabelled entries to filter. Either way the total is
/// capped at kMaxTicks.
///
/// Hostile input clears `out` and returns: a non-finite endpoint, `max <= min`
/// (a zero or negative span, which includes the collapsed range of a
/// single-sample series), a non-positive `px` or `min_px`, and a span whose step
/// overflows to infinity.
void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out,
bool include_minors = false);
} // namespace plot
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include <plot/core/color.hpp>
#include <plot/core/types.hpp>
#include <plot/data/series.hpp>
namespace plot {
/// How a caller asks for a trace. The series pointer is borrowed and must outlive
/// the plot's use of it; `axis_x`/`axis_y` index the plot's x and y axis lists.
struct TraceDesc {
const data::Series* series = nullptr;
Color color = Color::hex(0x4FA3FF);
f32 width = 1.5f;
u32 axis_x = 0, axis_y = 0;
};
/// A series bound to a colour, width and axis pair. The series pointer may be
/// null (a trace whose data has not arrived yet): every consumer skips such a
/// trace rather than dereferencing it.
struct Trace {
const data::Series* series = nullptr;
Color color = Color::hex(0x4FA3FF);
f32 width = 1.5f;
u32 axis_x = 0, axis_y = 0;
/// True when the trace has a series with at least one sample to draw.
bool visible() const noexcept { return series != nullptr && series->size() > 0; }
};
} // namespace plot