feat(plot): nice-number ticks, axes, grid, traces, cursors, measurement queries
This commit is contained in:
1 parent
b4e88fe0c1
commit
93fc06f050
18 files changed
+1582
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user