Files
plotter/tests/test_plot.cpp
T
Martino Ferrari f132a5ec92 test(plot): pin the hot-handle re-index on region removal, refresh coverage
`remove_region()` now clears or decrements the published `hot_.region` with the
cache it re-indexes, so a frame after a removal highlights the region the handle
actually names. The new case covers both branches (a handle above the hole
follows it down, the removed region's handle is cleared with its zone), and the
coverage document is regenerated by the command that measured it: 95.2% of src/,
every file at or above 80%.
2026-09-28 22:42:56 +02:00

2297 lines
110 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#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);
}
/// The published hot handles name a region by the same index the vector does, so a
/// removal has to re-index them with the rest: a frame drawn before the next pointer event
/// reads `hot_handles()`, and one left naming the old index would highlight the wrong
/// region's bound -- and an open drag would edit a different region. The handle on the
/// removed region is cleared with its zone; one naming a region above the hole follows it
/// down.
PLOT_TEST(removing_a_region_reindexes_the_published_hot_handles) {
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
p.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"v", {0.0, 99.0}, false, plot::AxisPlace::Left, false});
plot::Region bounds;
bounds.snap = plot::RegionSnap::Free;
PLOT_CHECK(p.add_region(bounds) == 0u);
PLOT_CHECK(p.add_region(bounds) == 1u);
PLOT_CHECK(p.add_region(bounds) == 2u);
// A handle on the last region, with the hole below it: it follows the region down.
plot::HotHandles hot;
hot.region = 2u;
hot.zone = plot::RegionGrab::Right;
p.set_hot_handles(hot);
p.remove_region(0u);
PLOT_CHECK(p.hot_handles().region == 1u);
PLOT_CHECK(p.hot_handles().zone == plot::RegionGrab::Right);
// A handle on the region that is removed: it is cleared, zone and all.
hot.region = 0u;
hot.zone = plot::RegionGrab::Left;
p.set_hot_handles(hot);
p.remove_region(0u);
PLOT_CHECK(p.hot_handles().region == plot::kInvalid);
PLOT_CHECK(p.hot_handles().zone == plot::RegionGrab::None);
}
/// 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);
}