Files
plotter/tests/test_plot.cpp
T

1146 lines
53 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;
}
/// Every major tick's value, in order: what a tick-density assertion compares, so it
/// can compare the *grid* the two scales produce rather than only a count.
std::vector<f64> major_tick_values(const plot::Axis& a) {
std::vector<f64> v;
for (const plot::Tick& tick : a.ticks) {
if (tick.major) v.push_back(tick.value);
}
return v;
}
/// 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;
}
/// The same question in *screen* space: the renderer places a line by mapping its
/// `x0`/`y0` through the axis' transform, so this is the coincidence the pixels show.
bool has_vertical_screen(const plot::Grid& g, const plot::Transform& tx, f64 sx, bool major) {
for (const plot::GridLine& l : g.lines()) {
if (l.x0 == l.x1 && l.major == major && tx.to_screen_x(l.x0) == sx) return true;
}
return false;
}
bool has_horizontal_screen(const plot::Grid& g, const plot::Transform& ty, f64 sy, bool major) {
for (const plot::GridLine& l : g.lines()) {
if (l.y0 == l.y1 && l.major == major && ty.to_screen_y(l.y0) == sy) return true;
}
return false;
}
/// How many of `g`'s lines carry one class: `vertical` picks the direction, `major`
/// the class.
u32 count_lines(const plot::Grid& g, bool vertical, bool major) {
u32 n = 0;
for (const plot::GridLine& l : g.lines()) {
if (((l.x0 == l.x1) == vertical) && l.major == major) ++n;
}
return n;
}
/// Whether `ticks` holds `value` -- the "and every tick has a line" half of the
/// coincidence check, asked of the list the grid was built from.
bool tick_at(const std::vector<plot::Tick>& ticks, f64 value) {
for (const plot::Tick& t : ticks) {
if (t.value == value) 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
}
/// The grid's positions have one source -- the tick set -- and this pins that, in
/// both directions and in both spaces: a line with no tick and a tick with no line
/// are each a defect, and one direction alone is satisfied by a grid that drops
/// ticks or by one that invents them. A labelled major tick and a major grid line
/// therefore coincide *by construction* (spec §11), not by two derivations that
/// happen to agree on the ranges this case tries.
///
/// Screen space is checked beside data space because the renderer places a line by
/// mapping its endpoints through the axis' transform (`Renderer::draw_grid`), so
/// screen coincidence is what the pixels show. A log axis is among the ranges: that
/// transform is the one that is not the identity.
PLOT_TEST(major_grid_lines_coincide_with_major_ticks) {
/// Both directions, both spaces, one line per tick -- for one laid-out plot.
const auto check_pair = [](const plot::Plot& p, u32 ax, u32 ay, const char* what) {
const plot::Grid& g = p.grid();
const plot::Transform tx = p.x_transform(ax);
const plot::Transform ty = p.y_transform(ay);
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());
// Exactly one line per tick of the tick set the grid was built from: none
// dropped, none invented, and both classes carried through.
PLOT_CHECK(count_lines(g, true, true) + count_lines(g, true, false) == xt.size());
PLOT_CHECK(count_lines(g, false, true) + count_lines(g, false, false) == yt.size());
PLOT_CHECK(count_lines(g, true, false) > 0u); // the range really has minors
PLOT_CHECK(count_lines(g, false, false) > 0u);
// Every tick -> a line of its own class, and never of the other class.
for (const plot::Tick& t : xt) {
PLOT_CHECK(has_vertical(g, t.value, t.major));
PLOT_CHECK(!has_vertical(g, t.value, !t.major));
PLOT_CHECK(has_vertical_screen(g, tx, tx.to_screen_x(t.value), t.major));
}
for (const plot::Tick& t : yt) {
PLOT_CHECK(has_horizontal(g, t.value, t.major));
PLOT_CHECK(!has_horizontal(g, t.value, !t.major));
PLOT_CHECK(has_horizontal_screen(g, ty, ty.to_screen_y(t.value), t.major));
}
// Every line -> a tick of its own class.
for (const plot::GridLine& l : g.lines()) {
if (l.x0 == l.x1) {
PLOT_CHECK(tick_at(xt, l.x0));
} else {
PLOT_CHECK(tick_at(yt, l.y0));
}
}
std::printf("[grid] %-22s x %u ticks (%u major) -> %u vertical lines; y %u ticks (%u major)"
" -> %u horizontal, %s\n",
what, static_cast<unsigned>(xt.size()), count_lines(g, true, true),
count_lines(g, true, true) + count_lines(g, true, false),
static_cast<unsigned>(yt.size()), count_lines(g, false, true),
count_lines(g, false, true) + count_lines(g, false, false),
(tx.log_x || ty.log_y) ? "log" : "linear");
};
// Several ranges and axes widths, so the check is not one nice-number step's
// accident.
struct Scene {
const char* what;
f64 lo, hi;
f32 w, h;
};
const Scene scenes[] = {
{"[0, 10] 800x400", 0.0, 10.0, 800.0f, 400.0f},
{"[0, 100] 800x400", 0.0, 100.0, 800.0f, 400.0f},
{"[-5, 5] 640x360", -5.0, 5.0, 640.0f, 360.0f},
{"[0, 3.3] 200x120", 0.0, 3.3, 200.0f, 120.0f}, // a narrow axes: few ticks
};
for (const Scene& s : scenes) {
plot::Plot p;
p.set_viewport({0.0f, 0.0f, s.w, s.h});
const u32 ax = p.add_x_axis({"t", {s.lo, s.hi}, false, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {s.lo, s.hi}, false, plot::AxisPlace::Left, false});
(void)p.layout();
check_pair(p, ax, ay, s.what);
}
// A log pair: the same data-space coincidence, and the same once mapped through the
// log10 pre-affine -- where an equal data step is *not* an equal screen step, so a
// grid built on any second derivation would part company here first.
{
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ax = p.add_x_axis({"t", {1.0, 1000.0}, false, plot::AxisPlace::Bottom, true});
const u32 ay = p.add_y_axis({"v", {1.0, 100.0}, false, plot::AxisPlace::Left, true});
(void)p.layout();
PLOT_CHECK(p.x_axis(ax).log);
PLOT_CHECK(p.y_axis(ay).log);
PLOT_CHECK(p.x_transform(ax).log_x);
PLOT_CHECK(p.y_transform(ay).log_y);
check_pair(p, ax, ay, "log [1, 1000]");
}
// A tick set with no minors in it -- `generate_ticks(..., include_minors = false)`,
// the labelled set a caller draws numbers for -- makes a grid with no minors: the
// grid owns no subdivision of its own. Both directions still hold, and the minor
// class is empty on *both* sides, which is asserted rather than skipped: an empty
// class on one side only is exactly the drift this case exists to catch.
{
std::vector<plot::Tick> xt;
std::vector<plot::Tick> yt;
plot::generate_ticks({0.0, 10.0}, 400.0f, 60.0f, xt, false);
plot::generate_ticks({0.0, 1.0}, 300.0f, 60.0f, yt, false);
PLOT_CHECK(!xt.empty());
PLOT_CHECK(!yt.empty());
for (const plot::Tick& t : xt) PLOT_CHECK(t.major);
for (const plot::Tick& t : yt) PLOT_CHECK(t.major);
plot::Grid g;
g.build(xt, {0.0, 10.0}, yt, {0.0, 1.0});
PLOT_CHECK(g.size() == xt.size() + yt.size());
for (const plot::Tick& t : xt) {
PLOT_CHECK(has_vertical(g, t.value, true));
PLOT_CHECK(!has_vertical(g, t.value, false));
}
for (const plot::Tick& t : yt) {
PLOT_CHECK(has_horizontal(g, t.value, true));
PLOT_CHECK(!has_horizontal(g, t.value, false));
}
PLOT_CHECK(count_lines(g, true, false) == 0u);
PLOT_CHECK(count_lines(g, false, false) == 0u);
std::printf("[grid] majors-only tick set %u x ticks, %u y ticks -> %zu lines, 0 minors\n",
static_cast<unsigned>(xt.size()), static_cast<unsigned>(yt.size()), g.size());
}
// Two axis pairs. `Plot::build_grid()` builds the grid from the *primary* pair (x
// axis 0, y axis 0) and `Renderer::draw_grid()` maps it with that pair's transforms
// -- the comment there says so -- so a second pair's ticks get no lines at all.
// Pinned rather than assumed: the coincidence above is with the primary pair's
// ticks, and a value only the second axis ticks at has no line, in data space or on
// screen. (A trace on the second pair is still drawn in the data area; it simply
// has no grid of its own.)
{
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ax0 = p.add_x_axis({"a", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
const u32 ax1 = p.add_x_axis({"b", {0.0, 1.0}, false, plot::AxisPlace::Top, false});
const u32 ay0 = p.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
const u32 ay1 = p.add_y_axis({"w", {0.0, 1.0}, false, plot::AxisPlace::Right, false});
(void)p.layout();
check_pair(p, ax0, ay0, "primary pair only");
PLOT_CHECK(p.x_axis_count() == 2u);
PLOT_CHECK(p.y_axis_count() == 2u);
const plot::Grid& g = p.grid();
const plot::Transform tx1 = p.x_transform(ax1);
const plot::Transform ty1 = p.y_transform(ay1);
u32 x_only_secondary = 0u;
u32 y_only_secondary = 0u;
for (const plot::Tick& t : p.x_axis(ax1).ticks) {
if (tick_at(p.x_axis(ax0).ticks, t.value)) continue; // 0 is on both ranges
++x_only_secondary;
PLOT_CHECK(!has_vertical(g, t.value, t.major));
PLOT_CHECK(!has_vertical_screen(g, tx1, tx1.to_screen_x(t.value), t.major));
}
for (const plot::Tick& t : p.y_axis(ay1).ticks) {
if (tick_at(p.y_axis(ay0).ticks, t.value)) continue;
++y_only_secondary;
PLOT_CHECK(!has_horizontal(g, t.value, t.major));
PLOT_CHECK(!has_horizontal_screen(g, ty1, ty1.to_screen_y(t.value), t.major));
}
// The case is not vacuous: the second pair really does tick where the first does
// not, so "no line there" is a statement about the build and not about the ranges.
PLOT_CHECK(x_only_secondary > 0u);
PLOT_CHECK(y_only_secondary > 0u);
std::printf("[grid] primary pair only %u secondary x ticks with no line, %u secondary y"
" ticks with no line; the grid is the primary pair's %zu lines\n",
static_cast<unsigned>(x_only_secondary),
static_cast<unsigned>(y_only_secondary), g.size());
}
}
// ------------------------------------------------------------------- legend
namespace {
/// The scene the legend cases measure: three named traces on one axis pair, a text
/// backend to measure the names with, and a viewport with room to spare. The names
/// are deliberately of different widths -- a panel sized to the *widest* one is what
/// the first case below checks, and names of one width could not tell that from a
/// panel sized to the first or the last.
struct LegendScene {
plot::render::EmbeddedMonoFont font;
plot::data::ArraySeries series;
plot::Plot plot;
LegendScene() : series(ramp(64), 0.0, 1.0) {
plot.set_text_backend(&font);
plot.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ax = plot.add_x_axis({"t", {0.0, 63.0}, false, plot::AxisPlace::Bottom, false});
const u32 ay = plot.add_y_axis({"v", {0.0, 63.0}, false, plot::AxisPlace::Left, false});
plot.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, ax, ay, "sine"});
plot.add_trace({&series, plot::Color::hex(0xFFB020), 1.5f, ax, ay, "noise floor"});
plot.add_trace({&series, plot::Color::hex(0x00E070), 1.5f, ax, ay, "ramp"});
}
};
/// One row's height is the taller of the swatch and a text line: the number every
/// other assertion here is written against, derived from the config the layout reads.
f32 legend_row_height(const plot::Legend& cfg, const plot::render::TextBackend& font) {
return std::max(cfg.swatch_h, font.height());
}
} // namespace
PLOT_TEST(legend_panel_wraps_the_entries) {
LegendScene scene;
const plot::PlotLayout& l = scene.plot.layout();
const plot::LegendLayout& legend = l.legend;
const plot::Legend& cfg = scene.plot.legend();
PLOT_CHECK(legend.rows.size() == 3u);
const f32 pad = cfg.pad;
const f32 row_h = legend_row_height(cfg, scene.font);
const f32 row_gap = cfg.row_gap;
const f32 widest = std::max({scene.font.measure("sine"), scene.font.measure("noise floor"),
scene.font.measure("ramp")});
// Anchored in the corner `place` names (top right by default), one `margin` in
// from the two edges that corner is near...
PLOT_CHECK_NEAR(legend.panel.right(), l.axes.right() - cfg.margin, 1e-4);
PLOT_CHECK_NEAR(legend.panel.y, l.axes.y + cfg.margin, 1e-4);
// ... and measured, not guessed: a padding, the swatch, a gutter and the widest
// name across; a padding, the rows and the gaps between them down.
PLOT_CHECK_NEAR(legend.panel.w, 2.0f * pad + cfg.swatch_w + pad + widest, 1e-4);
PLOT_CHECK_NEAR(legend.panel.h, 2.0f * pad + 3.0f * row_h + 2.0f * row_gap, 1e-4);
// Every row in trace order, one row gap below the last, with the boxes the
// renderer stages from already placed -- no arithmetic left for a caller.
const f32 name_x = legend.panel.x + pad + cfg.swatch_w + pad;
f32 row_y = legend.panel.y + pad;
for (u32 i = 0; i < legend.rows.size(); ++i) {
const plot::LegendRow& row = legend.rows[i];
PLOT_CHECK(row.trace == i);
PLOT_CHECK(!row.ellipsis);
PLOT_CHECK_NEAR(row.swatch.x, legend.panel.x + pad, 1e-4);
PLOT_CHECK_NEAR(row.swatch.y, row_y + 0.5f * (row_h - cfg.swatch_h), 1e-4);
PLOT_CHECK_NEAR(row.swatch.w, cfg.swatch_w, 1e-4);
PLOT_CHECK_NEAR(row.swatch.h, cfg.swatch_h, 1e-4);
PLOT_CHECK_NEAR(row.name.x, name_x, 1e-4);
PLOT_CHECK_NEAR(row.name.y, row_y + 0.5f * (row_h - scene.font.height()), 1e-4);
PLOT_CHECK_NEAR(row.name.h, scene.font.height(), 1e-4);
// Both boxes are inside the panel, swatch to panel edge and name to the padding
// the panel reserved for it.
PLOT_CHECK(row.swatch.x >= legend.panel.x);
PLOT_CHECK(row.swatch.right() <= legend.panel.right());
PLOT_CHECK(row.name.right() <= legend.panel.right() - pad + 1e-4f);
row_y += row_h + row_gap;
}
// The wrapper property, stated as the number it is: the widest row's name ends
// exactly one padding short of the panel's edge, which is what "the panel is
// sized to the widest name" means. A panel sized to another row's name would fail
// this by that row's difference in width.
PLOT_CHECK_NEAR(legend.rows[1].name.w, widest, 1e-4);
PLOT_CHECK_NEAR(legend.rows[1].name.right() + pad, legend.panel.right(), 1e-4);
}
/// A legend is inside `axes` in every corner, held off the two edges its corner is
/// near by `margin` and off the other two by its own measured size. The clamp is
/// what stops a legend from being drawn over the tick labels or the data the plot
/// reserved no room for -- it is inside the data area by construction, since the fit
/// above is measured against exactly this inset.
PLOT_TEST(legend_is_clamped_inside_the_axes) {
LegendScene scene;
const plot::LegendPlace places[4] = {plot::LegendPlace::TopLeft, plot::LegendPlace::TopRight,
plot::LegendPlace::BottomLeft,
plot::LegendPlace::BottomRight};
for (const plot::LegendPlace place : places) {
plot::Legend cfg = scene.plot.legend();
cfg.place = place;
scene.plot.set_legend(cfg);
const plot::PlotLayout& l = scene.plot.layout();
const plot::Rect& panel = l.legend.panel;
PLOT_CHECK(!l.legend.empty());
// Inside the data area: half-open containment, so the far edges compare directly.
PLOT_CHECK(panel.x >= l.axes.x);
PLOT_CHECK(panel.y >= l.axes.y);
PLOT_CHECK(panel.right() <= l.axes.right());
PLOT_CHECK(panel.bottom() <= l.axes.bottom());
const bool near_right = place == plot::LegendPlace::TopRight ||
place == plot::LegendPlace::BottomRight;
const bool near_bottom = place == plot::LegendPlace::BottomLeft ||
place == plot::LegendPlace::BottomRight;
PLOT_CHECK_NEAR(near_right ? panel.right() : panel.x,
near_right ? l.axes.right() - cfg.margin : l.axes.x + cfg.margin, 1e-4);
PLOT_CHECK_NEAR(near_bottom ? panel.bottom() : panel.y,
near_bottom ? l.axes.bottom() - cfg.margin : l.axes.y + cfg.margin, 1e-4);
}
}
/// Entries that do not fit are dropped from the end and the panel says so with a
/// final marker row; a plot with no room for even one entry and its marker gets no
/// legend at all rather than a panel over the axes. Both halves are the same rule
/// read in opposite directions, so they are asserted together.
PLOT_TEST(legend_drops_overflowing_entries) {
plot::render::EmbeddedMonoFont font;
plot::data::ArraySeries series(ramp(64), 0.0, 1.0);
plot::Plot p;
p.set_text_backend(&font);
p.set_viewport({0.0f, 0.0f, 400.0f, 260.0f});
const u32 ax = p.add_x_axis({"t", {0.0, 63.0}, false, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {0.0, 63.0}, false, plot::AxisPlace::Left, false});
constexpr u32 kNamed = 20;
for (u32 i = 0; i < kNamed; ++i) p.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, ax, ay,
"trace " + std::to_string(i)});
const plot::PlotLayout& l = p.layout();
const plot::LegendLayout& legend = l.legend;
const plot::Legend& cfg = p.legend();
const f32 pad = cfg.pad;
const f32 row_h = legend_row_height(cfg, font);
// Re-derive the rule rather than assert a coincidence: the largest number of kept
// entries whose panel -- with the marker row it needs once anything was dropped --
// still fits the inset box. `k + 1` rows is the truncated case, `k` the untruncated
// one; both are tried from the top down, exactly as the layout does.
const f32 room_h = l.axes.h - 2.0f * cfg.margin;
u32 expected = 0;
for (u32 k = kNamed; k > 0u; --k) {
const bool truncated = k < kNamed;
const f32 rows = static_cast<f32>(k + (truncated ? 1u : 0u));
if (2.0f * pad + rows * row_h + (rows - 1.0f) * cfg.row_gap <= room_h) {
expected = k;
break;
}
}
// The scene has to be one where the rule actually bites, or the case proves
// nothing about dropping entries.
PLOT_CHECK(expected != 0u);
PLOT_CHECK(expected < kNamed);
PLOT_CHECK(legend.rows.size() == expected + 1u);
// The kept entries are the first `expected` ones, in trace order: entries are
// dropped from the end, never from the middle.
for (u32 i = 0; i < expected; ++i) {
PLOT_CHECK(!legend.rows[i].ellipsis);
PLOT_CHECK(legend.rows[i].trace == i);
}
const plot::LegendRow& marker = legend.rows.back();
PLOT_CHECK(marker.ellipsis);
PLOT_CHECK(marker.name.x == legend.rows[0].name.x); // the marker lines up with the names
PLOT_CHECK_NEAR(marker.name.w, font.measure(plot::kLegendEllipsis), 1e-4);
PLOT_CHECK(marker.swatch.w == 0.0f && marker.swatch.h == 0.0f); // no trace, no swatch
// The panel with the marker is inside `axes`, which is the property the drop
// exists to keep: a panel that overhung the plot would be worse than a short list.
PLOT_CHECK(legend.panel.x >= l.axes.x && legend.panel.right() <= l.axes.right());
PLOT_CHECK(legend.panel.y >= l.axes.y && legend.panel.bottom() <= l.axes.bottom());
// Not even one entry plus the marker fits: no legend at all, and nothing drawn.
plot::Plot tiny;
tiny.set_text_backend(&font);
tiny.set_viewport({0.0f, 0.0f, 120.0f, 60.0f});
const u32 tax = tiny.add_x_axis({"t", {0.0, 63.0}, false, plot::AxisPlace::Bottom, false});
const u32 tay = tiny.add_y_axis({"v", {0.0, 63.0}, false, plot::AxisPlace::Left, false});
tiny.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, tax, tay, "sine"});
const plot::PlotLayout& tl = tiny.layout();
PLOT_CHECK(tl.legend.empty());
PLOT_CHECK_NEAR(tl.legend.panel.w, 0.0f, 1e-6);
PLOT_CHECK_NEAR(tl.legend.panel.h, 0.0f, 1e-6);
}
/// A trace with no name is not an entry, and neither is a trace with no series to
/// draw -- the same "visible" test every other consumer of a trace makes. This is
/// the whole cost of the feature for a caller that never names a trace: nothing.
PLOT_TEST(unnamed_traces_produce_no_legend) {
plot::render::EmbeddedMonoFont font;
plot::data::ArraySeries series(ramp(64), 0.0, 1.0);
plot::Plot p;
p.set_text_backend(&font);
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ax = p.add_x_axis({"t", {0.0, 63.0}, false, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {0.0, 63.0}, false, plot::AxisPlace::Left, false});
p.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, ax, ay}); // no name
p.add_trace({nullptr, plot::Color::hex(0xFFB020), 1.5f, ax, ay, "ghost"}); // named, no series
p.add_trace({&series, plot::Color::hex(0x00E070), 1.5f, ax, ay, "sig"});
const plot::PlotLayout& l = p.layout();
PLOT_CHECK(l.legend.rows.size() == 1u);
PLOT_CHECK(l.legend.rows[0].trace == 2u); // the named, drawable one, by its own id
PLOT_CHECK(!l.legend.rows[0].ellipsis);
// Without the one name, the layout is empty and zero-sized: no panel, no rows.
plot::Plot unnamed;
unnamed.set_text_backend(&font);
unnamed.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 uax = unnamed.add_x_axis({"t", {0.0, 63.0}, false, plot::AxisPlace::Bottom, false});
const u32 uay = unnamed.add_y_axis({"v", {0.0, 63.0}, false, plot::AxisPlace::Left, false});
unnamed.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, uax, uay});
unnamed.add_trace({&series, plot::Color::hex(0xFFB020), 1.5f, uax, uay});
const plot::LegendLayout& none = unnamed.layout().legend;
PLOT_CHECK(none.empty());
PLOT_CHECK_NEAR(none.panel.w, 0.0f, 1e-6);
PLOT_CHECK_NEAR(none.panel.h, 0.0f, 1e-6);
}
/// A plot has no font of its own, so a legend with nothing to measure a name with is
/// no legend: the plot keeps the defaults it laid out with before legends existed.
PLOT_TEST(no_text_backend_produces_no_legend) {
plot::data::ArraySeries series(ramp(64), 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, 63.0}, false, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {0.0, 63.0}, false, plot::AxisPlace::Left, false});
p.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, ax, ay, "sine"});
const plot::PlotLayout& l = p.layout();
PLOT_CHECK(p.text_backend() == nullptr);
PLOT_CHECK(l.legend.empty());
PLOT_CHECK_NEAR(l.legend.panel.w, 0.0f, 1e-6);
PLOT_CHECK_NEAR(l.legend.panel.h, 0.0f, 1e-6);
// Nothing else moved either: the strips are the no-backend defaults, not metrics a
// legend could not measure.
PLOT_CHECK_NEAR(l.y_axis_width, 56.0f, 1e-6);
PLOT_CHECK_NEAR(l.x_axis_height, 22.0f, 1e-6);
}
/// Switching the legend off is not the same as the plot having nothing to list: the
/// entries exist, the layout is empty, and -- the reason it matters -- the axes rect
/// is *identical*, because a legend is drawn inside the data area and reserves none
/// of it. A legend that changed `axes` would rescale every plot the moment a trace
/// was named.
PLOT_TEST(legend_hidden_by_visible_false_draws_nothing) {
LegendScene scene;
const plot::Rect shown_axes = scene.plot.layout().axes;
PLOT_CHECK(!scene.plot.layout().legend.empty());
plot::Legend cfg = scene.plot.legend();
cfg.visible = false;
scene.plot.set_legend(cfg);
const plot::PlotLayout& l = scene.plot.layout();
PLOT_CHECK(l.legend.empty());
PLOT_CHECK_NEAR(l.legend.panel.w, 0.0f, 1e-6);
PLOT_CHECK_NEAR(l.legend.panel.h, 0.0f, 1e-6);
PLOT_CHECK_NEAR(l.axes.x, shown_axes.x, 1e-6);
PLOT_CHECK_NEAR(l.axes.y, shown_axes.y, 1e-6);
PLOT_CHECK_NEAR(l.axes.w, shown_axes.w, 1e-6);
PLOT_CHECK_NEAR(l.axes.h, shown_axes.h, 1e-6);
// ... and switching it back on lists the same three entries again.
cfg.visible = true;
scene.plot.set_legend(cfg);
PLOT_CHECK(scene.plot.layout().legend.rows.size() == 3u);
}
// --- the UI scale: logical metrics, one physical size at any DPI -------------
/// A minimum tick spacing is a physical gap between labels, so the same *logical*
/// plot width must get the same major ticks at any scale. This is the failure mode
/// on the under-eager side: an unscaled `tick_min_px` measured against a 2x-wide
/// framebuffer packs twice as many ticks onto the same panel, and the grid's density
/// becomes a property of the display instead of the data.
PLOT_TEST(tick_spacing_is_physical) {
plot::render::EmbeddedMonoFont font;
// No y axis, and so no y label strip: with a text backend present the axes width
// is then the viewport width *exactly* (`layout()` reserves a left strip only for
// the axes that have labels), and the two runs differ in the display's scale and
// nothing else. The strips' own scaling is the render smoke test's subject; this
// case isolates the tick rule.
plot::Plot one;
one.set_text_backend(&font);
one.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ax1 = one.add_x_axis({"t", {0.0, 1000.0}, false, plot::AxisPlace::Bottom, false});
const plot::PlotLayout& lay1 = one.layout();
PLOT_CHECK_NEAR(lay1.axes.w, 800.0f, 1e-6);
plot::Plot two;
two.set_text_backend(&font);
two.set_ui_scale(2.0f);
two.set_viewport({0.0f, 0.0f, 1600.0f, 800.0f}); // the same plot, a 2x display
const u32 ax2 = two.add_x_axis({"t", {0.0, 1000.0}, false, plot::AxisPlace::Bottom, false});
const plot::PlotLayout& lay2 = two.layout();
PLOT_CHECK_NEAR(lay2.axes.w, 1600.0f, 1e-6);
// Both axes are 800 logical units wide, so both must carry the same major ticks.
PLOT_CHECK_NEAR(lay2.axes.w / 2.0f, lay1.axes.w, 1e-6);
const std::vector<f64> majors1 = major_tick_values(one.x_axis(ax1));
const std::vector<f64> majors2 = major_tick_values(two.x_axis(ax2));
PLOT_CHECK(majors1.size() > 2u); // not vacuous: the case has ticks to lose
PLOT_CHECK(majors2 == majors1);
// The label strip is the same *logical* height too: its padding scaled even though
// this backend's own metrics are fixed. (A frame drawn by a renderer also gets
// metrics scaled with the display -- see the smoke test's two-scale case.)
PLOT_CHECK(lay2.x_label_area.h > lay1.x_label_area.h);
}
/// The scale is an opt-in: nothing changes for a caller that never sets one, which
/// is what keeps this a unit change and not a behaviour change for existing code.
PLOT_TEST(ui_scale_defaults_to_one) {
plot::render::Style s;
PLOT_CHECK(s.ui_scale == 1.0f);
// At 1.0 the conversion is the identity, exactly -- no rounding, no reordering.
PLOT_CHECK(s.device(0.0f) == 0.0f);
PLOT_CHECK(s.device(1.5f) == 1.5f);
PLOT_CHECK(s.device(12.0f) == 12.0f);
PLOT_CHECK(s.device(1234.5f) == 1234.5f);
// A scale that is not a scale -- a host that has not measured its display -- is
// 1:1 rather than zero, which would collapse every strip and stroke to nothing.
s.ui_scale = 0.0f;
PLOT_CHECK(s.device(12.0f) == 12.0f);
s.ui_scale = -2.0f;
PLOT_CHECK(s.device(12.0f) == 12.0f);
plot::render::EmbeddedMonoFont font;
// One plot left at the default and one told the scale is 1.0 lay out identically,
// measured strips and legend included: the default *is* 1.0.
plot::Plot plain;
plot::Plot one;
one.set_ui_scale(1.0f);
plot::data::ArraySeries series(ramp(8), 0.0, 1.0);
for (plot::Plot* p : {&plain, &one}) {
p->set_text_backend(&font);
p->set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ax = p->add_x_axis({"t", {0.0, 7.0}, false, plot::AxisPlace::Bottom, false});
const u32 ay = p->add_y_axis({"v", {0.0, 7.0}, false, plot::AxisPlace::Left, false});
p->add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, ax, ay, "sine"});
}
const plot::PlotLayout& a = plain.layout();
const plot::PlotLayout& b = one.layout();
PLOT_CHECK(plain.ui_scale() == 1.0f);
PLOT_CHECK(b.y_axis_width == a.y_axis_width);
PLOT_CHECK(b.x_axis_height == a.x_axis_height);
PLOT_CHECK(b.y_title_width == a.y_title_width);
PLOT_CHECK(b.axes.x == a.axes.x);
PLOT_CHECK(b.axes.h == a.axes.h);
PLOT_CHECK(b.legend.panel.w == a.legend.panel.w);
PLOT_CHECK(b.legend.panel.h == a.legend.panel.h);
}