2259 lines
108 KiB
C++
2259 lines
108 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 <limits>
|
|
#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
|
|
}
|
|
|
|
/// Spec §14: a cursor is one instrument in two orientations, so it owns exactly one coordinate
|
|
/// and exactly one snap. The dead field of the other orientation is *ignored*, not honoured -- a
|
|
/// setter that wrote it would leave the struct holding two coordinates that could disagree about
|
|
/// where the cursor is, and would let the two snaps drift into meaning each other's job
|
|
/// (`SnapMode::NearestSample` is a sample of an x grid; `HRulerSnap::NearestTick` is a tick of a
|
|
/// y axis). The store's half of the ruler, with no pointer and no renderer involved.
|
|
PLOT_TEST(a_cursor_owns_only_the_coordinate_and_snap_of_its_orientation) {
|
|
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(0xFFFFFF), 1.0f, ax, ay});
|
|
const u32 marker = p.add_cursor(10.0, plot::Color::hex(0xFFB020));
|
|
const u32 ruler = p.add_h_ruler(50.0, plot::Color::hex(0x35D0A8));
|
|
|
|
// The orientation each constructor claims, and the coordinate it stored in.
|
|
PLOT_CHECK(p.cursor(marker).orientation == plot::CursorOrientation::Vertical);
|
|
PLOT_CHECK(p.cursor(ruler).orientation == plot::CursorOrientation::Horizontal);
|
|
PLOT_CHECK_NEAR(p.cursor(marker).x, 10.0, 1e-12);
|
|
PLOT_CHECK_NEAR(p.cursor(ruler).y, 50.0, 1e-12);
|
|
|
|
// The coordinate the other orientation owns, twice over: `y` is not a marker's to move and
|
|
// `x` is not a ruler's, so neither field can be written behind the orientation's back.
|
|
p.set_cursor_y(marker, 77.0);
|
|
PLOT_CHECK_NEAR(p.cursor(marker).y, 0.0, 1e-12); // still the default it was born with
|
|
PLOT_CHECK_NEAR(p.cursor(marker).x, 10.0, 1e-12);
|
|
p.set_cursor_x(ruler, 77.0);
|
|
PLOT_CHECK_NEAR(p.cursor(ruler).x, 0.0, 1e-12);
|
|
PLOT_CHECK_NEAR(p.cursor(ruler).y, 50.0, 1e-12);
|
|
|
|
// `SnapMode::NearestSample` on a ruler is ignored too: 33.37 is no sample of the x grid and no
|
|
// tick of the y axis, so an honoured sample-snap -- onto 33 or onto the nearest y tick -- would
|
|
// show up as a move, and neither did.
|
|
p.set_cursor_y(ruler, 33.37);
|
|
p.set_cursor_snap(ruler, plot::SnapMode::NearestSample);
|
|
PLOT_CHECK(p.cursor(ruler).snap == plot::SnapMode::Free);
|
|
PLOT_CHECK_NEAR(p.cursor(ruler).y, 33.37, 1e-12);
|
|
|
|
// The marker's half of the same rule: `NearestTick` does not touch its x, while its own
|
|
// `NearestSample` does -- so the two modes are live on exactly one orientation each.
|
|
p.set_cursor_x(marker, 12.37);
|
|
PLOT_CHECK_NEAR(p.cursor(marker).x, 12.37, 1e-12); // Free: stored verbatim
|
|
p.set_cursor_h_snap(marker, plot::HRulerSnap::NearestTick);
|
|
PLOT_CHECK(p.cursor(marker).h_snap == plot::HRulerSnap::Free);
|
|
PLOT_CHECK_NEAR(p.cursor(marker).x, 12.37, 1e-12);
|
|
p.set_cursor_snap(marker, plot::SnapMode::NearestSample);
|
|
PLOT_CHECK(p.cursor(marker).snap == plot::SnapMode::NearestSample);
|
|
PLOT_CHECK_NEAR(p.cursor(marker).x, 12.0, 1e-12);
|
|
}
|
|
|
|
/// The setter's two acts, and the explicit commit between them: `CursorPlacement::Free` stores
|
|
/// the coordinate verbatim even on a `NearestSample` cursor -- the value a drag writes under the
|
|
/// pointer -- and `snap_cursor()` is what later puts it on the grid. The default is the other
|
|
/// act (snapping now), so a programmatic placement is unchanged; a ruler's commit uses its own
|
|
/// rule, which is what makes the two orientations snap at the same moment of a gesture.
|
|
PLOT_TEST(free_placement_defers_the_snap_until_snap_cursor) {
|
|
plot::data::ArraySeries s(ramp(100), 0.0, 1.0); // samples at x = 0, 1, 2, ...
|
|
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(0xFFFFFF), 1.0f, ax, ay});
|
|
const u32 marker = p.add_cursor(12.0, plot::Color::hex(0xFFB020));
|
|
p.set_cursor_snap(marker, plot::SnapMode::NearestSample);
|
|
PLOT_CHECK_NEAR(p.cursor(marker).x, 12.0, 1e-12);
|
|
|
|
// Free: the value is kept, and it is on no sample of the grid.
|
|
p.set_cursor_x(marker, 12.6, plot::CursorPlacement::Free);
|
|
PLOT_CHECK_NEAR(p.cursor(marker).x, 12.6, 1e-12);
|
|
|
|
// The commit applies the cursor's own rule -- the nearest sample -- exactly once.
|
|
p.snap_cursor(marker);
|
|
PLOT_CHECK_NEAR(p.cursor(marker).x, 13.0, 1e-12);
|
|
|
|
// The default placement snaps at once, so the free act is an opt-in, not a new default.
|
|
p.set_cursor_x(marker, 20.6);
|
|
PLOT_CHECK_NEAR(p.cursor(marker).x, 21.0, 1e-12);
|
|
|
|
// A ruler's commit is its own rule: a Free y is kept, and `snap_cursor()` is a no-op on a
|
|
// `Free` ruler -- there is no sample grid a y coordinate could land on.
|
|
const u32 ruler = p.add_h_ruler(50.0, plot::Color::hex(0x35D0A8));
|
|
p.set_cursor_y(ruler, 33.37, plot::CursorPlacement::Free);
|
|
PLOT_CHECK_NEAR(p.cursor(ruler).y, 33.37, 1e-12);
|
|
p.snap_cursor(ruler);
|
|
PLOT_CHECK_NEAR(p.cursor(ruler).y, 33.37, 1e-12);
|
|
}
|
|
|
|
/// `NearestTick` lands on a tick of the ruler's own y axis, and `Free` stores the value it was
|
|
/// handed -- so the two modes are told apart by what the store holds, not by a flag. The tick set
|
|
/// is the one `layout()` refreshed: ticks are generated there and nowhere else, which is why a
|
|
/// range moved without a layout snaps against the previous set. That one-frame staleness is
|
|
/// pinned here, so a later change cannot quietly reintroduce a second tick computation.
|
|
PLOT_TEST(horizontal_ruler_snaps_to_the_y_ticks_when_asked) {
|
|
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 ay = p.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
|
|
const u32 ruler = p.add_h_ruler(0.0, plot::Color::hex(0x35D0A8));
|
|
p.layout(); // the tick set every snap below reads
|
|
|
|
const std::vector<plot::Tick>& ticks = p.y_axis(ay).ticks;
|
|
PLOT_CHECK(ticks.size() >= 3u);
|
|
// Two *adjacent* ticks, so the value between them is equal to no entry of the list at all --
|
|
// majors and minors alike: a ruler snaps onto the whole set, and no tick lies between two
|
|
// neighbours.
|
|
const f64 lo = ticks[1].value;
|
|
const f64 hi = ticks[2].value;
|
|
PLOT_CHECK(lo < hi);
|
|
const f64 between = lo + 0.6 * (hi - lo); // nearer `hi`, and equal to neither
|
|
PLOT_CHECK(!tick_at(ticks, between));
|
|
|
|
p.set_cursor_h_snap(ruler, plot::HRulerSnap::NearestTick);
|
|
p.set_cursor_y(ruler, between);
|
|
PLOT_CHECK_NEAR(p.cursor(ruler).y, hi, 1e-12); // pulled onto the nearer tick
|
|
PLOT_CHECK(tick_at(ticks, p.cursor(ruler).y));
|
|
|
|
// The same value with `Free` is not moved, and is equal to no tick: the mode is the snap, not
|
|
// the value.
|
|
p.set_cursor_h_snap(ruler, plot::HRulerSnap::Free);
|
|
p.set_cursor_y(ruler, between);
|
|
PLOT_CHECK_NEAR(p.cursor(ruler).y, between, 1e-12);
|
|
PLOT_CHECK(!tick_at(p.y_axis(ay).ticks, p.cursor(ruler).y));
|
|
|
|
// A range moved without a layout leaves the previous tick set in force -- ticks come from
|
|
// `layout()` and these stores do not recompute them -- so a y the axis now *shows* still
|
|
// snaps onto the old set. 550 lies in [500, 600] and the stale set ends at 100, so the
|
|
// snap pulls it clear out of the range the axis is drawing; letting `layout()` refresh the
|
|
// set is what makes the same y land where the axis is labelled.
|
|
const std::vector<plot::Tick> previous = p.y_axis(ay).ticks;
|
|
const f64 stale = previous.back().value;
|
|
PLOT_CHECK(stale < 500.0); // the old set has nothing to offer near 550
|
|
p.set_y_range(ay, {500.0, 600.0});
|
|
p.set_cursor_h_snap(ruler, plot::HRulerSnap::NearestTick);
|
|
p.set_cursor_y(ruler, 550.0);
|
|
PLOT_CHECK_NEAR(p.cursor(ruler).y, stale, 1e-12); // the stale set, not the axis' range
|
|
p.layout();
|
|
p.set_cursor_y(ruler, 550.0);
|
|
PLOT_CHECK(p.cursor(ruler).y >= 500.0 && p.cursor(ruler).y <= 600.0);
|
|
PLOT_CHECK(tick_at(p.y_axis(ay).ticks, p.cursor(ruler).y));
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// --------------------------------------------- live x follow (spec §12)
|
|
//
|
|
// The streaming case: the visible window is not a pair of numbers the caller chose
|
|
// but a *view from the live edge*, recomputed from the series' newest x on every
|
|
// frame. `XFollow::Off` -- the default, and every case above -- is unchanged; these
|
|
// cases are about the mode where following is explicit, and about the two numbers
|
|
// that survive the per-frame recompute: the span, and the offset behind the edge.
|
|
|
|
namespace {
|
|
|
|
/// A streaming plot: one `StreamSeries` on one x axis, 800x400, with `push`
|
|
/// samples already produced and acquired. The axis starts 10 units wide and follows
|
|
/// nothing until a case asks it to, so "Off is the default" is what every case here
|
|
/// starts from.
|
|
struct LiveFixture {
|
|
plot::data::StreamSeries series;
|
|
plot::Plot graph;
|
|
u32 ax = 0;
|
|
u32 ay = 0;
|
|
|
|
explicit LiveFixture(u64 retain, u64 push) : series(retain, 0.0, 1.0) {
|
|
graph.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
|
ax = graph.add_x_axis({"t", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false});
|
|
ay = graph.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
|
graph.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, ax, ay});
|
|
feed(push);
|
|
graph.layout();
|
|
}
|
|
|
|
/// Produces `n` samples and publishes them, the way a frame does.
|
|
void feed(u64 n) {
|
|
for (u64 i = 0; i < n; ++i) series.push(0.0f);
|
|
series.acquire();
|
|
}
|
|
/// Arms following: the mode under test.
|
|
void live() { graph.set_x_follow(ax, plot::XFollow::Live); }
|
|
/// The newest x the series holds -- the live edge.
|
|
f64 newest() const { return series.x_bounds().max; }
|
|
plot::Range xr() const { return graph.x_range(ax); }
|
|
};
|
|
|
|
} // namespace
|
|
|
|
/// The window's right edge *is* the newest sample, every frame, for as long as the
|
|
/// stream runs -- and the span stays the axis' own, because following scrolls the
|
|
/// window rather than rescaling it. The second half is what stops a "tracks the
|
|
/// newest sample" implementation from passing by re-fitting the whole retained
|
|
/// window each frame.
|
|
PLOT_TEST(live_window_tracks_the_newest_sample) {
|
|
LiveFixture f(64, 4);
|
|
f.live();
|
|
const f64 span = f.xr().span();
|
|
PLOT_CHECK_NEAR(f.xr().max, f.newest(), 1e-9);
|
|
for (u64 i = 0; i < 40; ++i) {
|
|
f.feed(3);
|
|
f.graph.layout();
|
|
PLOT_CHECK_NEAR(f.xr().max, f.newest(), 1e-9);
|
|
PLOT_CHECK_NEAR(f.xr().span(), span, 1e-9);
|
|
PLOT_CHECK_NEAR(f.xr().min, f.xr().max - span, 1e-9);
|
|
}
|
|
// The ring has saturated (40 * 3 + 4 > 64) and the window still ends at the newest
|
|
// sample: the sliding retained window does not drag the view with it.
|
|
PLOT_CHECK(f.series.size() == 64u);
|
|
}
|
|
|
|
/// "A plot with no trace on that axis, or a non-streaming series, keeps its stored
|
|
/// range": an array's bounds never slide, so `Live` has no edge to follow and the
|
|
/// axis behaves exactly as an `Off` one -- stored range, `auto_scale` and all.
|
|
PLOT_TEST(live_without_a_streaming_series_keeps_its_stored_range) {
|
|
plot::data::ArraySeries array(ramp(51), 0.0, 1.0); // x = 0 .. 50, fixed
|
|
plot::Plot p;
|
|
p.set_viewport({0, 0, 800, 400});
|
|
const u32 ax = p.add_x_axis({"t", {3.0, 7.0}, false, plot::AxisPlace::Bottom, false});
|
|
const u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
|
const u32 empty = p.add_x_axis({"u", {0.0, 1.0}, false, plot::AxisPlace::Top, false});
|
|
p.add_trace({&array, plot::Color::hex(0x4FA3FF), 1.5f, ax, ay});
|
|
p.set_x_follow(ax, plot::XFollow::Live);
|
|
p.set_x_follow(empty, plot::XFollow::Live); // nothing bound to it at all
|
|
p.layout();
|
|
PLOT_CHECK_NEAR(p.x_range(ax).min, 3.0, 1e-9);
|
|
PLOT_CHECK_NEAR(p.x_range(ax).max, 7.0, 1e-9);
|
|
PLOT_CHECK_NEAR(p.x_range(empty).min, 0.0, 1e-9);
|
|
PLOT_CHECK_NEAR(p.x_range(empty).max, 1.0, 1e-9);
|
|
f64 newest = 0.0;
|
|
PLOT_CHECK(!p.x_live_edge(ax, newest)); // and it says so, rather than inventing one
|
|
|
|
// Auto scale still works on such an axis: the follow path is not involved, so
|
|
// this is an ordinary fit -- the behaviour a static plot has always had.
|
|
p.set_x_auto_scale(ax, true);
|
|
p.layout();
|
|
PLOT_CHECK(p.x_range(ax).min < 0.0);
|
|
PLOT_CHECK(p.x_range(ax).max > 50.0);
|
|
|
|
// ...and a caller-set range is stored verbatim, because there is no live edge to
|
|
// express it relative to.
|
|
p.set_x_range(ax, {-1.0, 2.0});
|
|
p.layout();
|
|
PLOT_CHECK_NEAR(p.x_range(ax).min, -1.0, 1e-9);
|
|
PLOT_CHECK_NEAR(p.x_range(ax).max, 2.0, 1e-9);
|
|
}
|
|
|
|
/// Under `Live` -- with a live edge -- `set_x_range` is a statement about the span
|
|
/// and the offset, not about absolute positions: the window follows from those at
|
|
/// whatever is newest now. This is the reinterpretation every gesture goes through,
|
|
/// so it is pinned directly here rather than only through the controller.
|
|
PLOT_TEST(set_x_range_under_live_is_a_span_and_an_offset) {
|
|
LiveFixture f(200, 20); // newest = 19
|
|
f.live();
|
|
f.graph.set_x_range(f.ax, {f.newest() - 20.0 - 5.0, f.newest() - 5.0});
|
|
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), 5.0, 1e-9);
|
|
PLOT_CHECK_NEAR(f.xr().span(), 20.0, 1e-9);
|
|
PLOT_CHECK_NEAR(f.xr().max, f.newest() - 5.0, 1e-9);
|
|
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live); // and still armed
|
|
|
|
// The absolute numbers are gone: the same offset and span hold after the stream
|
|
// has moved on, which is what a live window means.
|
|
f.feed(30);
|
|
f.graph.layout();
|
|
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), 5.0, 1e-9);
|
|
PLOT_CHECK_NEAR(f.xr().span(), 20.0, 1e-9);
|
|
PLOT_CHECK_NEAR(f.xr().max, f.newest() - 5.0, 1e-9);
|
|
PLOT_CHECK(f.xr().max > f.newest() - 5.0 - 1e-9); // moved with the data
|
|
}
|
|
|
|
/// The offset is clamped against what the traces still hold -- `x_bounds()` for a
|
|
/// stream, i.e. the retained ring -- so the window's right edge can never be put
|
|
/// behind the oldest sample the ring has. Asking for more than that is not an
|
|
/// error: it is the end of the window, and the empty space in front of it is the
|
|
/// honest view of data the ring dropped.
|
|
PLOT_TEST(set_x_offset_clamps_to_the_retained_window) {
|
|
LiveFixture f(64, 64); // saturated: retains x = 0 .. 63
|
|
f.live();
|
|
const plot::Range retained = f.series.x_bounds();
|
|
PLOT_CHECK_NEAR(retained.span(), 63.0, 1e-9);
|
|
|
|
f.graph.set_x_offset(f.ax, 1.0e6);
|
|
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), 63.0, 1e-9); // clamped, not stored
|
|
PLOT_CHECK_NEAR(f.xr().max, retained.min, 1e-9); // the right edge stops at the oldest
|
|
PLOT_CHECK_NEAR(f.xr().span(), 10.0, 1e-9); // ...and the window is still the axis' span
|
|
|
|
// The clamp stays honest as the ring slides: the offset is re-clamped against the
|
|
// window the traces hold *now*, not the one they held when it was set.
|
|
f.feed(20);
|
|
f.graph.layout();
|
|
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), f.series.x_bounds().span(), 1e-9);
|
|
PLOT_CHECK_NEAR(f.xr().max, f.series.x_bounds().min, 1e-9);
|
|
|
|
f.graph.set_x_offset(f.ax, -5.0);
|
|
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), 0.0, 1e-9);
|
|
PLOT_CHECK_NEAR(f.xr().max, f.newest(), 1e-9);
|
|
|
|
// A value that is not a number is refused: the window it would place is not a
|
|
// window, and there is no way back from one a caller could not see.
|
|
f.graph.set_x_offset(f.ax, std::numeric_limits<f64>::quiet_NaN());
|
|
PLOT_CHECK(std::isfinite(f.graph.x_offset(f.ax)));
|
|
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), 0.0, 1e-9);
|
|
}
|
|
|
|
/// `auto_fit` under `Live` is the way *back* to the live edge: the span becomes the
|
|
/// retained window's and the offset returns to zero, so the window ends at the
|
|
/// newest sample -- and following stays armed, because a fit is not how a plot
|
|
/// stops following.
|
|
PLOT_TEST(auto_fit_returns_to_the_live_edge) {
|
|
LiveFixture f(64, 40);
|
|
f.live();
|
|
f.graph.set_x_offset(f.ax, 12.0);
|
|
PLOT_CHECK_NEAR(f.xr().max, f.newest() - 12.0, 1e-9);
|
|
|
|
f.graph.auto_fit_x();
|
|
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), 0.0, 1e-9);
|
|
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
|
|
PLOT_CHECK_NEAR(f.xr().max, f.newest(), 1e-9);
|
|
// The fitted span is the retained window's, with the 2% margin auto-fit has always
|
|
// taken on both ends.
|
|
PLOT_CHECK_NEAR(f.xr().span(), 1.04 * f.series.x_bounds().span(), 1e-9);
|
|
|
|
// And the next frame keeps it at the live edge rather than at the fitted window's
|
|
// absolute position.
|
|
f.feed(25);
|
|
f.graph.layout();
|
|
PLOT_CHECK_NEAR(f.xr().max, f.newest(), 1e-9);
|
|
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), 0.0, 1e-9);
|
|
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
|
|
}
|
|
|
|
/// The mode is opt-in and `Off` is the default, so nothing about an existing caller
|
|
/// changes: the range is stored verbatim, a layout never moves it, and an offset has
|
|
/// no effect on an axis that is not following. The gesture half of "a static plot is
|
|
/// unaffected" is in the controller's cases, where the gestures live.
|
|
PLOT_TEST(a_static_plot_is_unaffected) {
|
|
LiveFixture f(64, 40);
|
|
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Off); // the default
|
|
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), 0.0, 1e-9);
|
|
|
|
f.graph.set_x_range(f.ax, {-3.0, 7.0});
|
|
PLOT_CHECK_NEAR(f.xr().min, -3.0, 1e-9);
|
|
PLOT_CHECK_NEAR(f.xr().max, 7.0, 1e-9); // absolute, exactly the numbers given
|
|
PLOT_CHECK(!f.graph.x_axis(f.ax).auto_scale);
|
|
|
|
// An offset on an axis that is not following changes nothing it draws: it is
|
|
// stored, and applies when following is armed.
|
|
f.graph.set_x_offset(f.ax, 9.0);
|
|
f.feed(30);
|
|
f.graph.layout();
|
|
PLOT_CHECK_NEAR(f.xr().min, -3.0, 1e-9);
|
|
PLOT_CHECK_NEAR(f.xr().max, 7.0, 1e-9);
|
|
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), 9.0, 1e-9);
|
|
f.graph.set_x_follow(f.ax, plot::XFollow::Live);
|
|
PLOT_CHECK_NEAR(f.xr().max, f.newest() - 9.0, 1e-9); // that far behind the live edge
|
|
PLOT_CHECK_NEAR(f.xr().span(), 10.0, 1e-9); // and the span it already had
|
|
|
|
// Turning following off leaves the axis exactly where it is: the last live window
|
|
// becomes an ordinary stored range and stays put as the stream moves on.
|
|
f.graph.set_x_follow(f.ax, plot::XFollow::Off);
|
|
const plot::Range frozen = f.xr();
|
|
f.feed(30);
|
|
f.graph.layout();
|
|
PLOT_CHECK_NEAR(f.xr().min, frozen.min, 1e-9);
|
|
PLOT_CHECK_NEAR(f.xr().max, frozen.max, 1e-9);
|
|
}
|
|
|
|
/// A degenerate range must not be able to strand a live axis. `set_x_range` under
|
|
/// `Live` is read as a span and an offset, and a span that is not a span is not a
|
|
/// window: storing it would leave the axis with a range nothing can place -- every
|
|
/// layout rejects a non-positive span -- and, because every gesture refuses one too,
|
|
/// with a range nothing can move either, while `x_follow()` still said `Live`. So the
|
|
/// one setter that could produce that state refuses to.
|
|
PLOT_TEST(a_degenerate_range_cannot_freeze_a_live_axis) {
|
|
LiveFixture f(200, 40);
|
|
f.live();
|
|
const f64 span = f.xr().span();
|
|
const f64 newest = f.newest();
|
|
|
|
// A zero-width range is ignored: the axis keeps the span and the offset it holds.
|
|
f.graph.set_x_range(f.ax, {newest, newest});
|
|
PLOT_CHECK_NEAR(f.xr().span(), span, 1e-9);
|
|
PLOT_CHECK_NEAR(f.xr().max, newest, 1e-9);
|
|
PLOT_CHECK(f.graph.x_axis(f.ax).range.max > f.graph.x_axis(f.ax).range.min);
|
|
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
|
|
// ...and the stream carries on being followed, which is what the state was for.
|
|
f.feed(10);
|
|
f.graph.layout();
|
|
PLOT_CHECK_NEAR(f.xr().max, f.newest(), 1e-9);
|
|
|
|
// A non-finite endpoint is ignored for the same reason.
|
|
f.graph.set_x_range(f.ax, {f.newest() - 5.0, std::numeric_limits<f64>::quiet_NaN()});
|
|
PLOT_CHECK_NEAR(f.xr().span(), span, 1e-9);
|
|
PLOT_CHECK_NEAR(f.xr().max, f.newest(), 1e-9);
|
|
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
|
|
|
|
// An inverted range is a caller's ordering slip, not a request to break the axis'
|
|
// `min <= max` invariant -- every consumer of a range reads it as one -- so it is
|
|
// normalised, and the window still spans what the two endpoints enclose.
|
|
const f64 edge = f.newest();
|
|
f.graph.set_x_range(f.ax, {edge - 5.0, edge - 15.0});
|
|
PLOT_CHECK_NEAR(f.xr().span(), 10.0, 1e-9);
|
|
PLOT_CHECK_NEAR(f.xr().max, edge - 5.0, 1e-9);
|
|
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), 5.0, 1e-9);
|
|
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
|
|
|
|
// The offset that came with it is the one the next frames keep.
|
|
f.feed(20);
|
|
f.graph.layout();
|
|
PLOT_CHECK_NEAR(f.xr().max, f.newest() - 5.0, 1e-9);
|
|
PLOT_CHECK_NEAR(f.xr().span(), 10.0, 1e-9);
|
|
}
|
|
|
|
// --- regions: exact interval statistics (spec §5) ------------------------------
|
|
|
|
/// A region is an interval a user drags, so it lands *between* samples: the stats
|
|
/// must be over exactly the samples inside the bounds, which is what
|
|
/// `Series::reduce/sum/sumsq` answer for and what the pyramid's `*_buckets` forms,
|
|
/// counting whole buckets, cannot. The grid here is deliberately nothing a caller
|
|
/// could divide by -- `x0` is not 0 and `dx` is not 1 -- and the interval is not
|
|
/// bucket-aligned, so a bucket-grained or off-by-one answer changes the count.
|
|
///
|
|
/// The brute-force scan below applies the rule `RegionStats` documents -- the samples
|
|
/// whose x is at or after `x0` and at or before `x1`, both ends included -- so the two
|
|
/// are comparable at the last bit. Two spikes *outside* the interval are what make the
|
|
/// extremes an assertion about the interval rather than about the series.
|
|
PLOT_TEST(region_stats_match_bruteforce_over_an_unaligned_interval) {
|
|
constexpr f64 kDx = 0.7;
|
|
constexpr f64 kX0 = 3.0;
|
|
constexpr f64 kLo = 10.4;
|
|
constexpr f64 kHi = 47.6;
|
|
constexpr u64 kCount = 53u; // x = 3.0 + 0.7*i, i = 11..63, both ends inside
|
|
|
|
std::vector<f32> ys = ramp(120);
|
|
ys[20] = -100.0f; // inside the interval: the minimum
|
|
ys[40] = 200.0f; // inside the interval: the maximum
|
|
ys[5] = 500.0f; // before it, on a sample
|
|
ys[70] = -300.0f; // after it, on a sample
|
|
const std::vector<f32> samples = ys; // the brute-force copy
|
|
plot::data::ArraySeries s(ys, kX0, kDx); // takes the vector by value
|
|
|
|
plot::Plot p;
|
|
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
|
const u32 ax = p.add_x_axis({"t", s.x_bounds(), false, plot::AxisPlace::Bottom, false});
|
|
const u32 ay = p.add_y_axis({"v", {-200.0, 200.0}, false, plot::AxisPlace::Left, false});
|
|
const u32 tr = p.add_trace({&s, plot::Color::hex(0xFFFFFF), 1.0f, ax, ay});
|
|
|
|
plot::Region reg;
|
|
reg.x0 = kLo;
|
|
reg.x1 = kHi;
|
|
reg.snap = plot::RegionSnap::Free; // stored verbatim: this is the interval under test
|
|
const u32 rid = p.add_region(reg);
|
|
|
|
const plot::RegionStats st = p.region_stats(rid, tr);
|
|
PLOT_CHECK(st.valid);
|
|
PLOT_CHECK(st.count == kCount);
|
|
PLOT_CHECK_NEAR(st.x0, kLo, 1e-12);
|
|
PLOT_CHECK_NEAR(st.x1, kHi, 1e-12);
|
|
PLOT_CHECK_NEAR(st.width, kHi - kLo, 1e-9);
|
|
PLOT_CHECK_NEAR(st.min, -100.0, 1e-9); // the spike inside, never the one outside
|
|
PLOT_CHECK_NEAR(st.max, 200.0, 1e-9);
|
|
|
|
u64 count = 0;
|
|
f64 sum = 0.0, sq = 0.0, lo = 0.0, hi = 0.0;
|
|
for (u64 i = 0; i < samples.size(); ++i) {
|
|
const f64 x = kX0 + kDx * static_cast<f64>(i);
|
|
if (x < kLo || x > kHi) continue;
|
|
const f64 v = static_cast<f64>(samples[static_cast<plot::usize>(i)]);
|
|
if (count == 0u) {
|
|
lo = v;
|
|
hi = v;
|
|
}
|
|
lo = std::min(lo, v);
|
|
hi = std::max(hi, v);
|
|
sum += v;
|
|
sq += v * v;
|
|
++count;
|
|
}
|
|
PLOT_CHECK(count == kCount); // the scan and the query agree on the interval itself
|
|
PLOT_CHECK_NEAR(st.mean, sum / static_cast<f64>(count), 1e-6);
|
|
PLOT_CHECK_NEAR(st.rms, std::sqrt(sq / static_cast<f64>(count)), 1e-6);
|
|
PLOT_CHECK_NEAR(st.min, lo, 1e-9);
|
|
PLOT_CHECK_NEAR(st.max, hi, 1e-9);
|
|
PLOT_CHECK_NEAR(st.integral, sum * kDx, 1e-6); // left-Riemann, the measurement's rule
|
|
|
|
// An interval whose bounds *are* sample x's takes both of those samples: the rule is
|
|
// inclusive at each end, so this is one sample wider at the top than a half-open one.
|
|
plot::Region exact;
|
|
exact.x0 = kX0 + kDx * 11.0; // 10.7
|
|
exact.x1 = kX0 + kDx * 63.0; // 47.1
|
|
exact.snap = plot::RegionSnap::Free;
|
|
const u32 rid_exact = p.add_region(exact);
|
|
PLOT_CHECK(p.region_stats(rid_exact, tr).count == kCount);
|
|
|
|
// Between two adjacent samples there is nothing to average, so the answer is
|
|
// invalid and every field zero rather than a mean of no samples.
|
|
plot::Region gap;
|
|
gap.x0 = 10.71;
|
|
gap.x1 = 10.79;
|
|
gap.snap = plot::RegionSnap::Free;
|
|
const u32 rid_gap = p.add_region(gap);
|
|
const plot::RegionStats none = p.region_stats(rid_gap, tr);
|
|
PLOT_CHECK(!none.valid);
|
|
PLOT_CHECK(none.count == 0u && none.x0 == 0.0 && none.x1 == 0.0 && none.mean == 0.0);
|
|
|
|
// A trace with no series and an id that names no region are invalid too.
|
|
const u32 tr_null = p.add_trace({nullptr, plot::Color::hex(0xFFFFFF), 1.0f, ax, ay});
|
|
PLOT_CHECK(!p.region_stats(rid, tr_null).valid);
|
|
PLOT_CHECK(!p.region_stats(99u, tr).valid);
|
|
|
|
// Moving a region moves the interval the statistics are over.
|
|
p.set_region(rid, 32.0, 47.6);
|
|
PLOT_CHECK(p.region_stats(rid, tr).count == 22u); // x = 32.4 .. 47.1, i = 42..63
|
|
PLOT_CHECK_NEAR(p.region_stats(rid, tr).min, 42.0, 1e-9); // both spikes are outside it now
|
|
PLOT_CHECK_NEAR(p.region_stats(rid, tr).max, 63.0, 1e-9);
|
|
}
|
|
|
|
/// A saturated ring pins `size()` at capacity while `x0()` advances every acquire, so
|
|
/// a region whose own bounds never move still covers a different set of samples each
|
|
/// frame. The cache key includes `x0()` for exactly this: with (series, sample count)
|
|
/// alone the readout would freeze on the first saturated frame, which is the bug the
|
|
/// measurement cache's third key exists to prevent and this statistic must not
|
|
/// reintroduce.
|
|
///
|
|
/// The signal is a ramp in absolute x, so a sample's value names its x: what the
|
|
/// statistics report once the window has slid past a cursor is the current window's
|
|
/// oldest sample, which a frozen cache cannot produce.
|
|
PLOT_TEST(region_stats_track_a_sliding_streaming_window) {
|
|
constexpr u64 kCapacity = 64;
|
|
plot::data::StreamSeries s(kCapacity, 0.0, 1.0);
|
|
for (u64 i = 0; i < kCapacity; ++i) s.push(static_cast<f32>(i)); // y == absolute x
|
|
s.acquire();
|
|
PLOT_CHECK(s.size() == kCapacity); // the published window is full from here on
|
|
|
|
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});
|
|
const u32 tr = p.add_trace({&s, plot::Color::hex(0xFFFFFF), 1.0f, ax, ay});
|
|
|
|
plot::Region reg;
|
|
reg.x0 = 10.0;
|
|
reg.x1 = 60.0;
|
|
reg.snap = plot::RegionSnap::Free; // the region never moves; the window does
|
|
const u32 rid = p.add_region(reg);
|
|
plot::Region behind;
|
|
behind.x0 = 0.0;
|
|
behind.x1 = 5.0;
|
|
behind.snap = plot::RegionSnap::Free;
|
|
const u32 rid_behind = p.add_region(behind);
|
|
|
|
// The returned reference aliases the cached slot the next read updates, so every
|
|
// comparison here keeps a copy of the frame it is about.
|
|
const plot::RegionStats first = p.region_stats(rid, tr);
|
|
PLOT_CHECK(first.valid);
|
|
PLOT_CHECK(first.count == 51u); // absolute x 10..60
|
|
PLOT_CHECK_NEAR(first.min, 10.0, 1e-9);
|
|
PLOT_CHECK_NEAR(first.max, 60.0, 1e-9);
|
|
PLOT_CHECK_NEAR(first.mean, 35.0, 1e-9);
|
|
PLOT_CHECK_NEAR(first.integral, 35.0 * 51.0, 1e-9);
|
|
PLOT_CHECK(p.region_stats(rid_behind, tr).valid); // it is inside the window for now
|
|
|
|
// Slide the window past the region's lower bound: 40 more samples and an acquire,
|
|
// with the sample count unchanged and the region untouched.
|
|
for (u64 i = kCapacity; i < kCapacity + 40u; ++i) s.push(static_cast<f32>(i));
|
|
s.acquire();
|
|
PLOT_CHECK(s.size() == kCapacity); // size() cannot be the cache key
|
|
PLOT_CHECK_NEAR(s.x0(), 40.0, 1e-9); // x0() is what moved under it
|
|
|
|
const plot::RegionStats slid = p.region_stats(rid, tr);
|
|
PLOT_CHECK(slid.valid);
|
|
PLOT_CHECK(slid.count == 21u); // absolute x 40..60, the part the ring still holds
|
|
PLOT_CHECK(slid.count != first.count);
|
|
PLOT_CHECK_NEAR(slid.min, 40.0, 1e-9);
|
|
PLOT_CHECK_NEAR(slid.max, 60.0, 1e-9);
|
|
PLOT_CHECK_NEAR(slid.mean, 50.0, 1e-9);
|
|
PLOT_CHECK_NEAR(slid.integral, 50.0 * 21.0, 1e-9);
|
|
// The window has walked out from under the other region: no sample is left in it.
|
|
PLOT_CHECK(!p.region_stats(rid_behind, tr).valid);
|
|
|
|
// And it keeps tracking, one acquire at a time, with nothing but x0() moving.
|
|
for (u32 i = 0; i < 5u; ++i) {
|
|
s.push(static_cast<f32>(kCapacity + 40u + i));
|
|
s.acquire();
|
|
const plot::RegionStats step = p.region_stats(rid, tr);
|
|
PLOT_CHECK(step.valid);
|
|
PLOT_CHECK(step.count == 20u - i); // absolute x (41 + i)..60
|
|
PLOT_CHECK_NEAR(step.min, 41.0 + static_cast<f64>(i), 1e-9);
|
|
PLOT_CHECK_NEAR(step.mean, 0.5 * (step.min + 60.0), 1e-9);
|
|
}
|
|
}
|
|
|
|
/// Region ids are vector indices, so removing one shifts every id above it -- and the
|
|
/// statistics cache is keyed by that index, so it has to be re-indexed with the vector
|
|
/// rather than left naming the regions it was filled for. A stale key here would answer
|
|
/// for a *different* interval than the one asked about, which a count alone cannot see:
|
|
/// the three intervals below are all 11 samples wide, so these assertions are about the
|
|
/// extremes each id actually names.
|
|
PLOT_TEST(removing_a_region_shifts_the_ids_and_keeps_the_statistics) {
|
|
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});
|
|
const u32 tr = p.add_trace({&s, plot::Color::hex(0xFFFFFF), 1.0f, ax, ay});
|
|
|
|
plot::Region bounds;
|
|
bounds.snap = plot::RegionSnap::Free; // the intervals under test are these numbers
|
|
bounds.x0 = 10.0;
|
|
bounds.x1 = 20.0;
|
|
const u32 a = p.add_region(bounds);
|
|
bounds.x0 = 30.0;
|
|
bounds.x1 = 40.0;
|
|
const u32 b = p.add_region(bounds);
|
|
bounds.x0 = 50.0;
|
|
bounds.x1 = 60.0;
|
|
const u32 c = p.add_region(bounds);
|
|
PLOT_CHECK(a == 0u && b == 1u && c == 2u);
|
|
PLOT_CHECK(p.region_count() == 3u);
|
|
// Filled on demand, in the order the caller asks: three entries, one per pair.
|
|
PLOT_CHECK_NEAR(p.region_stats(a, tr).min, 10.0, 1e-9);
|
|
PLOT_CHECK_NEAR(p.region_stats(b, tr).min, 30.0, 1e-9);
|
|
PLOT_CHECK_NEAR(p.region_stats(c, tr).min, 50.0, 1e-9);
|
|
|
|
p.remove_region(a);
|
|
PLOT_CHECK(p.region_count() == 2u);
|
|
PLOT_CHECK_NEAR(p.region(0).x0, 30.0, 1e-12); // the old region 1
|
|
PLOT_CHECK_NEAR(p.region(1).x0, 50.0, 1e-12); // the old region 2
|
|
// The surviving entries follow their regions down, so each id still answers for its own
|
|
// interval rather than for the one that used to hold that index.
|
|
PLOT_CHECK_NEAR(p.region_stats(0u, tr).min, 30.0, 1e-9);
|
|
PLOT_CHECK_NEAR(p.region_stats(0u, tr).max, 40.0, 1e-9);
|
|
PLOT_CHECK_NEAR(p.region_stats(1u, tr).min, 50.0, 1e-9);
|
|
PLOT_CHECK_NEAR(p.region_stats(1u, tr).max, 60.0, 1e-9);
|
|
// The id the removed region held now names the region that shifted into it -- there is
|
|
// no hole in the vector -- and an id past the end names nothing and reads a default
|
|
// rather than out of range. Neither has any statistics.
|
|
PLOT_CHECK_NEAR(p.region(0u).x0, 30.0, 1e-12);
|
|
PLOT_CHECK_NEAR(p.region(2u).x0, 0.0, 1e-12);
|
|
PLOT_CHECK(!p.region_stats(2u, tr).valid);
|
|
PLOT_CHECK(!p.region_stats(9u, tr).valid);
|
|
}
|
|
|
|
/// A bound that is not a number is not a bound. `std::min`/`std::max` propagate a NaN
|
|
/// through *both* ends of the pair, so accepting one would not merely invert the interval:
|
|
/// it would replace the finite partner too, and a `Free` region stored that way could never
|
|
/// be dragged back -- every motion re-derives both bounds from the stored ones. The other
|
|
/// bound setters in the file refuse a non-finite value outright, and these two write paths
|
|
/// have to as well: `set_region` leaves the region on the interval it already held, and
|
|
/// `add_region`, which does not route through it, adds nothing at all.
|
|
PLOT_TEST(non_finite_region_bounds_are_refused) {
|
|
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});
|
|
const u32 tr = p.add_trace({&s, plot::Color::hex(0xFFFFFF), 1.0f, ax, ay});
|
|
|
|
plot::Region reg;
|
|
reg.x0 = 10.0;
|
|
reg.x1 = 20.0;
|
|
reg.snap = plot::RegionSnap::Free;
|
|
const u32 rid = p.add_region(reg);
|
|
const f64 nan = std::numeric_limits<f64>::quiet_NaN();
|
|
const f64 inf = std::numeric_limits<f64>::infinity();
|
|
|
|
// Each of the four ways a pair can be unusable, and the interval is the same one after
|
|
// every one of them -- both bounds, not just the bound that arrived bad.
|
|
p.set_region(rid, nan, 20.0);
|
|
PLOT_CHECK_NEAR(p.region(rid).x0, 10.0, 1e-12);
|
|
PLOT_CHECK_NEAR(p.region(rid).x1, 20.0, 1e-12);
|
|
p.set_region(rid, 10.0, nan);
|
|
PLOT_CHECK_NEAR(p.region(rid).x0, 10.0, 1e-12);
|
|
PLOT_CHECK_NEAR(p.region(rid).x1, 20.0, 1e-12);
|
|
p.set_region(rid, 30.0, inf);
|
|
PLOT_CHECK_NEAR(p.region(rid).x0, 10.0, 1e-12);
|
|
PLOT_CHECK_NEAR(p.region(rid).x1, 20.0, 1e-12);
|
|
p.set_region(rid, -inf, 5.0);
|
|
PLOT_CHECK_NEAR(p.region(rid).x0, 10.0, 1e-12);
|
|
PLOT_CHECK_NEAR(p.region(rid).x1, 20.0, 1e-12);
|
|
// The interval it still holds is the one its statistics are over.
|
|
PLOT_CHECK(p.region_stats(rid, tr).valid);
|
|
PLOT_CHECK_NEAR(p.region_stats(rid, tr).min, 10.0, 1e-9);
|
|
PLOT_CHECK_NEAR(p.region_stats(rid, tr).max, 20.0, 1e-9);
|
|
|
|
// A region whose bounds are not numbers is refused rather than stored: the count does not
|
|
// move, no id is handed out, and the id that came back names nothing.
|
|
const u32 before = p.region_count();
|
|
plot::Region bad = reg;
|
|
bad.x0 = nan; // the finite partner goes with it, which is why the pair is refused
|
|
const u32 refused = p.add_region(bad);
|
|
PLOT_CHECK(p.region_count() == before);
|
|
PLOT_CHECK(refused != before);
|
|
PLOT_CHECK_NEAR(p.region(refused).x0, 0.0, 1e-12);
|
|
PLOT_CHECK_NEAR(p.region(refused).x1, 0.0, 1e-12);
|
|
PLOT_CHECK(!p.region_stats(refused, tr).valid);
|
|
// The next region a caller adds still takes the next free id, so a refusal leaves the plot
|
|
// exactly as it was rather than a hole a later id would fall into.
|
|
plot::Region good = reg;
|
|
good.x0 = 30.0;
|
|
good.x1 = 40.0;
|
|
PLOT_CHECK(p.add_region(good) == before);
|
|
PLOT_CHECK(p.region_count() == before + 1u);
|
|
PLOT_CHECK_NEAR(p.region(before).x0, 30.0, 1e-12);
|
|
}
|
|
|
|
// ------------------------------------------------- the crosshair (spec §15)
|
|
|
|
namespace {
|
|
|
|
/// A series of hand-computable values -- `y[i] == 10 * i` -- so every interpolation the cases
|
|
/// below assert can be written down as a number rather than recomputed by a formula the
|
|
/// implementation might share with the test.
|
|
std::vector<f32> tens(u64 n) {
|
|
std::vector<f32> y(n);
|
|
for (u64 i = 0; i < n; ++i) y[i] = f32(10.0 * static_cast<f64>(i));
|
|
return y;
|
|
}
|
|
|
|
/// The crosshair's own scene: four `tens` samples on x = 0..3, a [0, 30] y axis, a text
|
|
/// backend to measure the readout's rows with, and a viewport with room to place a panel in.
|
|
struct CrosshairScene {
|
|
plot::render::EmbeddedMonoFont font;
|
|
plot::data::ArraySeries series;
|
|
plot::Plot plot;
|
|
u32 ax = 0;
|
|
u32 ay = 0;
|
|
|
|
CrosshairScene() : series(tens(4), 0.0, 1.0) {
|
|
plot.set_text_backend(&font);
|
|
plot.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
|
ax = plot.add_x_axis({"t", {0.0, 3.0}, false, plot::AxisPlace::Bottom, false});
|
|
ay = plot.add_y_axis({"v", {0.0, 30.0}, false, plot::AxisPlace::Left, false});
|
|
}
|
|
};
|
|
|
|
/// A series that counts what a reader asked it for.
|
|
///
|
|
/// The crosshair's lookup must be O(1) -- index arithmetic and at most two sample reads -- and
|
|
/// "it does not scan" is only observable through the one path a raw sample can be read from:
|
|
/// `Series::copy_raw()`, which `gather()` reaches. This is a `Series` the test owns, so the
|
|
/// count is the test's and not a counter the implementation maintains about itself: long
|
|
/// enough (a quarter of a million samples) that a walk over the data would be unmistakable in
|
|
/// it, whatever path the walk took.
|
|
class CountingSeries final : public plot::data::Series {
|
|
public:
|
|
CountingSeries(u64 n, f64 x0, f64 dx) : samples_(n), x0_(x0), dx_(dx) {
|
|
for (u64 i = 0; i < n; ++i) samples_[i] = f32(i % 100u);
|
|
pyramid_.reserve(n);
|
|
for (const f32 v : samples_) pyramid_.append(v);
|
|
}
|
|
|
|
u64 size() const override { return samples_.size(); }
|
|
f64 x0() const override { return x0_; }
|
|
f64 dx() const override { return dx_; }
|
|
plot::Range x_bounds() const override {
|
|
return {x0_, x0_ + static_cast<f64>(samples_.size() - 1u) * dx_};
|
|
}
|
|
plot::Range y_bounds() const override { return {0.0, 99.0}; }
|
|
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
|
|
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out) const override {
|
|
++gather_calls_;
|
|
samples_read_ += end - begin;
|
|
gather_impl(level, begin, end, out);
|
|
}
|
|
|
|
u64 gather_calls() const noexcept { return gather_calls_; }
|
|
u64 samples_read() const noexcept { return samples_read_; }
|
|
|
|
protected:
|
|
void copy_raw(u64 begin, u64 end, f32* out) const override {
|
|
for (u64 i = begin; i < end && i < samples_.size(); ++i) out[i - begin] = samples_[i];
|
|
}
|
|
|
|
private:
|
|
std::vector<f32> samples_;
|
|
f64 x0_ = 0.0;
|
|
f64 dx_ = 1.0;
|
|
plot::data::MinMaxPyramid pyramid_;
|
|
mutable u64 gather_calls_ = 0;
|
|
mutable u64 samples_read_ = 0;
|
|
};
|
|
|
|
} // namespace
|
|
|
|
/// Spec §15, the lookup: a readout's y is **linear interpolation between the two samples
|
|
/// adjacent to the crosshair's x**, and hand-computed here rather than checked against the
|
|
/// implementation's own arithmetic. The four corners of the rule are all asserted: a query
|
|
/// between two samples, the two exact-sample ends of the window (where the span collapses onto
|
|
/// one sample), and a one-sample series, whose window is that sample alone.
|
|
PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) {
|
|
CrosshairScene scene;
|
|
const u32 trace = scene.plot.add_trace(
|
|
{&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay});
|
|
|
|
// The span itself, for a query halfway between sample 1 (x = 1, y = 10) and sample 2
|
|
// (x = 2, y = 20): the two neighbours, and the fraction between them. Asserting the span and
|
|
// not only the value is what pins the convention -- a nearest-sample lookup would report the
|
|
// span's `t` as 0 or 1, and an average of a *range* would not report a span at all.
|
|
const plot::CrosshairSpan half = plot::crosshair_span(scene.series, 1.5);
|
|
PLOT_CHECK(half.valid);
|
|
PLOT_CHECK(half.begin == 1u);
|
|
PLOT_CHECK(half.end == 2u);
|
|
PLOT_CHECK_NEAR(half.t, 0.5, 1e-12);
|
|
|
|
f64 y = 0.0;
|
|
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.5, y));
|
|
PLOT_CHECK_NEAR(y, 15.0, 1e-9);
|
|
// A quarter of the way through another pair: 20 + (30 - 20) * 0.25.
|
|
PLOT_CHECK(scene.plot.crosshair_value(trace, 2.25, y));
|
|
PLOT_CHECK_NEAR(y, 22.5, 1e-9);
|
|
|
|
// The ends of the window are samples too, so a query exactly on one reads that sample --
|
|
// and reads it *as* a span of one, with nothing to its left or right to interpolate with.
|
|
const plot::CrosshairSpan first = plot::crosshair_span(scene.series, 0.0);
|
|
PLOT_CHECK(first.valid);
|
|
PLOT_CHECK(first.begin == 0u);
|
|
PLOT_CHECK(first.end == 0u);
|
|
PLOT_CHECK(first.t == 0.0);
|
|
PLOT_CHECK(scene.plot.crosshair_value(trace, 0.0, y));
|
|
PLOT_CHECK_NEAR(y, 0.0, 1e-9);
|
|
const plot::CrosshairSpan last = plot::crosshair_span(scene.series, 3.0);
|
|
PLOT_CHECK(last.valid);
|
|
PLOT_CHECK(last.begin == 3u);
|
|
PLOT_CHECK(last.end == 3u);
|
|
PLOT_CHECK(scene.plot.crosshair_value(trace, 3.0, y));
|
|
PLOT_CHECK_NEAR(y, 30.0, 1e-9);
|
|
|
|
// Outside the window there is no value at all -- not the nearest sample's, not the end of
|
|
// the window's, which are the clamped lies the header refuses -- and a series with no
|
|
// samples has no span at any x.
|
|
PLOT_CHECK(!plot::crosshair_span(scene.series, -1e-9).valid);
|
|
PLOT_CHECK(!plot::crosshair_span(scene.series, 3.0 + 1e-9).valid);
|
|
PLOT_CHECK(!scene.plot.crosshair_value(trace, 4.0, y));
|
|
PLOT_CHECK(!scene.plot.crosshair_value(trace, -1.0, y));
|
|
plot::data::ArraySeries empty(std::vector<f32>{}, 0.0, 1.0);
|
|
PLOT_CHECK(!plot::crosshair_span(empty, 0.0).valid);
|
|
const u32 empty_trace = scene.plot.add_trace(
|
|
{&empty, plot::Color::hex(0x00E070), 1.5f, scene.ax, scene.ay, "quiet"});
|
|
PLOT_CHECK(!scene.plot.crosshair_value(empty_trace, 1.0, y));
|
|
PLOT_CHECK(!scene.plot.crosshair_value(/*trace=*/99u, 1.0, y)); // an id that names nothing
|
|
|
|
// A one-sample series: the sample itself is the only query it answers, and it answers it
|
|
// without a neighbour to interpolate against.
|
|
plot::data::ArraySeries single(std::vector<f32>{7.0f}, 5.0, 1.0);
|
|
plot::Plot one;
|
|
one.add_x_axis({"t", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false});
|
|
one.add_y_axis({"v", {0.0, 10.0}, false, plot::AxisPlace::Left, false});
|
|
const u32 single_trace =
|
|
one.add_trace({&single, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
|
|
const plot::CrosshairSpan only = plot::crosshair_span(single, 5.0);
|
|
PLOT_CHECK(only.valid);
|
|
PLOT_CHECK(only.begin == 0u);
|
|
PLOT_CHECK(only.end == 0u);
|
|
PLOT_CHECK(one.crosshair_value(single_trace, 5.0, y));
|
|
PLOT_CHECK_NEAR(y, 7.0, 1e-9);
|
|
PLOT_CHECK(!one.crosshair_value(single_trace, 5.5, y));
|
|
|
|
// The readout's row is the same lookup: the number a user reads off the panel is the value
|
|
// this test computed by hand, not a second derivation of it.
|
|
scene.plot.set_crosshair(1.5, true);
|
|
const plot::CrosshairLayout& crosshair = scene.plot.layout().crosshair;
|
|
PLOT_CHECK(crosshair.active);
|
|
// One row: the sampled trace. The named-but-empty series added above is *not* a row -- a trace
|
|
// with no samples is not a signal under the pointer, so the readout lists nothing for it
|
|
// (a series that has samples but not *at this x* is the other case: a row with no value, which
|
|
// the figure's case pins).
|
|
PLOT_CHECK(crosshair.rows.size() == 1u);
|
|
PLOT_CHECK(crosshair.rows[0].valid);
|
|
PLOT_CHECK_NEAR(crosshair.rows[0].y, 15.0, 1e-9);
|
|
PLOT_CHECK(crosshair.rows[0].value == "15");
|
|
}
|
|
|
|
/// Spec §15, the readout's membership: **every visible trace gets a row, whether or not it is
|
|
/// named** -- the readout is about what is under the pointer, so the legend's rule of skipping
|
|
/// unnamed traces does not apply -- while a trace with no series or no samples is not under the
|
|
/// pointer and is not listed. An unnamed trace's row carries a positional label, so the rows
|
|
/// stay identifiable without a name being invented for it.
|
|
PLOT_TEST(crosshair_readout_lists_every_visible_trace_including_unnamed_ones) {
|
|
CrosshairScene scene;
|
|
plot::data::ArraySeries empty(std::vector<f32>{}, 0.0, 1.0);
|
|
scene.plot.add_trace({&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay,
|
|
"sine"});
|
|
scene.plot.add_trace({&scene.series, plot::Color::hex(0x00E070), 1.5f, scene.ax, scene.ay, ""});
|
|
scene.plot.add_trace({nullptr, plot::Color::hex(0xFF00FF), 1.5f, scene.ax, scene.ay, "pending"});
|
|
scene.plot.add_trace({&empty, plot::Color::hex(0xFFB020), 1.5f, scene.ax, scene.ay, "quiet"});
|
|
scene.plot.set_crosshair(1.5, true);
|
|
|
|
const plot::CrosshairLayout& crosshair = scene.plot.layout().crosshair;
|
|
PLOT_CHECK(crosshair.active);
|
|
PLOT_CHECK(crosshair.rows.size() == 2u);
|
|
// In trace order, with the ids the plot handed out: the named trace and the unnamed one.
|
|
PLOT_CHECK(crosshair.rows[0].trace == 0u);
|
|
PLOT_CHECK(crosshair.rows[0].label == "sine");
|
|
PLOT_CHECK(crosshair.rows[1].trace == 1u);
|
|
// The placeholder is positional and derived from the id alone, so the same trace keeps the
|
|
// same caption frame after frame -- and it is not the legend's behaviour of disappearing.
|
|
PLOT_CHECK(crosshair.rows[1].label == plot::crosshair_unnamed_label(1u));
|
|
PLOT_CHECK(crosshair.rows[1].label == "#1");
|
|
|
|
// Both are listed with the value their own series has at x = 1.5, the swatch, the label's box
|
|
// and the value's box -- all placed by the plot, all inside the panel it measured.
|
|
PLOT_CHECK(crosshair.panel.w > 0.0f && crosshair.panel.h > 0.0f);
|
|
for (const plot::CrosshairRow& row : crosshair.rows) {
|
|
PLOT_CHECK(row.valid);
|
|
PLOT_CHECK(row.listed);
|
|
PLOT_CHECK_NEAR(row.y, 15.0, 1e-9);
|
|
PLOT_CHECK(row.value == "15");
|
|
PLOT_CHECK(row.swatch.x >= crosshair.panel.x);
|
|
PLOT_CHECK(row.swatch.right() <= crosshair.panel.right());
|
|
PLOT_CHECK(row.swatch.y >= crosshair.panel.y);
|
|
PLOT_CHECK(row.swatch.bottom() <= crosshair.panel.bottom());
|
|
PLOT_CHECK(row.label_box.x >= crosshair.panel.x + row.swatch.w);
|
|
PLOT_CHECK(row.label_box.right() <= crosshair.panel.right());
|
|
PLOT_CHECK(row.value_box.w > 0.0f);
|
|
PLOT_CHECK(row.value_box.right() <= crosshair.panel.right());
|
|
PLOT_CHECK(row.value_box.x >= row.label_box.right());
|
|
}
|
|
// The two rows' swatches are their own traces' colours, so a row can be told apart from its
|
|
// neighbour -- and the panel is wide enough for the widest row's label, gutter and value.
|
|
PLOT_CHECK(crosshair.rows[1].swatch.w == crosshair.rows[0].swatch.w);
|
|
PLOT_CHECK(crosshair.rows[1].swatch.y > crosshair.rows[0].swatch.y);
|
|
PLOT_CHECK(crosshair.rows[1].label_box.x == crosshair.rows[0].label_box.x);
|
|
PLOT_CHECK(crosshair.rows[1].value_box.right() == crosshair.rows[0].value_box.right());
|
|
|
|
// Clearing the crosshair empties the whole geometry -- nothing of it is left for a renderer
|
|
// to stage -- and re-placing it at another x moves the panel with the row it lists.
|
|
scene.plot.clear_crosshair();
|
|
const plot::CrosshairLayout& cleared = scene.plot.layout().crosshair;
|
|
PLOT_CHECK(!cleared.active);
|
|
PLOT_CHECK(cleared.rows.empty());
|
|
PLOT_CHECK(cleared.panel.w == 0.0f && cleared.panel.h == 0.0f);
|
|
PLOT_CHECK(cleared.vertical.w == 0.0f && cleared.horizontal.h == 0.0f);
|
|
|
|
// A crosshair whose x is outside every trace's window: the row is still listed -- the trace is
|
|
// one of the signals under the pointer -- and says so with the documented placeholder rather than
|
|
// a blank cell or a fabricated number.
|
|
scene.plot.set_crosshair(10.0, true); // past the last sample of the 4-sample series
|
|
const plot::CrosshairLayout& past_the_end = scene.plot.layout().crosshair;
|
|
PLOT_CHECK(past_the_end.rows.size() == 2u); // the two visible traces, both with no sample here
|
|
PLOT_CHECK(!past_the_end.rows[0].valid);
|
|
PLOT_CHECK(past_the_end.rows[0].value == plot::kCrosshairNoValue);
|
|
PLOT_CHECK(!past_the_end.rows[0].value.empty());
|
|
PLOT_CHECK(past_the_end.rows[0].value_box.w > 0.0f); // the dash has a box of its own
|
|
PLOT_CHECK(past_the_end.rows[0].listed);
|
|
PLOT_CHECK(past_the_end.rows[0].value_box.w <= past_the_end.panel.w);
|
|
|
|
// The readout switch, and the reason it is a switch rather than a field nobody can reach: with it
|
|
// off the crosshair itself is untouched -- the lines are still placed and the trace is still a
|
|
// row -- while nothing is listed in a panel, so a host that wants the pointer's column marked and
|
|
// not a panel over its data can say so. Switching it back on lists the rows again.
|
|
scene.plot.set_crosshair(1.5, true);
|
|
scene.plot.set_crosshair_readout(false);
|
|
const plot::CrosshairLayout& off = scene.plot.layout().crosshair;
|
|
PLOT_CHECK(off.active);
|
|
PLOT_CHECK(off.vertical.w > 0.0f); // the switch is the readout's: the lines are untouched
|
|
PLOT_CHECK(off.rows.size() == 2u); // both traces are still there, with their markers
|
|
for (const plot::CrosshairRow& row : off.rows) {
|
|
PLOT_CHECK(!row.listed); // ... and neither is a line in a panel
|
|
PLOT_CHECK(row.value_box.w == 0.0f);
|
|
PLOT_CHECK(row.marker.x > 0.0f); // the marker is placed either way
|
|
}
|
|
PLOT_CHECK(off.panel.w == 0.0f && off.panel.h == 0.0f);
|
|
scene.plot.set_crosshair_readout(true);
|
|
const plot::CrosshairLayout& on = scene.plot.layout().crosshair;
|
|
PLOT_CHECK(on.rows[0].listed);
|
|
PLOT_CHECK(on.rows[1].listed);
|
|
PLOT_CHECK(on.panel.w > 0.0f && on.panel.h > 0.0f);
|
|
PLOT_CHECK(on.rows[0].value == "15");
|
|
}
|
|
|
|
/// Spec §15, the panel's placement: near the pointer, flipped over it where it would otherwise
|
|
/// cross an edge, and clamped -- so it can never leave `axes`. A panel that did leave would be
|
|
/// drawn over a label strip, which is the defect Task 1 fixed, so all four corners are walked
|
|
/// and every piece of the panel's geometry is asserted against the rect it was measured for.
|
|
PLOT_TEST(crosshair_panel_stays_inside_the_axes_at_all_four_edges) {
|
|
CrosshairScene scene;
|
|
scene.plot.add_trace({&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay,
|
|
"a long signal name"});
|
|
scene.plot.add_trace({&scene.series, plot::Color::hex(0x00E070), 1.5f, scene.ax, scene.ay, ""});
|
|
scene.plot.add_trace({&scene.series, plot::Color::hex(0xFF00FF), 1.5f, scene.ax, scene.ay,
|
|
"third"});
|
|
|
|
const plot::Rect axes = scene.plot.layout().axes;
|
|
const plot::Transform tx = scene.plot.x_transform(scene.ax);
|
|
const plot::Transform ty = scene.plot.y_transform(scene.ay);
|
|
|
|
// A pointer one pixel inside each corner of the data area, as its *data* coordinates -- the
|
|
// form the crosshair takes -- so the four cases are the four edges a panel could fall off.
|
|
const struct {
|
|
const char* name;
|
|
f32 px;
|
|
f32 py;
|
|
} corners[4] = {
|
|
{"top left", axes.x + 1.0f, axes.y + 1.0f},
|
|
{"top right", axes.right() - 1.0f, axes.y + 1.0f},
|
|
{"bottom left", axes.x + 1.0f, axes.bottom() - 1.0f},
|
|
{"bottom right", axes.right() - 1.0f, axes.bottom() - 1.0f},
|
|
};
|
|
|
|
for (const auto& corner : corners) {
|
|
scene.plot.set_crosshair(tx.to_data_x(corner.px), ty.to_data_y(corner.py), true);
|
|
const plot::CrosshairLayout& crosshair = scene.plot.layout().crosshair;
|
|
std::printf("[crosshair] %s: panel %.1f,%.1f %.1fx%.1f in axes %.1f,%.1f %.1fx%.1f\n",
|
|
corner.name, static_cast<double>(crosshair.panel.x),
|
|
static_cast<double>(crosshair.panel.y), static_cast<double>(crosshair.panel.w),
|
|
static_cast<double>(crosshair.panel.h), static_cast<double>(axes.x),
|
|
static_cast<double>(axes.y), static_cast<double>(axes.w),
|
|
static_cast<double>(axes.h));
|
|
// There is a real panel to place, with all three rows listed: the corner is not a case the
|
|
// fit dropped out of.
|
|
PLOT_CHECK(crosshair.panel.w > 0.0f);
|
|
PLOT_CHECK(crosshair.panel.h > 0.0f);
|
|
u32 listed = 0;
|
|
for (const plot::CrosshairRow& row : crosshair.rows) {
|
|
if (row.listed) ++listed;
|
|
}
|
|
PLOT_CHECK(listed == 3u);
|
|
|
|
// Inside the axes, half-open on the far edges: the panel's own rect and every box in it.
|
|
PLOT_CHECK(crosshair.panel.x >= axes.x);
|
|
PLOT_CHECK(crosshair.panel.y >= axes.y);
|
|
PLOT_CHECK(crosshair.panel.right() <= axes.right());
|
|
PLOT_CHECK(crosshair.panel.bottom() <= axes.bottom());
|
|
for (const plot::CrosshairRow& row : crosshair.rows) {
|
|
PLOT_CHECK(row.swatch.x >= axes.x && row.swatch.right() <= axes.right());
|
|
PLOT_CHECK(row.swatch.y >= axes.y && row.swatch.bottom() <= axes.bottom());
|
|
PLOT_CHECK(row.label_box.x >= axes.x && row.label_box.right() <= axes.right());
|
|
PLOT_CHECK(row.value_box.x >= axes.x && row.value_box.right() <= axes.right());
|
|
}
|
|
// The two lines are inside it as well, stroke and all -- which is what leaves the clip
|
|
// nothing to trim but the sub-pixel a fractional edge rasterises outside the rect.
|
|
PLOT_CHECK(crosshair.vertical.x >= axes.x);
|
|
PLOT_CHECK(crosshair.vertical.right() <= axes.right());
|
|
PLOT_CHECK(crosshair.vertical.y >= axes.y);
|
|
PLOT_CHECK(crosshair.vertical.bottom() <= axes.bottom());
|
|
PLOT_CHECK(crosshair.horizontal.x >= axes.x);
|
|
PLOT_CHECK(crosshair.horizontal.right() <= axes.right());
|
|
PLOT_CHECK(crosshair.horizontal.y >= axes.y);
|
|
PLOT_CHECK(crosshair.horizontal.bottom() <= axes.bottom());
|
|
|
|
// Near the pointer, on the side the flip chose: down and to the right of it where there is
|
|
// room, and *over* it to the left or up where there is not -- so the panel is beside the
|
|
// crosshair rather than pinned to a corner whenever the crosshair is in that half.
|
|
const bool flip_x = corner.px > axes.x + 0.5f * axes.w;
|
|
const bool flip_y = corner.py > axes.y + 0.5f * axes.h;
|
|
if (flip_x) {
|
|
PLOT_CHECK(crosshair.panel.right() <= corner.px);
|
|
} else {
|
|
PLOT_CHECK(crosshair.panel.x >= corner.px);
|
|
}
|
|
if (flip_y) {
|
|
PLOT_CHECK(crosshair.panel.bottom() <= corner.py);
|
|
} else {
|
|
PLOT_CHECK(crosshair.panel.y >= corner.py);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Spec §15's cost requirement: the lookup runs on **every pointer move**, so it is index
|
|
/// arithmetic plus at most two sample reads and never a scan. The count is taken from the one
|
|
/// path a raw sample can be read through, over a series long enough that a walk would be
|
|
/// unmistakable -- and the values are asserted too, so a lookup that read nothing and invented
|
|
/// a number cannot pass by being cheap.
|
|
PLOT_TEST(crosshair_lookup_does_not_scan) {
|
|
constexpr u64 kSamples = 250'000u;
|
|
CountingSeries series(kSamples, 0.0, 1.0);
|
|
plot::Plot p;
|
|
p.add_x_axis({"t", {0.0, static_cast<f64>(kSamples - 1u)}, false, plot::AxisPlace::Bottom,
|
|
false});
|
|
p.add_y_axis({"v", {0.0, 99.0}, false, plot::AxisPlace::Left, false});
|
|
const u32 trace = p.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
|
|
|
|
// A query between two samples: 1234 % 100 = 34, the next is 35, halfway between them.
|
|
f64 y = 0.0;
|
|
const u64 before_reads = series.samples_read();
|
|
PLOT_CHECK(p.crosshair_value(trace, 1234.5, y));
|
|
PLOT_CHECK_NEAR(y, 34.5, 1e-9);
|
|
PLOT_CHECK(series.samples_read() - before_reads <= 2u);
|
|
PLOT_CHECK(series.gather_calls() <= 1u);
|
|
|
|
// The same bound at a query on a sample, at the far end of the series, and at both ends of
|
|
// the window: what varies is the index, never the amount of data touched. A lookup that
|
|
// scanned would read `kSamples` samples here, four orders of magnitude past the bound.
|
|
const f64 queries[6] = {0.0, 1.0, 99'999.5, static_cast<f64>(kSamples - 1u),
|
|
static_cast<f64>(kSamples) - 1.5, 987'654.321};
|
|
for (const f64 x : queries) {
|
|
const u64 reads = series.samples_read();
|
|
const u64 calls = series.gather_calls();
|
|
const bool found = p.crosshair_value(trace, x, y);
|
|
if (x <= static_cast<f64>(kSamples - 1u) + 1e-9) {
|
|
PLOT_CHECK(found);
|
|
} else {
|
|
PLOT_CHECK(!found); // past the newest sample: no value, and nothing read for one
|
|
}
|
|
PLOT_CHECK(series.samples_read() - reads <= 2u);
|
|
PLOT_CHECK(series.gather_calls() - calls <= 1u);
|
|
}
|
|
|
|
// A whole readout is a lookup per *visible trace*, not per sample: the process's readout for
|
|
// this one-trace plot stays inside the same bound, which is what keeps a frame's cost
|
|
// proportional to the number of signals rather than to their length.
|
|
p.set_crosshair(1234.5, true);
|
|
const u64 reads = series.samples_read();
|
|
const u64 calls = series.gather_calls();
|
|
const plot::CrosshairLayout& crosshair = p.layout().crosshair;
|
|
PLOT_CHECK(crosshair.rows.size() == 1u);
|
|
PLOT_CHECK(crosshair.rows[0].valid);
|
|
PLOT_CHECK_NEAR(crosshair.rows[0].y, 34.5, 1e-9);
|
|
PLOT_CHECK(series.samples_read() - reads <= 2u);
|
|
PLOT_CHECK(series.gather_calls() - calls <= 1u);
|
|
// And the readout is not vacuous: the series it read two samples of really does hold a
|
|
// quarter of a million of them.
|
|
PLOT_CHECK(series.size() == kSamples);
|
|
}
|