diff --git a/CMakeLists.txt b/CMakeLists.txt index 83c2bc0..375a982 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -19,6 +19,11 @@ add_library(plot_core src/render/buffer.cpp src/render/vertex_array.cpp src/render/program.cpp + src/plot/ticks.cpp + src/plot/axis.cpp + src/plot/grid.cpp + src/plot/measure.cpp + src/plot/plot.cpp ) add_library(plot::core ALIAS plot_core) diff --git a/include/plot/plot/annotation.hpp b/include/plot/plot/annotation.hpp new file mode 100644 index 0000000..cc433b8 --- /dev/null +++ b/include/plot/plot/annotation.hpp @@ -0,0 +1,22 @@ +#pragma once +#include + +#include +#include + +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 diff --git a/include/plot/plot/axis.hpp b/include/plot/plot/axis.hpp new file mode 100644 index 0000000..25d7f01 --- /dev/null +++ b/include/plot/plot/axis.hpp @@ -0,0 +1,45 @@ +#pragma once +#include +#include + +#include +#include + +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 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 diff --git a/include/plot/plot/cursor.hpp b/include/plot/plot/cursor.hpp new file mode 100644 index 0000000..f9171ea --- /dev/null +++ b/include/plot/plot/cursor.hpp @@ -0,0 +1,26 @@ +#pragma once +#include + +#include +#include + +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 diff --git a/include/plot/plot/grid.hpp b/include/plot/plot/grid.hpp new file mode 100644 index 0000000..6b19bd0 --- /dev/null +++ b/include/plot/plot/grid.hpp @@ -0,0 +1,40 @@ +#pragma once +#include + +#include +#include +#include + +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& x_ticks, Range x_range, + const std::vector& y_ticks, Range y_range); + + const std::vector& lines() const noexcept { return lines_; } + usize size() const noexcept { return lines_.size(); } + void clear() noexcept { lines_.clear(); } + + private: + std::vector lines_; +}; + +} // namespace plot diff --git a/include/plot/plot/measure.hpp b/include/plot/plot/measure.hpp new file mode 100644 index 0000000..224213c --- /dev/null +++ b/include/plot/plot/measure.hpp @@ -0,0 +1,47 @@ +#pragma once +#include +#include + +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 diff --git a/include/plot/plot/plot.hpp b/include/plot/plot/plot.hpp new file mode 100644 index 0000000..20b0e40 --- /dev/null +++ b/include/plot/plot/plot.hpp @@ -0,0 +1,195 @@ +#pragma once +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(x_axes_.size()); } + u32 y_axis_count() const noexcept { return static_cast(y_axes_.size()); } + u32 trace_count() const noexcept { return static_cast(traces_.size()); } + u32 cursor_count() const noexcept { return static_cast(cursors_.size()); } + u32 measure_count() const noexcept { return static_cast(measures_.size()); } + u32 annotation_count() const noexcept { return static_cast(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 x_axes_; + std::vector y_axes_; + std::vector traces_; + std::vector cursors_; + std::vector measures_; + std::vector annotations_; + + PlotLayout layout_{}; + Grid grid_; +}; + +} // namespace plot diff --git a/include/plot/plot/ticks.hpp b/include/plot/plot/ticks.hpp new file mode 100644 index 0000000..b60c510 --- /dev/null +++ b/include/plot/plot/ticks.hpp @@ -0,0 +1,68 @@ +#pragma once +#include +#include + +#include + +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& out, + bool include_minors = false); + +} // namespace plot diff --git a/include/plot/plot/trace.hpp b/include/plot/plot/trace.hpp new file mode 100644 index 0000000..67ba4a7 --- /dev/null +++ b/include/plot/plot/trace.hpp @@ -0,0 +1,30 @@ +#pragma once +#include +#include +#include + +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 diff --git a/src/plot/axis.cpp b/src/plot/axis.cpp new file mode 100644 index 0000000..1564c00 --- /dev/null +++ b/src/plot/axis.cpp @@ -0,0 +1,18 @@ +#include + +namespace plot { + +void Axis::refresh(f32 px, f32 min_px, bool include_minors) { + generate_ticks(range, px, min_px, ticks, include_minors); + if (format != TickFormat::Time) return; + // generate_ticks labels with format_si; a time axis relabels its majors here, + // because the tick generator has no business knowing about a clock. + char buf[64]; + for (Tick& tick : ticks) { + if (!tick.major) continue; + format_time(tick.value, buf, sizeof buf); + tick.label = buf; + } +} + +} // namespace plot diff --git a/src/plot/grid.cpp b/src/plot/grid.cpp new file mode 100644 index 0000000..2193125 --- /dev/null +++ b/src/plot/grid.cpp @@ -0,0 +1,45 @@ +#include + +#include +#include + +namespace plot { +namespace { + +/// True when `v` lies in `r`, allowing the rounding slack a generated tick can +/// carry at the range's edge: a tick that is within 1e-9 relative of the edge is +/// the edge, and dropping it would leave the frame un-drawn. +bool inside(f64 v, Range r) noexcept { + const f64 tolerance = std::max(std::fabs(r.min), std::fabs(r.max)) * 1e-9 + 1e-12; + return v >= r.min - tolerance && v <= r.max + tolerance; +} + +} // namespace + +void Grid::build(const std::vector& x_ticks, Range x_range, + const std::vector& y_ticks, Range y_range) { + lines_.clear(); + if (!std::isfinite(x_range.min) || !std::isfinite(x_range.max) || + !std::isfinite(y_range.min) || !std::isfinite(y_range.max)) { + return; + } + + // Segments stay in data space: the renderer applies the transform, so a pan or + // zoom needs no grid rebuild other than for a changed tick set. + if (vertical) { + for (const Tick& tick : x_ticks) { + if (!tick.major && !show_minor) continue; + if (!std::isfinite(tick.value) || !inside(tick.value, x_range)) continue; + lines_.push_back(GridLine{tick.value, y_range.min, tick.value, y_range.max, tick.major}); + } + } + if (horizontal) { + for (const Tick& tick : y_ticks) { + if (!tick.major && !show_minor) continue; + if (!std::isfinite(tick.value) || !inside(tick.value, y_range)) continue; + lines_.push_back(GridLine{x_range.min, tick.value, x_range.max, tick.value, tick.major}); + } + } +} + +} // namespace plot diff --git a/src/plot/measure.cpp b/src/plot/measure.cpp new file mode 100644 index 0000000..98c4b3a --- /dev/null +++ b/src/plot/measure.cpp @@ -0,0 +1,43 @@ +#include + +#include + +namespace plot { + +Measurement Measure::evaluate(const data::Series& s, u64 begin, u64 end) { + Measurement m; + // Both guards run before any query, so an empty, inverted or out-of-range + // interval returns an invalid result without touching the series. + if (end <= begin || end > s.size()) return m; + + // Exact extremes and sums come from Series -- never from the pyramid's + // *_buckets forms, which answer in whole buckets only and would drop up to two + // partial edge buckets, silently returning wrong extrema and sums for any + // interval that is not bucket-aligned. Each query here is O(kBucket) worst + // case, so a measurement never scales with series length. + const Range extremes = s.reduce(begin, end); + const f64 total = s.sum(begin, end); + const f64 squared = s.sumsq(begin, end); + const f64 n = static_cast(end - begin); + const f64 step = s.dx(); + + m.ymin = extremes.min; + m.ymax = extremes.max; + m.mean = total / n; + m.rms = std::sqrt(squared / n); + m.integral = total * step; // left-Riemann sum: see the note in measure.hpp + + if (end - begin >= 2) { + // First and last sample of the interval: a single-sample query reduces to + // that sample's value, and costs the same O(kBucket) as any other. + const f64 first = s.reduce(begin, begin + 1).min; + const f64 last = s.reduce(end - 1, end).min; + m.dy = last - first; + m.dx = static_cast(end - 1 - begin) * step; + m.slope = m.dx != 0.0 ? m.dy / m.dx : 0.0; + } + m.valid = true; + return m; +} + +} // namespace plot diff --git a/src/plot/plot.cpp b/src/plot/plot.cpp new file mode 100644 index 0000000..9a0fc55 --- /dev/null +++ b/src/plot/plot.cpp @@ -0,0 +1,371 @@ +#include + +#include +#include + +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; + +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; +} + +/// Adds the margin an auto-fitted range needs. 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. +Range fit_range(Range r, bool log) { + if (!std::isfinite(r.min) || !std::isfinite(r.max)) return Range{0.0, 1.0}; + f64 lo = std::min(r.min, r.max); + f64 hi = std::max(r.min, r.max); + + if (log && lo > 0.0) { + if (hi > lo) { + const f64 factor = std::pow(hi / lo, 0.02); // 2% of the span, in log space + return Range{lo / factor, hi * factor}; + } + return Range{lo / 10.0, hi * 10.0}; // one decade either side + } + + if (hi - lo > 0.0) { + const f64 pad = (hi - lo) * 0.02; + return Range{lo - pad, hi + pad}; + } + const f64 pad = std::fabs(lo) * 1e-3 + 0.5; + return Range{lo - pad, hi + pad}; +} + +} // namespace + +u32 Plot::add_x_axis(const AxisDesc& desc) { + x_axes_.push_back(axis_from(desc)); + return static_cast(x_axes_.size() - 1); +} + +u32 Plot::add_y_axis(const AxisDesc& desc) { + y_axes_.push_back(axis_from(desc)); + return static_cast(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(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(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(measures_.size() - 1); +} + +u32 Plot::add_annotation(const Annotation& annotation) { + annotations_.push_back(annotation); + return static_cast(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(r.x), + static_cast(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(r.bottom()), + static_cast(r.y), false, a.log); +} + +bool Plot::fit_x_axis(u32 axis) { + Range union_range{}; + bool any = false; + for (const Trace& t : traces_) { + if (t.axis_x != axis || !t.visible()) continue; // null or empty series: skip + const Range bounds = t.series->x_bounds(); + if (!std::isfinite(bounds.min) || !std::isfinite(bounds.max)) continue; + if (!any) { + union_range = bounds; + any = true; + } else { + union_range.min = std::min(union_range.min, bounds.min); + union_range.max = std::max(union_range.max, bounds.max); + } + } + if (!any) return false; // nothing bound to this axis: leave the caller's range + + Axis& a = x_axes_[axis]; + Range fitted = fit_range(union_range, a.log); + if (a.log && fitted.min <= 0.0) fitted.min = fitted.max > 0.0 ? fitted.max * 1e-3 : 1.0; + a.range = fitted; + return true; +} + +bool Plot::fit_y_axis(u32 axis) { + Range union_range{}; + bool any = false; + for (const Trace& t : traces_) { + if (t.axis_y != axis || !t.visible()) continue; + const Range bounds = t.series->y_bounds(); + if (!std::isfinite(bounds.min) || !std::isfinite(bounds.max)) continue; + if (!any) { + union_range = bounds; + any = true; + } else { + union_range.min = std::min(union_range.min, bounds.min); + union_range.max = std::max(union_range.max, bounds.max); + } + } + if (!any) return false; + + Axis& a = y_axes_[axis]; + Range fitted = fit_range(union_range, a.log); + if (a.log && fitted.min <= 0.0) fitted.min = fitted.max > 0.0 ? fitted.max * 1e-3 : 1.0; + a.range = fitted; + return true; +} + +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; + + // Recompute only when someone moved a cursor, replaced the trace's series, or + // grew it. 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) { + 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.dirty = false; + } + return slot.result; +} + +void Plot::invalidate_measurements() noexcept { mark_measures_dirty(); } + +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; + } + + for (u32 pass = 0;; ++pass) { + // Clamped to the viewport so a huge label set can never push the axes rect + // outside it (or make its width negative). + y_width = std::clamp(y_width, 0.0f, viewport_.w); + x_height = std::clamp(x_height, 0.0f, viewport_.h); + layout_.y_axis_width = y_width; + layout_.x_axis_height = x_height; + layout_.axes = Rect{viewport_.x + y_width, viewport_.y, viewport_.w - y_width, + viewport_.h - x_height}; + layout_.y_label_area = Rect{viewport_.x, layout_.axes.y, y_width, layout_.axes.h}; + layout_.x_label_area = Rect{layout_.axes.x, layout_.axes.bottom(), layout_.axes.w, x_height}; + + refresh_ticks(); + + if (text_ == nullptr || pass + 1 >= kMaxLayoutPasses) break; + const f32 need_y = std::clamp(y_label_width(), 0.0f, viewport_.w); + const f32 need_x = std::clamp(text_->height() + 2.0f * pad, 0.0f, viewport_.h); + 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(i) * s.dx(); + } + return c.x; +} + +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 diff --git a/src/plot/ticks.cpp b/src/plot/ticks.cpp new file mode 100644 index 0000000..25fcca4 --- /dev/null +++ b/src/plot/ticks.cpp @@ -0,0 +1,181 @@ +#include + +#include +#include +#include + +namespace plot { +namespace { + +/// Longest label a tick keeps before falling back to `%.3g`. +constexpr usize kMaxLabelChars = 12; +/// Scratch for one label; longest possible output is a `%.3g` fallback plus a +/// prefix plus a unit, far under this. +constexpr usize kLabelBuf = 64; + +/// SI prefixes indexed by engineering exponent / 3, from 10^-24 to 10^24. The +/// micro sign is UTF-8 U+00B5 (0xC2 0xB5), not the Greek mu U+03BC: the bitmap +/// font has a glyph for U+00B5 and would draw a missing-glyph box for U+03BC. +struct SiPrefix { + int index; + const char* text; +}; +constexpr SiPrefix kPrefixes[] = { + {-8, "y"}, {-7, "z"}, {-6, "a"}, {-5, "f"}, {-4, "p"}, {-3, "n"}, {-2, "\xC2\xB5"}, + {-1, "m"}, {0, ""}, {1, "k"}, {2, "M"}, {3, "G"}, {4, "T"}, {5, "P"}, + {6, "E"}, {7, "Z"}, {8, "Y"}, +}; + +const char* prefix_for(int index) noexcept { + for (const SiPrefix& p : kPrefixes) { + if (p.index == index) return p.text; + } + return ""; +} + +/// `%.3g` with a NUL-terminated, never-overflowing write. Used for every value +/// the SI path refuses. +void fallback_number(f64 v, const char* unit, char* buf, usize n) noexcept { + std::snprintf(buf, n, "%.3g%s", v, unit); +} + +} // namespace + +void format_si(f64 v, char* buf, usize n, const char* unit) { + if (buf == nullptr || n == 0) return; + buf[0] = '\0'; + const char* const u = unit != nullptr ? unit : ""; + + if (!std::isfinite(v)) { + fallback_number(v, u, buf, n); + return; + } + if (v == 0.0) { + std::snprintf(buf, n, "0%s", u); + return; + } + + // Engineering exponent: |v| == scaled * 10^(3*e3) with scaled in [1, 1000). + const f64 magnitude = std::fabs(v); + const int e3 = static_cast(std::floor(std::log10(magnitude) / 3.0)); + if (e3 < kPrefixes[0].index || e3 > kPrefixes[sizeof(kPrefixes) / sizeof(kPrefixes[0]) - 1].index) { + fallback_number(v, u, buf, n); + return; + } + + f64 scaled = v / std::pow(10.0, 3.0 * static_cast(e3)); + int index = e3; + // Rounding to three significant digits must not spill the scale: 999.9 goes + // to 1.00 of the next prefix rather than to `1e+03` of this one. + if (std::fabs(scaled) >= 999.5) { + index = e3 + 1; + if (index > kPrefixes[sizeof(kPrefixes) / sizeof(kPrefixes[0]) - 1].index) { + fallback_number(v, u, buf, n); + return; + } + scaled = v / std::pow(10.0, 3.0 * static_cast(index)); + } + + std::snprintf(buf, n, "%.3g%s%s", scaled, prefix_for(index), u); + if (std::strlen(buf) > kMaxLabelChars) { + // A 12-character budget keeps axis labels readable; anything longer is a + // scientific-notation value the caller is better served by verbatim. + fallback_number(v, u, buf, n); + } +} + +void format_time(f64 seconds, char* buf, usize n) { + if (buf == nullptr || n == 0) return; + buf[0] = '\0'; + if (!std::isfinite(seconds)) { + std::snprintf(buf, n, "%.3g", seconds); + return; + } + + const char* const sign = seconds < 0.0 ? "-" : ""; + const f64 t = std::fabs(seconds); + if (t >= 86400.0) { + std::snprintf(buf, n, "%s%.3gd", sign, t / 86400.0); + } else if (t >= 3600.0) { + const auto hours = static_cast(t / 3600.0); + const auto mins = static_cast((t - 3600.0 * static_cast(hours)) / 60.0); + const auto secs = + static_cast(std::llround(t - 3600.0 * static_cast(hours) - + 60.0 * static_cast(mins))); + std::snprintf(buf, n, "%s%llu:%02llu:%02llu", sign, hours, mins, secs); + } else if (t >= 60.0) { + const auto mins = static_cast(t / 60.0); + const auto secs = static_cast(std::llround(t - 60.0 * static_cast(mins))); + std::snprintf(buf, n, "%s%llu:%02llu", sign, mins, secs); + } else if (t >= 1.0) { + std::snprintf(buf, n, "%s%.3gs", sign, t); + } else { + std::snprintf(buf, n, "%s%.3gms", sign, t * 1000.0); + } +} + +void generate_ticks(Range range, f32 px, f32 min_px, std::vector& out, bool include_minors) { + out.clear(); + + const f64 lo = range.min; + const f64 hi = range.max; + const f64 span = hi - lo; + // Non-finite endpoints, an inverted range and a zero span (a collapsed range, + // e.g. the x bounds of a single-sample series) all have nothing to tick. + if (!std::isfinite(lo) || !std::isfinite(hi) || !(span > 0.0)) return; + if (!(px > 0.0f) || !(min_px > 0.0f)) return; + + const f64 target = std::max(1.0, static_cast(px) / static_cast(min_px)); + const f64 raw_step = span / target; + if (!std::isfinite(raw_step) || !(raw_step > 0.0)) return; + + const f64 magnitude = std::pow(10.0, std::floor(std::log10(raw_step))); + if (!std::isfinite(magnitude) || !(magnitude > 0.0)) return; + const f64 normalised = raw_step / magnitude; // in [1, 10) + // Round the step UP to the next 1/2/5 x 10^n, so the major spacing really does + // meet min_px instead of landing just under it. + const f64 step = magnitude * (normalised <= 1.0 ? 1.0 + : normalised <= 2.0 ? 2.0 + : normalised <= 5.0 ? 5.0 + : 10.0); + if (!std::isfinite(step) || !(step > 0.0)) return; // span so large the step overflowed + + const f64 first_major = std::ceil(lo / step) * step; + if (!std::isfinite(first_major)) return; + + // Minor ticks subdivide the major step: by 4 when the step's leading digit is + // 2, by 5 for 1 and 5 (and for a step of 10, i.e. a leading 1 one decade up). + const f64 lead = step / magnitude; + const f64 minor_ratio = (lead > 1.5 && lead < 3.0) ? 4.0 : 5.0; + const f64 minor_step = step / minor_ratio; + if (!std::isfinite(minor_step) || !(minor_step > 0.0)) return; + + const f64 stride = include_minors ? minor_step : step; + const f64 first = include_minors ? std::ceil(lo / minor_step) * minor_step : first_major; + if (!std::isfinite(first)) return; + + char label[kLabelBuf]; + for (f64 v = first; v <= hi + stride * 1e-9;) { + if (out.size() >= kMaxTicks) break; + const bool major = !include_minors || + std::fabs(v - std::round(v / step) * step) <= step * 1e-9; + Tick tick; + tick.value = v; + tick.major = major; + if (major) { + // format_si owns both label guards: it never writes past the buffer, and it + // falls back to %.3g for a non-finite value or a label over 12 characters. + format_si(v, label, sizeof label); + tick.label = label; + } + out.push_back(std::move(tick)); + + const f64 next = v + stride; + // No progress would mean an infinite loop on a denormal stride far from the + // origin; stop instead of spinning. + if (!(next > v)) break; + v = next; + } +} + +} // namespace plot diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 44f743b..02dcaeb 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -32,6 +32,9 @@ add_executable(plot_tests test_ring_buffer.cpp test_minmax_pyramid.cpp test_series.cpp + test_ticks.cpp + test_measure.cpp + test_plot.cpp egl_headless.cpp ) target_link_libraries(plot_tests PRIVATE plot::core ${plot_egl_library} Threads::Threads) diff --git a/tests/test_measure.cpp b/tests/test_measure.cpp new file mode 100644 index 0000000..b645758 --- /dev/null +++ b/tests/test_measure.cpp @@ -0,0 +1,43 @@ +#include "harness.hpp" +#include +#include +#include +#include +#include +#include + +PLOT_TEST(measurement_over_interval_matches_direct_computation) { + std::vector y; for (int i = 0; i < 1000; ++i) y.push_back(f32(std::sin(static_cast(i) * 0.01))); + plot::data::ArraySeries s(std::move(y), 0.0, 1.0); + const auto m = plot::Measure::evaluate(s, 100, 800); + double sum = 0, sumsq = 0, integral = 0; float lo = 1e30f, hi = -1e30f; + for (u64 i = 100; i < 800; ++i) { + const double v = std::sin(static_cast(i) * 0.01); + sum += v; sumsq += v * v; integral += v; // dx == 1 + lo = std::min(lo, float(v)); hi = std::max(hi, float(v)); + } + const double n = 700; + PLOT_CHECK_NEAR(m.mean, sum / n, 1e-5); + PLOT_CHECK_NEAR(m.rms, std::sqrt(sumsq / n), 1e-5); + PLOT_CHECK_NEAR(m.integral, integral, 1e-3); + PLOT_CHECK_NEAR(m.ymin, lo, 1e-6); + PLOT_CHECK_NEAR(m.ymax, hi, 1e-6); + PLOT_CHECK_NEAR(m.dx, 699.0, 1e-9); +} +PLOT_TEST(measurement_uses_pyramid_not_scan) { + // Guard against a future regression that linear-scans the series: a huge + // series must evaluate a 1-sample-wide interval in well under a millisecond. + std::vector y(1u << 22, 1.0f); // 4M samples + plot::data::ArraySeries s(std::move(y), 0.0, 1.0); + const auto t0 = std::chrono::steady_clock::now(); + for (int i = 0; i < 1000; ++i) (void)plot::Measure::evaluate(s, 1'000'000, 1'000'001); + const auto us = std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0).count(); + PLOT_CHECK(us < 50'000); // 1000 evals in < 50 ms +} +PLOT_TEST(empty_and_single_sample_intervals_are_valid_false) { + std::vector y{3.0f}; + plot::data::ArraySeries s(std::move(y), 0.0, 1.0); + PLOT_CHECK(!plot::Measure::evaluate(s, 0, 0).valid); + PLOT_CHECK(!plot::Measure::evaluate(s, 1, 1).valid); +} diff --git a/tests/test_plot.cpp b/tests/test_plot.cpp new file mode 100644 index 0000000..f342f12 --- /dev/null +++ b/tests/test_plot.cpp @@ -0,0 +1,291 @@ +#include "harness.hpp" +#include +#include +#include +#include +#include +#include + +namespace { + +std::vector ramp(u64 n) { + std::vector y(n); + for (u64 i = 0; i < n; ++i) y[i] = f32(i); + return y; +} + +f32 widest_major_label(const plot::Axis& a, const plot::render::TextBackend& t) { + f32 w = 0.0f; + for (const plot::Tick& tick : a.ticks) { + if (!tick.major) continue; + const f32 m = t.measure(tick.label.c_str()); + if (m > w) w = m; + } + return w; +} + +bool has_vertical(const plot::Grid& g, f64 x, bool major) { + for (const plot::GridLine& l : g.lines()) { + if (l.x0 == l.x1 && l.x0 == x && l.major == major) return true; + } + return false; +} + +bool has_horizontal(const plot::Grid& g, f64 y, bool major) { + for (const plot::GridLine& l : g.lines()) { + if (l.y0 == l.y1 && l.y0 == y && l.major == major) return true; + } + return false; +} + +} // namespace + +PLOT_TEST(layout_without_text_backend_uses_default_label_areas) { + plot::Plot p; + p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); + p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); + const plot::PlotLayout& l = p.layout(); + PLOT_CHECK_NEAR(l.y_axis_width, 56.0f, 1e-6); + PLOT_CHECK_NEAR(l.x_axis_height, 22.0f, 1e-6); + PLOT_CHECK_NEAR(l.axes.x, 56.0f, 1e-6); + PLOT_CHECK_NEAR(l.axes.w, 744.0f, 1e-6); + PLOT_CHECK_NEAR(l.axes.h, 378.0f, 1e-6); + PLOT_CHECK_NEAR(l.y_label_area.x, 0.0f, 1e-6); + PLOT_CHECK_NEAR(l.y_label_area.w, 56.0f, 1e-6); + PLOT_CHECK_NEAR(l.y_label_area.h, 378.0f, 1e-6); + PLOT_CHECK_NEAR(l.x_label_area.y, 378.0f, 1e-6); + PLOT_CHECK_NEAR(l.x_label_area.w, 744.0f, 1e-6); + PLOT_CHECK_NEAR(l.x_label_area.h, 22.0f, 1e-6); +} + +PLOT_TEST(layout_derives_label_areas_from_font_metrics) { + plot::render::BitmapFont font; + plot::Plot p; + p.set_text_backend(&font); + p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); + const u32 ay = p.add_y_axis({"v", {-1000.0, 1000.0}, false, plot::AxisPlace::Left, false}); + const plot::PlotLayout& l = p.layout(); + const f32 pad = p.style().grid_label_pad; + PLOT_CHECK_NEAR(l.y_axis_width, widest_major_label(p.y_axis(ay), font) + 2.0f * pad, 1e-4); + PLOT_CHECK_NEAR(l.x_axis_height, font.height() + 2.0f * pad, 1e-4); + PLOT_CHECK_NEAR(l.axes.x, l.y_axis_width, 1e-4); + PLOT_CHECK_NEAR(l.axes.w, 800.0f - l.y_axis_width, 1e-4); + PLOT_CHECK_NEAR(l.axes.h, 400.0f - l.x_axis_height, 1e-4); +} + +PLOT_TEST(time_axis_tick_labels_use_format_time) { + plot::Plot p; + p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); + const u32 ax = p.add_x_axis( + {"t", {0.0, 120.0}, false, plot::AxisPlace::Bottom, false, plot::TickFormat::Time}); + (void)p.layout(); + const std::vector& ticks = p.x_axis(ax).ticks; + PLOT_CHECK(!ticks.empty()); + bool saw_minute_label = false; + for (const plot::Tick& tick : ticks) { + if (!tick.major) continue; + PLOT_CHECK(!tick.label.empty()); + if (tick.value == 60.0) { + PLOT_CHECK(tick.label == "1:00"); // format_time, not format_si + saw_minute_label = true; + } + } + PLOT_CHECK(saw_minute_label); +} + +PLOT_TEST(x_transform_maps_range_onto_axes_rect) { + plot::Plot p; + p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); + const u32 ax = p.add_x_axis({"t", {100.0, 200.0}, false, plot::AxisPlace::Bottom, false}); + const u32 ay = p.add_y_axis({"v", {-2.0, 2.0}, false, plot::AxisPlace::Left, false}); + const plot::Rect a = p.layout().axes; + const plot::Transform tx = p.x_transform(ax); + const plot::Transform ty = p.y_transform(ay); + PLOT_CHECK_NEAR(tx.to_screen_x(100.0), a.x, 1e-9); + PLOT_CHECK_NEAR(tx.to_screen_x(200.0), a.right(), 1e-9); + PLOT_CHECK_NEAR(ty.to_screen_y(-2.0), a.bottom(), 1e-9); // range min sits at the bottom + PLOT_CHECK_NEAR(ty.to_screen_y(2.0), a.y, 1e-9); // range max sits at the top + PLOT_CHECK_NEAR(tx.to_data_x(tx.to_screen_x(137.0)), 137.0, 1e-9); + PLOT_CHECK_NEAR(ty.to_data_y(ty.to_screen_y(0.5)), 0.5, 1e-9); + // An axis index that does not exist must not crash and must stay invertible. + PLOT_CHECK_NEAR(p.x_transform(9).to_data_x(p.x_transform(9).to_screen_x(0.25)), 0.25, 1e-9); + PLOT_CHECK_NEAR(p.y_transform(9).to_data_y(p.y_transform(9).to_screen_y(0.25)), 0.25, 1e-9); +} + +PLOT_TEST(auto_fit_uses_trace_bounds_with_margin) { + std::vector y = {0.0f, 1.0f, 2.0f, 3.0f}; + plot::data::ArraySeries s(std::move(y), 0.0, 1.0); + plot::Plot p; + p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); + const u32 ax = p.add_x_axis({"t", {0.0, 1000.0}, true, plot::AxisPlace::Bottom, false}); + const u32 ay = p.add_y_axis({"v", {0.0, 1000.0}, true, plot::AxisPlace::Left, false}); + p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay}); + p.auto_fit_x(); + p.auto_fit_y(ay); + const plot::Range xr = p.x_range(ax); + const plot::Range yr = p.y_range(ay); + PLOT_CHECK_NEAR(xr.min, 0.0 - 3.0 * 0.02, 1e-12); // 4 samples span x 0..3, 2% margin + PLOT_CHECK_NEAR(xr.max, 3.0 + 3.0 * 0.02, 1e-12); + PLOT_CHECK_NEAR(yr.min, 0.0 - 3.0 * 0.02, 1e-12); + PLOT_CHECK_NEAR(yr.max, 3.0 + 3.0 * 0.02, 1e-12); + + // An explicit range pins the axis; a later fit re-enables auto scaling. + p.set_y_range(ay, {0.0, 1.0}); + PLOT_CHECK(!p.y_axis(ay).auto_scale); + p.auto_fit_y(ay); + PLOT_CHECK(p.y_axis(ay).auto_scale); + PLOT_CHECK_NEAR(p.y_range(ay).max, 3.0 + 3.0 * 0.02, 1e-12); + // auto_scale = false survives layout(): the pinned range is not snapped back. + p.set_y_range(ay, {0.0, 1.0}); + (void)p.layout(); + PLOT_CHECK_NEAR(p.y_range(ay).max, 1.0, 1e-12); +} + +PLOT_TEST(layout_refits_axes_left_on_auto_scale) { + std::vector y = {0.0f, 1.0f, 2.0f, 3.0f}; + plot::data::ArraySeries s(std::move(y), 0.0, 1.0); + plot::Plot p; + p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); + const u32 ax = p.add_x_axis({"t", {0.0, 1000.0}, true, plot::AxisPlace::Bottom, false}); + const u32 ay = p.add_y_axis({"v", {0.0, 1000.0}, true, plot::AxisPlace::Left, false}); + p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay}); + (void)p.layout(); // no explicit fit needed + PLOT_CHECK_NEAR(p.x_range(ax).max, 3.0 + 3.0 * 0.02, 1e-12); + PLOT_CHECK_NEAR(p.y_range(ay).max, 3.0 + 3.0 * 0.02, 1e-12); + // An explicit range wins from then on: layout() must not snap it back. + p.set_x_range(ax, {0.0, 1.0}); + p.set_y_range(ay, {0.0, 2.0}); + (void)p.layout(); + PLOT_CHECK_NEAR(p.x_range(ax).max, 1.0, 1e-12); + PLOT_CHECK_NEAR(p.y_range(ay).max, 2.0, 1e-12); +} + +PLOT_TEST(auto_fit_handles_degenerate_and_null_series) { + std::vector one{5.0f}; + plot::data::ArraySeries s(std::move(one), 0.0, 1.0); + plot::Plot p; + p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); + const u32 ax = p.add_x_axis({"t", {0.0, 1.0}, true, plot::AxisPlace::Bottom, false}); + const u32 ay = p.add_y_axis({"v", {0.0, 1.0}, true, plot::AxisPlace::Left, false}); + p.add_trace({nullptr, plot::Color::hex(0xFFFFFF), 1.0f, ax, ay}); // skipped, not a crash + PLOT_CHECK(p.add_trace({&s, plot::Color::hex(0xFFFFFF), 1.0f, ax, ay}) == 1u); + PLOT_CHECK(p.trace_count() == 2u); + + p.auto_fit_x(); + p.auto_fit_y(ay); + const plot::Range xr = p.x_range(ax); + const plot::Range yr = p.y_range(ay); + PLOT_CHECK(xr.span() > 0.0); // degenerate {0,0} widened + PLOT_CHECK(xr.min < 0.0 && xr.max > 0.0); + PLOT_CHECK(yr.span() > 0.0); // degenerate {5,5} widened + PLOT_CHECK(yr.min < 5.0 && yr.max > 5.0); + (void)p.layout(); // must survive the null trace +} + +PLOT_TEST(measurement_matches_direct_computation_and_tracks_cursor_moves) { + plot::data::ArraySeries s(ramp(100), 0.0, 1.0); + plot::Plot p; + p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); + const u32 ax = p.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false}); + const u32 ay = p.add_y_axis({"v", {0.0, 99.0}, false, plot::AxisPlace::Left, false}); + p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay}); + const u32 a = p.add_cursor(10.0, plot::Color::hex(0xFFB020)); + const u32 b = p.add_cursor(19.0, plot::Color::hex(0xFFB020)); + const u32 m = p.add_measure(a, b); + const plot::Measurement& r = p.measurement(m); + PLOT_CHECK(r.valid); + double sum = 0.0, sq = 0.0; + for (u64 i = 10; i <= 19; ++i) { + sum += double(i); + sq += double(i) * double(i); + } + PLOT_CHECK_NEAR(r.mean, sum / 10.0, 1e-9); + PLOT_CHECK_NEAR(r.rms, std::sqrt(sq / 10.0), 1e-9); + PLOT_CHECK_NEAR(r.integral, sum, 1e-9); // dx == 1 + PLOT_CHECK_NEAR(r.ymin, 10.0, 1e-9); + PLOT_CHECK_NEAR(r.ymax, 19.0, 1e-9); + PLOT_CHECK_NEAR(r.dx, 9.0, 1e-9); + PLOT_CHECK_NEAR(r.dy, 9.0, 1e-9); + PLOT_CHECK_NEAR(r.slope, 1.0, 1e-9); + + // Moving a cursor the measure depends on must invalidate the cached result. + p.set_cursor_x(b, 14.0); + const plot::Measurement& r2 = p.measurement(m); + PLOT_CHECK(r2.valid); + PLOT_CHECK_NEAR(r2.ymax, 14.0, 1e-9); + PLOT_CHECK_NEAR(r2.dx, 4.0, 1e-9); +} + +PLOT_TEST(measurement_recomputes_when_the_series_grows) { + plot::data::StreamSeries s(1024, 0.0, 1.0); + for (u64 i = 0; i < 10; ++i) s.push(f32(i)); + s.acquire(); + plot::Plot p; + p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); + const u32 ax = p.add_x_axis({"t", {0.0, 39.0}, false, plot::AxisPlace::Bottom, false}); + const u32 ay = p.add_y_axis({"v", {0.0, 39.0}, false, plot::AxisPlace::Left, false}); + p.add_trace({&s, plot::Color::hex(0xFFFFFF), 1.0f, ax, ay}); + const u32 a = p.add_cursor(2.0, plot::Color::hex(0xFFB020)); + const u32 b = p.add_cursor(20.0, plot::Color::hex(0xFFB020)); + const u32 m = p.add_measure(a, b); + PLOT_CHECK(p.measurement(m).valid); + // Only 10 samples exist: x = 20 snaps to the last one, index 9. + PLOT_CHECK_NEAR(p.measurement(m).ymax, 9.0, 1e-9); + PLOT_CHECK_NEAR(p.measurement(m).dx, 7.0, 1e-9); + + for (u64 i = 10; i < 40; ++i) s.push(f32(i)); + s.acquire(); + // Same cursors, longer series: the cached result must be recomputed. + PLOT_CHECK(p.measurement(m).valid); + PLOT_CHECK_NEAR(p.measurement(m).ymax, 20.0, 1e-9); + PLOT_CHECK_NEAR(p.measurement(m).dx, 18.0, 1e-9); +} + +PLOT_TEST(inverted_and_out_of_range_measurements_are_invalid) { + plot::data::ArraySeries s(ramp(10), 0.0, 1.0); + plot::Plot p; + p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); + const u32 ax = p.add_x_axis({"t", {0.0, 9.0}, false, plot::AxisPlace::Bottom, false}); + const u32 ay = p.add_y_axis({"v", {0.0, 9.0}, false, plot::AxisPlace::Left, false}); + const u32 a = p.add_cursor(8.0, plot::Color::hex(0xFFB020)); + const u32 b = p.add_cursor(2.0, plot::Color::hex(0xFFB020)); + const u32 m_inverted = p.add_measure(a, b); + PLOT_CHECK(!p.measurement(m_inverted).valid); + p.add_trace({nullptr, plot::Color::hex(0xFFFFFF), 1.0f, ax, ay}); // trace 0 has no series + const u32 m_null = p.add_measure(b, a, 0); + PLOT_CHECK(!p.measurement(m_null).valid); + const u32 m_bad_cursors = p.add_measure(50u, 51u); + PLOT_CHECK(!p.measurement(m_bad_cursors).valid); + PLOT_CHECK(!p.measurement(99u).valid); // unknown measure id +} + +PLOT_TEST(grid_lines_are_tick_aligned_data_space_segments) { + plot::Plot p; + p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); + const u32 ax = p.add_x_axis({"t", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false}); + const u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); + (void)p.layout(); + const plot::Grid& g = p.grid(); + const std::vector& xt = p.x_axis(ax).ticks; + const std::vector& yt = p.y_axis(ay).ticks; + PLOT_CHECK(!xt.empty()); + PLOT_CHECK(!yt.empty()); + PLOT_CHECK(g.size() == xt.size() + yt.size()); + for (const plot::Tick& t : xt) PLOT_CHECK(has_vertical(g, t.value, t.major)); + for (const plot::Tick& t : yt) PLOT_CHECK(has_horizontal(g, t.value, t.major)); + // Vertical grid lines span the y range, horizontal ones the x range: data + // space only, so the renderer's uniform transform can place them. + for (const plot::GridLine& l : g.lines()) { + if (l.x0 == l.x1) { + PLOT_CHECK_NEAR(l.y0, -1.0, 1e-12); + PLOT_CHECK_NEAR(l.y1, 1.0, 1e-12); + } else { + PLOT_CHECK_NEAR(l.y0, l.y1, 1e-12); + PLOT_CHECK_NEAR(l.x0, 0.0, 1e-12); + PLOT_CHECK_NEAR(l.x1, 10.0, 1e-12); + } + } + u32 minor = 0; + for (const plot::GridLine& l : g.lines()) minor += l.major ? 0u : 1u; + PLOT_CHECK(minor > 0); // minor grid is generated too +} diff --git a/tests/test_ticks.cpp b/tests/test_ticks.cpp new file mode 100644 index 0000000..895a940 --- /dev/null +++ b/tests/test_ticks.cpp @@ -0,0 +1,109 @@ +#include "harness.hpp" +#include +#include +#include +#include +#include +#include + +PLOT_TEST(nice_ticks_are_powers_of_ten_times_125) { + std::vector t; + plot::generate_ticks({0.0, 1.0}, /*px=*/500.0f, /*min_px=*/60.0f, t); + PLOT_CHECK(!t.empty()); + for (auto& tick : t) PLOT_CHECK(std::fabs(tick.value * 5.0 - std::round(tick.value * 5.0)) < 1e-9); + // spacing of major ticks >= min_px + PLOT_CHECK(static_cast(t.size()) <= 500.0 / 60.0 + 2.0); +} +PLOT_TEST(ticks_are_monotonic_and_cover_range) { + std::vector t; + plot::generate_ticks({-3.7, 91.2}, 800.0f, 60.0f, t); + for (size_t i = 1; i < t.size(); ++i) PLOT_CHECK(t[i].value > t[i-1].value); + PLOT_CHECK(t.front().value <= 0.0); + PLOT_CHECK(t.back().value >= 90.0); +} +PLOT_TEST(si_formatting) { + char b[32]; + plot::format_si(0.0000015, b, sizeof b); PLOT_CHECK(std::string(b) == "1.5\xC2\xB5s" || std::string(b).find("1.5") == 0); + plot::format_si(2.5e9, b, sizeof b); PLOT_CHECK(std::string(b).find("2.5") == 0); + plot::format_si(1234.0, b, sizeof b); PLOT_CHECK(std::string(b).find("1.23") == 0); + plot::format_si(0.0, b, sizeof b); PLOT_CHECK(std::string(b) == "0"); +} +PLOT_TEST(degenerate_ranges_do_not_hang) { + std::vector t; + plot::generate_ticks({5.0, 5.0}, 500.0f, 60.0f, t); // must not loop forever + PLOT_CHECK(t.size() <= 64); + plot::generate_ticks({0.0, 0.0}, 0.0f, 60.0f, t); + plot::generate_ticks({1e300, 1e300}, 500.0f, 60.0f, t); +} + +PLOT_TEST(hostile_tick_inputs_clear_the_output) { + std::vector t{{1.0, true, "stale"}}; + plot::generate_ticks({10.0, -10.0}, 500.0f, 60.0f, t); // inverted + PLOT_CHECK(t.empty()); + plot::generate_ticks({0.0, std::nan("")}, 500.0f, 60.0f, t); // non-finite max + PLOT_CHECK(t.empty()); + plot::generate_ticks({std::nan(""), 1.0}, 500.0f, 60.0f, t); + PLOT_CHECK(t.empty()); + plot::generate_ticks({0.0, 1.0}, 500.0f, 0.0f, t); // min_px <= 0 + PLOT_CHECK(t.empty()); + // Span overflows to infinity: the step would be inf, so nothing is emitted + // and, crucially, the loop never runs. + plot::generate_ticks({-1e308, 1e308}, 500.0f, 60.0f, t); + PLOT_CHECK(t.empty()); +} + +PLOT_TEST(minor_ticks_subdivide_and_the_total_is_capped) { + std::vector t; + plot::generate_ticks({0.0, 1.0}, 500.0f, 60.0f, t, /*include_minors=*/true); + PLOT_CHECK(t.size() > 10); // minors really added + for (size_t i = 1; i < t.size(); ++i) PLOT_CHECK(t[i].value > t[i-1].value); + // The major step is 0.2 (leading digit 2), so minors are quartered at 0.05. + for (const plot::Tick& tick : t) { + const double scale = tick.major ? 5.0 : 20.0; + PLOT_CHECK(std::fabs(tick.value * scale - std::round(tick.value * scale)) < 1e-9); + PLOT_CHECK(tick.major ? !tick.label.empty() : tick.label.empty()); + } + // A range dense enough to want millions of minors stops at the hard cap. + std::vector capped; + plot::generate_ticks({0.0, 1e9}, 1e6f, 1.0f, capped, /*include_minors=*/true); + PLOT_CHECK(capped.size() == plot::kMaxTicks); +} + +PLOT_TEST(si_labels_are_utf8_and_never_overrun) { + char b[32]; + plot::format_si(0.0000015, b, sizeof b); + PLOT_CHECK(std::string(b) == "1.5\xC2\xB5"); // U+00B5, not the Greek mu + plot::format_si(1234.0, b, sizeof b); + PLOT_CHECK(std::string(b) == "1.23k"); + plot::format_si(-1234.0, b, sizeof b); + PLOT_CHECK(std::string(b) == "-1.23k"); + plot::format_si(2.5e9, b, sizeof b); + PLOT_CHECK(std::string(b) == "2.5G"); + plot::format_si(1.5e-6, b, sizeof b, "s"); + PLOT_CHECK(std::string(b) == "1.5\xC2\xB5s"); // a caller-supplied unit suffix + plot::format_si(std::nan(""), b, sizeof b); // never silently dropped + PLOT_CHECK(std::string(b) == "nan"); + plot::format_si(std::numeric_limits::infinity(), b, sizeof b); + PLOT_CHECK(std::string(b) == "inf"); + + // A short buffer truncates; the string is always terminated inside it and + // nothing is ever written at or past `n`. + for (plot::usize n = 1; n <= 8; ++n) { + char small[8]; + for (char& c : small) c = '#'; + plot::format_si(-1234.567, small, n); + PLOT_CHECK(std::strlen(small) < n); + PLOT_CHECK(small[std::strlen(small)] == '\0'); + } +} + +PLOT_TEST(format_time_shapes) { + char b[32]; + plot::format_time(0.5, b, sizeof b); PLOT_CHECK(std::string(b) == "500ms"); + plot::format_time(1.5, b, sizeof b); PLOT_CHECK(std::string(b) == "1.5s"); + plot::format_time(90.0, b, sizeof b); PLOT_CHECK(std::string(b) == "1:30"); + plot::format_time(3661.0, b, sizeof b); PLOT_CHECK(std::string(b) == "1:01:01"); + plot::format_time(-90.0, b, sizeof b); PLOT_CHECK(std::string(b) == "-1:30"); + plot::format_time(86400.0, b, sizeof b); PLOT_CHECK(std::string(b) == "1d"); + plot::format_time(std::nan(""), b, sizeof b); PLOT_CHECK(std::string(b) == "nan"); +}