566 lines
26 KiB
C++
566 lines
26 KiB
C++
#include "harness.hpp"
|
|
#include <plot/plot/plot.hpp>
|
|
#include <plot/plot/detail/measure_count.hpp>
|
|
#include <plot/data/array_series.hpp>
|
|
#include <plot/data/stream_series.hpp>
|
|
#include <plot/render/embedded_mono_font.hpp>
|
|
#include <cmath>
|
|
#include <vector>
|
|
|
|
namespace {
|
|
|
|
std::vector<f32> ramp(u64 n) {
|
|
std::vector<f32> 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;
|
|
}
|
|
|
|
/// A log axis must never be left inverted or non-positive by an auto-fit.
|
|
void check_log_axis_range(const plot::Range& r) {
|
|
PLOT_CHECK(std::isfinite(r.min) && std::isfinite(r.max));
|
|
PLOT_CHECK(r.min > 0.0);
|
|
PLOT_CHECK(r.min < r.max);
|
|
}
|
|
|
|
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);
|
|
// "v" is a title, but no backend can measure it, so nothing is reserved: the
|
|
// defaults above are what a plot without a text backend keeps laying out with.
|
|
PLOT_CHECK_NEAR(l.y_title_width, 0.0f, 1e-6);
|
|
PLOT_CHECK_NEAR(l.x_title_height, 0.0f, 1e-6);
|
|
PLOT_CHECK_NEAR(l.y_title_area.w, 0.0f, 1e-6);
|
|
PLOT_CHECK_NEAR(l.x_title_area.h, 0.0f, 1e-6);
|
|
}
|
|
|
|
PLOT_TEST(layout_derives_label_areas_from_font_metrics) {
|
|
plot::render::EmbeddedMonoFont 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({"volts", {-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);
|
|
// The y axis carries the title "volts", drawn rotated to read bottom-to-top, so
|
|
// the strip left of the label strip is one text line *wide* -- the column the
|
|
// run advances up -- and not as wide as the title is when horizontal. There is
|
|
// no x axis, so the x direction reserves nothing beyond its label strip.
|
|
PLOT_CHECK_NEAR(l.y_title_width, font.height() + 2.0f * pad, 1e-4);
|
|
PLOT_CHECK(l.y_title_width < font.measure("volts") + 2.0f * pad);
|
|
PLOT_CHECK_NEAR(l.x_title_height, 0.0f, 1e-6);
|
|
PLOT_CHECK_NEAR(l.axes.x, l.y_title_width + l.y_axis_width, 1e-4);
|
|
PLOT_CHECK_NEAR(l.axes.w, 800.0f - (l.y_title_width + l.y_axis_width), 1e-4);
|
|
PLOT_CHECK_NEAR(l.axes.h, 400.0f - (l.x_axis_height + l.x_title_height), 1e-4);
|
|
// The strips tile the viewport outside `axes`: title, then labels, then data.
|
|
PLOT_CHECK_NEAR(l.y_title_area.x, 0.0f, 1e-6);
|
|
PLOT_CHECK_NEAR(l.y_title_area.right(), l.y_label_area.x, 1e-4);
|
|
PLOT_CHECK_NEAR(l.y_label_area.right(), l.axes.x, 1e-4);
|
|
PLOT_CHECK_NEAR(l.x_title_area.y, l.x_label_area.bottom(), 1e-4);
|
|
PLOT_CHECK_NEAR(l.x_title_area.bottom(), l.axes.bottom() + l.x_label_area.h + l.x_title_height,
|
|
1e-4);
|
|
PLOT_CHECK_NEAR(l.x_label_area.y, l.axes.bottom(), 1e-4);
|
|
}
|
|
|
|
/// Every titled axis of a direction gets its own text line inside its strip; without
|
|
/// that, the second titled axis of an edge is drawn on top of the first. An x line is
|
|
/// a row of the strip, a y one a column of it, because the y title is drawn rotated.
|
|
PLOT_TEST(layout_stacks_one_title_line_per_titled_axis) {
|
|
plot::render::EmbeddedMonoFont font;
|
|
plot::Plot p;
|
|
p.set_text_backend(&font);
|
|
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
|
const f32 pad = p.style().grid_label_pad;
|
|
p.add_x_axis({"seconds", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
|
|
p.add_x_axis({"samples", {0.0, 1.0}, false, plot::AxisPlace::Top, false});
|
|
p.add_y_axis({"volts", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
|
p.add_y_axis({"amps", {-1.0, 1.0}, false, plot::AxisPlace::Right, false});
|
|
const plot::PlotLayout& l = p.layout();
|
|
PLOT_CHECK(p.titled_x_axis_count() == 2u);
|
|
PLOT_CHECK(p.titled_y_axis_count() == 2u);
|
|
// The x titles stack in rows, so the strip grows by a line per titled axis...
|
|
PLOT_CHECK_NEAR(l.x_title_height, 2.0f * font.height() + 2.0f * pad, 1e-4);
|
|
// ... and the y titles, rotated, take a *column* each: the second titled y axis
|
|
// costs another text line of width rather than a row of the strip's height, and
|
|
// the pair still cannot be drawn over each other. The widest title's own width
|
|
// plays no part in it.
|
|
PLOT_CHECK_NEAR(l.y_title_width, 2.0f * font.height() + 2.0f * pad, 1e-4);
|
|
PLOT_CHECK(l.y_title_width <
|
|
std::max(font.measure("volts"), font.measure("amps")) + 2.0f * pad);
|
|
PLOT_CHECK_NEAR(l.axes.h, 400.0f - (l.x_axis_height + l.x_title_height), 1e-4);
|
|
}
|
|
|
|
/// An axis with no title reserves nothing: the strips exist so that a titled axis
|
|
/// has somewhere outside `axes` to draw in, not as a fixed cost per plot.
|
|
PLOT_TEST(layout_reserves_no_title_strip_without_a_title) {
|
|
plot::render::EmbeddedMonoFont font;
|
|
plot::Plot p;
|
|
p.set_text_backend(&font);
|
|
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
|
p.add_x_axis({"", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
|
|
p.add_y_axis({"", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
|
const plot::PlotLayout& l = p.layout();
|
|
PLOT_CHECK_NEAR(l.y_title_width, 0.0f, 1e-6);
|
|
PLOT_CHECK_NEAR(l.x_title_height, 0.0f, 1e-6);
|
|
PLOT_CHECK_NEAR(l.y_title_area.w, 0.0f, 1e-6);
|
|
PLOT_CHECK_NEAR(l.x_title_area.h, 0.0f, 1e-6);
|
|
PLOT_CHECK_NEAR(l.y_label_area.x, 0.0f, 1e-6);
|
|
PLOT_CHECK_NEAR(l.axes.x, l.y_axis_width, 1e-4);
|
|
PLOT_CHECK_NEAR(l.axes.h, 400.0f - l.x_axis_height, 1e-4);
|
|
}
|
|
|
|
/// The y title is rotated, so its strip is a column: one text line wide where a
|
|
/// horizontal run needed the title's own width, and as tall as the axes, which is
|
|
/// where the run spends its length. The title here is wide enough to make the two
|
|
/// models plainly different -- it measures more than ten of the lines it now costs.
|
|
PLOT_TEST(y_title_strip_is_narrow_when_a_y_title_is_set) {
|
|
plot::render::EmbeddedMonoFont font;
|
|
plot::Plot p;
|
|
p.set_text_backend(&font);
|
|
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
|
p.add_x_axis({"seconds", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
|
|
p.add_y_axis({"millivolts per division", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
|
const plot::PlotLayout& l = p.layout();
|
|
const plot::f32 pad = p.style().grid_label_pad;
|
|
|
|
// One text line wide -- a column, not the run's own width...
|
|
PLOT_CHECK_NEAR(l.y_title_area.w, l.y_title_width, 1e-6);
|
|
PLOT_CHECK_NEAR(l.y_title_width, font.height() + 2.0f * pad, 1e-4);
|
|
PLOT_CHECK(l.y_title_width < font.measure("millivolts per division") + 2.0f * pad);
|
|
// ... spanning the axes' whole vertical extent, so the run has the room to
|
|
// advance in and a title longer than the axes is cut where the axes ends.
|
|
PLOT_CHECK_NEAR(l.y_title_area.x, 0.0f, 1e-6);
|
|
PLOT_CHECK_NEAR(l.y_title_area.y, l.axes.y, 1e-6);
|
|
PLOT_CHECK_NEAR(l.y_title_area.h, l.axes.h, 1e-4);
|
|
PLOT_CHECK(l.y_title_area.h > l.y_title_area.w);
|
|
// The strips tile the viewport outside `axes`: title, then labels, then data.
|
|
PLOT_CHECK_NEAR(l.y_title_area.right(), l.y_label_area.x, 1e-4);
|
|
PLOT_CHECK_NEAR(l.y_label_area.right(), l.axes.x, 1e-4);
|
|
PLOT_CHECK_NEAR(l.axes.x, l.y_title_width + l.y_axis_width, 1e-4);
|
|
PLOT_CHECK_NEAR(l.axes.w, 800.0f - (l.y_title_width + l.y_axis_width), 1e-4);
|
|
}
|
|
|
|
/// A Left and a Right titled y axis both draw in the one strip the layout reserves,
|
|
/// so the strip has to hold two runs. They no longer stack down its height -- the
|
|
/// runs are vertical, they would overlap from top to bottom -- so each gets a
|
|
/// column, and the strip is two text lines wide. What the model must not do is go
|
|
/// back to being as wide as the widest title: the columns are lines, not titles.
|
|
PLOT_TEST(two_titled_y_axes_reserve_a_column_each) {
|
|
plot::render::EmbeddedMonoFont font;
|
|
plot::Plot p;
|
|
p.set_text_backend(&font);
|
|
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
|
p.add_y_axis({"volts", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
|
p.add_y_axis({"amps", {-1.0, 1.0}, false, plot::AxisPlace::Right, false});
|
|
const plot::PlotLayout& l = p.layout();
|
|
const plot::f32 pad = p.style().grid_label_pad;
|
|
|
|
PLOT_CHECK(p.titled_y_axis_count() == 2u);
|
|
PLOT_CHECK_NEAR(l.y_title_width, 2.0f * font.height() + 2.0f * pad, 1e-4);
|
|
// Two columns fit side by side inside the strip, and the axes is inset by it once.
|
|
PLOT_CHECK(l.y_title_width >= 2.0f * font.height());
|
|
PLOT_CHECK_NEAR(l.y_title_area.w, l.y_title_width, 1e-6);
|
|
PLOT_CHECK_NEAR(l.y_title_area.right(), l.y_label_area.x, 1e-4);
|
|
PLOT_CHECK_NEAR(l.axes.x, l.y_title_width + l.y_axis_width, 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<plot::Tick>& 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<f32> 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<f32> 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<f32> 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);
|
|
}
|
|
|
|
/// A saturated ring pins size() at capacity while x0() advances every acquire(),
|
|
/// so a cache keyed on (pointer, sample count) alone would freeze this measurement
|
|
/// on the first saturated frame -- the one case the demos never reach, because they
|
|
/// move their cursors (and therefore `dirty`) every frame.
|
|
///
|
|
/// The signal is a ramp in absolute x, so the value a cursor names once the window
|
|
/// has slid past it is the current window's oldest sample: a live cache tracks
|
|
/// x0(), a frozen one keeps reporting the first frame's numbers. The counter is the
|
|
/// other half of the contract -- a frame in which nothing moved is still a cache
|
|
/// hit, or the fix would have traded a stale readout for a per-frame recompute.
|
|
PLOT_TEST(saturated_stream_measurement_tracks_the_sliding_window) {
|
|
constexpr u64 kCapacity = 64;
|
|
plot::data::StreamSeries s(kCapacity, 0.0, 1.0);
|
|
for (u64 i = 0; i < kCapacity; ++i) s.push(f32(i)); // y == absolute x
|
|
s.acquire();
|
|
PLOT_CHECK(s.size() == kCapacity); // published window is full
|
|
|
|
plot::Plot p;
|
|
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
|
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
|
|
const u32 ay = p.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
|
|
p.add_trace({&s, plot::Color::hex(0xFFFFFF), 1.0f, ax, ay});
|
|
const u32 a = p.add_cursor(10.0, plot::Color::hex(0xFFB020));
|
|
const u32 b = p.add_cursor(60.0, plot::Color::hex(0xFFB020));
|
|
const u32 m = p.add_measure(a, b);
|
|
|
|
// First window, both cursors inside it: y over absolute x 10..60.
|
|
PLOT_CHECK(p.measurement(m).valid);
|
|
PLOT_CHECK_NEAR(p.measurement(m).ymin, 10.0, 1e-9);
|
|
PLOT_CHECK_NEAR(p.measurement(m).ymax, 60.0, 1e-9);
|
|
PLOT_CHECK_NEAR(p.measurement(m).mean, 35.0, 1e-9);
|
|
PLOT_CHECK_NEAR(p.measurement(m).dx, 50.0, 1e-9);
|
|
|
|
auto counter = [] {
|
|
return plot::detail::measure_recomputes().load(std::memory_order_relaxed);
|
|
};
|
|
plot::detail::measure_recomputes().store(0, std::memory_order_relaxed);
|
|
// Neither cursor moves and neither does the window: eight reads, no recompute.
|
|
for (int i = 0; i < 8; ++i) (void)p.measurement(m);
|
|
PLOT_CHECK(counter() == 0u);
|
|
|
|
// Slide the window past cursor `a` -- 40 more samples and an acquire, with the
|
|
// sample count unchanged (still capacity) and neither cursor touched.
|
|
for (u64 i = kCapacity; i < kCapacity + 40; ++i) s.push(f32(i));
|
|
s.acquire();
|
|
PLOT_CHECK(s.size() == kCapacity); // size() cannot be the key
|
|
PLOT_CHECK_NEAR(s.x0(), 40.0, 1e-9); // x0() moved under it
|
|
|
|
const plot::Measurement& slid = p.measurement(m); // one acquire, one recompute
|
|
PLOT_CHECK(counter() == 1u);
|
|
PLOT_CHECK(slid.valid);
|
|
PLOT_CHECK_NEAR(slid.ymin, 40.0, 1e-9); // `a` clamped onto the new x0()
|
|
PLOT_CHECK_NEAR(slid.ymax, 60.0, 1e-9);
|
|
PLOT_CHECK_NEAR(slid.mean, 50.0, 1e-9);
|
|
PLOT_CHECK_NEAR(slid.dx, 20.0, 1e-9);
|
|
|
|
// And it keeps tracking, one recompute per acquire with the cursors still.
|
|
for (u64 i = 0; i < 5; ++i) {
|
|
s.push(f32(kCapacity + 40 + static_cast<f64>(i)));
|
|
s.acquire();
|
|
PLOT_CHECK_NEAR(p.measurement(m).ymin, 41.0 + static_cast<f64>(i), 1e-9);
|
|
PLOT_CHECK(counter() == 2u + i);
|
|
}
|
|
}
|
|
|
|
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(auto_fit_log_axis_stays_positive_and_ordered) {
|
|
// All-negative trace bounds: nothing to show on a log axis, so the fit is
|
|
// refused and the caller's (positive) range survives untouched.
|
|
{
|
|
std::vector<f32> y = {-5.0f, -3.0f, -1.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", {1.0, 10.0}, false, plot::AxisPlace::Bottom, false});
|
|
const u32 ay = p.add_y_axis({"v", {1.0, 100.0}, true, plot::AxisPlace::Left, true});
|
|
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
|
|
p.auto_fit_y(ay);
|
|
check_log_axis_range(p.y_range(ay));
|
|
PLOT_CHECK_NEAR(p.y_range(ay).min, 1.0, 1e-12);
|
|
PLOT_CHECK_NEAR(p.y_range(ay).max, 100.0, 1e-12);
|
|
(void)p.layout(); // the per-layout refit refuses too
|
|
check_log_axis_range(p.y_range(ay));
|
|
PLOT_CHECK_NEAR(p.y_range(ay).max, 100.0, 1e-12);
|
|
}
|
|
|
|
// All-zero bounds: same refusal, no "0" or negative bound on the axis. One
|
|
// sample at x = 0 makes both bounds exactly {0, 0}.
|
|
{
|
|
std::vector<f32> y = {0.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", {1.0, 10.0}, true, plot::AxisPlace::Bottom, true});
|
|
const u32 ay = p.add_y_axis({"v", {1.0, 10.0}, true, plot::AxisPlace::Left, true});
|
|
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
|
|
p.auto_fit_x();
|
|
p.auto_fit_y(ay);
|
|
check_log_axis_range(p.x_range(ax));
|
|
check_log_axis_range(p.y_range(ay));
|
|
PLOT_CHECK_NEAR(p.x_range(ax).min, 1.0, 1e-12); // refused: range untouched
|
|
PLOT_CHECK_NEAR(p.x_range(ax).max, 10.0, 1e-12);
|
|
PLOT_CHECK_NEAR(p.y_range(ay).min, 1.0, 1e-12);
|
|
PLOT_CHECK_NEAR(p.y_range(ay).max, 10.0, 1e-12);
|
|
}
|
|
|
|
// Spanning zero with some positive samples: the fit uses the positive part, and
|
|
// the positive data stays inside the range.
|
|
{
|
|
std::vector<f32> y = {-1.0f, 0.5f, 3.0f};
|
|
plot::data::ArraySeries s(std::move(y), -1.0, 1.0); // x bounds {-1, 1}
|
|
plot::Plot p;
|
|
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
|
const u32 ax = p.add_x_axis({"t", {1.0, 10.0}, true, plot::AxisPlace::Bottom, true});
|
|
const u32 ay = p.add_y_axis({"v", {1.0, 100.0}, true, plot::AxisPlace::Left, true});
|
|
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
|
|
p.auto_fit_x();
|
|
p.auto_fit_y(ay);
|
|
check_log_axis_range(p.x_range(ax));
|
|
check_log_axis_range(p.y_range(ay));
|
|
PLOT_CHECK(p.x_range(ax).min <= 1.0); // the positive x data
|
|
PLOT_CHECK(p.x_range(ax).max >= 1.0);
|
|
PLOT_CHECK(p.y_range(ay).min <= 3.0); // the positive y data
|
|
PLOT_CHECK(p.y_range(ay).max >= 3.0);
|
|
}
|
|
|
|
// Purely positive stays a plain log fit, still ordered and positive.
|
|
{
|
|
std::vector<f32> y = {1.0f, 2.0f, 3.0f};
|
|
plot::data::ArraySeries s(std::move(y), 1.0, 1.0); // x bounds {1, 3}
|
|
plot::Plot p;
|
|
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
|
const u32 ax = p.add_x_axis({"t", {1.0, 10.0}, true, plot::AxisPlace::Bottom, true});
|
|
const u32 ay = p.add_y_axis({"v", {1.0, 100.0}, true, plot::AxisPlace::Left, true});
|
|
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
|
|
p.auto_fit_x();
|
|
p.auto_fit_y(ay);
|
|
check_log_axis_range(p.x_range(ax));
|
|
check_log_axis_range(p.y_range(ay));
|
|
PLOT_CHECK(p.x_range(ax).min < 1.0 && p.x_range(ax).max > 3.0);
|
|
PLOT_CHECK(p.y_range(ay).min < 1.0 && p.y_range(ay).max > 3.0);
|
|
}
|
|
}
|
|
|
|
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<plot::Tick>& xt = p.x_axis(ax).ticks;
|
|
const std::vector<plot::Tick>& 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
|
|
}
|