// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari #include "harness.hpp" #include #include #include #include #include #include #include #include namespace { std::vector ramp(u64 n) { std::vector y(n); for (u64 i = 0; i < n; ++i) y[i] = f32(i); return y; } f32 widest_major_label(const plot::Axis& a, const plot::render::TextBackend& t) { f32 w = 0.0f; for (const plot::Tick& tick : a.ticks) { if (!tick.major) continue; const f32 m = t.measure(tick.label.c_str()); if (m > w) w = m; } return w; } /// 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 major_tick_values(const plot::Axis& a) { std::vector 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& 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& ticks = p.x_axis(ax).ticks; PLOT_CHECK(!ticks.empty()); bool saw_minute_label = false; for (const plot::Tick& tick : ticks) { if (!tick.major) continue; PLOT_CHECK(!tick.label.empty()); if (tick.value == 60.0) { PLOT_CHECK(tick.label == "1:00"); // format_time, not format_si saw_minute_label = true; } } PLOT_CHECK(saw_minute_label); } PLOT_TEST(x_transform_maps_range_onto_axes_rect) { plot::Plot p; p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); const u32 ax = p.add_x_axis({"t", {100.0, 200.0}, false, plot::AxisPlace::Bottom, false}); const u32 ay = p.add_y_axis({"v", {-2.0, 2.0}, false, plot::AxisPlace::Left, false}); const plot::Rect a = p.layout().axes; const plot::Transform tx = p.x_transform(ax); const plot::Transform ty = p.y_transform(ay); PLOT_CHECK_NEAR(tx.to_screen_x(100.0), a.x, 1e-9); PLOT_CHECK_NEAR(tx.to_screen_x(200.0), a.right(), 1e-9); PLOT_CHECK_NEAR(ty.to_screen_y(-2.0), a.bottom(), 1e-9); // range min sits at the bottom PLOT_CHECK_NEAR(ty.to_screen_y(2.0), a.y, 1e-9); // range max sits at the top PLOT_CHECK_NEAR(tx.to_data_x(tx.to_screen_x(137.0)), 137.0, 1e-9); PLOT_CHECK_NEAR(ty.to_data_y(ty.to_screen_y(0.5)), 0.5, 1e-9); // An axis index that does not exist must not crash and must stay invertible. PLOT_CHECK_NEAR(p.x_transform(9).to_data_x(p.x_transform(9).to_screen_x(0.25)), 0.25, 1e-9); PLOT_CHECK_NEAR(p.y_transform(9).to_data_y(p.y_transform(9).to_screen_y(0.25)), 0.25, 1e-9); } PLOT_TEST(auto_fit_uses_trace_bounds_with_margin) { std::vector y = {0.0f, 1.0f, 2.0f, 3.0f}; plot::data::ArraySeries s(std::move(y), 0.0, 1.0); plot::Plot p; p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); const u32 ax = p.add_x_axis({"t", {0.0, 1000.0}, true, plot::AxisPlace::Bottom, false}); const u32 ay = p.add_y_axis({"v", {0.0, 1000.0}, true, plot::AxisPlace::Left, false}); p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay}); p.auto_fit_x(); p.auto_fit_y(ay); const plot::Range xr = p.x_range(ax); const plot::Range yr = p.y_range(ay); PLOT_CHECK_NEAR(xr.min, 0.0 - 3.0 * 0.02, 1e-12); // 4 samples span x 0..3, 2% margin PLOT_CHECK_NEAR(xr.max, 3.0 + 3.0 * 0.02, 1e-12); PLOT_CHECK_NEAR(yr.min, 0.0 - 3.0 * 0.02, 1e-12); PLOT_CHECK_NEAR(yr.max, 3.0 + 3.0 * 0.02, 1e-12); // An explicit range pins the axis; a later fit re-enables auto scaling. p.set_y_range(ay, {0.0, 1.0}); PLOT_CHECK(!p.y_axis(ay).auto_scale); p.auto_fit_y(ay); PLOT_CHECK(p.y_axis(ay).auto_scale); PLOT_CHECK_NEAR(p.y_range(ay).max, 3.0 + 3.0 * 0.02, 1e-12); // auto_scale = false survives layout(): the pinned range is not snapped back. p.set_y_range(ay, {0.0, 1.0}); (void)p.layout(); PLOT_CHECK_NEAR(p.y_range(ay).max, 1.0, 1e-12); } PLOT_TEST(layout_refits_axes_left_on_auto_scale) { std::vector y = {0.0f, 1.0f, 2.0f, 3.0f}; plot::data::ArraySeries s(std::move(y), 0.0, 1.0); plot::Plot p; p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); const u32 ax = p.add_x_axis({"t", {0.0, 1000.0}, true, plot::AxisPlace::Bottom, false}); const u32 ay = p.add_y_axis({"v", {0.0, 1000.0}, true, plot::AxisPlace::Left, false}); p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay}); (void)p.layout(); // no explicit fit needed PLOT_CHECK_NEAR(p.x_range(ax).max, 3.0 + 3.0 * 0.02, 1e-12); PLOT_CHECK_NEAR(p.y_range(ay).max, 3.0 + 3.0 * 0.02, 1e-12); // An explicit range wins from then on: layout() must not snap it back. p.set_x_range(ax, {0.0, 1.0}); p.set_y_range(ay, {0.0, 2.0}); (void)p.layout(); PLOT_CHECK_NEAR(p.x_range(ax).max, 1.0, 1e-12); PLOT_CHECK_NEAR(p.y_range(ay).max, 2.0, 1e-12); } PLOT_TEST(auto_fit_handles_degenerate_and_null_series) { std::vector one{5.0f}; plot::data::ArraySeries s(std::move(one), 0.0, 1.0); plot::Plot p; p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); const u32 ax = p.add_x_axis({"t", {0.0, 1.0}, true, plot::AxisPlace::Bottom, false}); const u32 ay = p.add_y_axis({"v", {0.0, 1.0}, true, plot::AxisPlace::Left, false}); p.add_trace({nullptr, plot::Color::hex(0xFFFFFF), 1.0f, ax, ay}); // skipped, not a crash PLOT_CHECK(p.add_trace({&s, plot::Color::hex(0xFFFFFF), 1.0f, ax, ay}) == 1u); PLOT_CHECK(p.trace_count() == 2u); p.auto_fit_x(); p.auto_fit_y(ay); const plot::Range xr = p.x_range(ax); const plot::Range yr = p.y_range(ay); PLOT_CHECK(xr.span() > 0.0); // degenerate {0,0} widened PLOT_CHECK(xr.min < 0.0 && xr.max > 0.0); PLOT_CHECK(yr.span() > 0.0); // degenerate {5,5} widened PLOT_CHECK(yr.min < 5.0 && yr.max > 5.0); (void)p.layout(); // must survive the null trace } PLOT_TEST(measurement_matches_direct_computation_and_tracks_cursor_moves) { plot::data::ArraySeries s(ramp(100), 0.0, 1.0); plot::Plot p; p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); const u32 ax = p.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false}); const u32 ay = p.add_y_axis({"v", {0.0, 99.0}, false, plot::AxisPlace::Left, false}); p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay}); const u32 a = p.add_cursor(10.0, plot::Color::hex(0xFFB020)); const u32 b = p.add_cursor(19.0, plot::Color::hex(0xFFB020)); const u32 m = p.add_measure(a, b); const plot::Measurement& r = p.measurement(m); PLOT_CHECK(r.valid); double sum = 0.0, sq = 0.0; for (u64 i = 10; i <= 19; ++i) { sum += double(i); sq += double(i) * double(i); } PLOT_CHECK_NEAR(r.mean, sum / 10.0, 1e-9); PLOT_CHECK_NEAR(r.rms, std::sqrt(sq / 10.0), 1e-9); PLOT_CHECK_NEAR(r.integral, sum, 1e-9); // dx == 1 PLOT_CHECK_NEAR(r.ymin, 10.0, 1e-9); PLOT_CHECK_NEAR(r.ymax, 19.0, 1e-9); PLOT_CHECK_NEAR(r.dx, 9.0, 1e-9); PLOT_CHECK_NEAR(r.dy, 9.0, 1e-9); PLOT_CHECK_NEAR(r.slope, 1.0, 1e-9); // Moving a cursor the measure depends on must invalidate the cached result. p.set_cursor_x(b, 14.0); const plot::Measurement& r2 = p.measurement(m); PLOT_CHECK(r2.valid); PLOT_CHECK_NEAR(r2.ymax, 14.0, 1e-9); PLOT_CHECK_NEAR(r2.dx, 4.0, 1e-9); } PLOT_TEST(measurement_recomputes_when_the_series_grows) { plot::data::StreamSeries s(1024, 0.0, 1.0); for (u64 i = 0; i < 10; ++i) s.push(f32(i)); s.acquire(); plot::Plot p; p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); const u32 ax = p.add_x_axis({"t", {0.0, 39.0}, false, plot::AxisPlace::Bottom, false}); const u32 ay = p.add_y_axis({"v", {0.0, 39.0}, false, plot::AxisPlace::Left, false}); p.add_trace({&s, plot::Color::hex(0xFFFFFF), 1.0f, ax, ay}); const u32 a = p.add_cursor(2.0, plot::Color::hex(0xFFB020)); const u32 b = p.add_cursor(20.0, plot::Color::hex(0xFFB020)); const u32 m = p.add_measure(a, b); PLOT_CHECK(p.measurement(m).valid); // Only 10 samples exist: x = 20 snaps to the last one, index 9. PLOT_CHECK_NEAR(p.measurement(m).ymax, 9.0, 1e-9); PLOT_CHECK_NEAR(p.measurement(m).dx, 7.0, 1e-9); for (u64 i = 10; i < 40; ++i) s.push(f32(i)); s.acquire(); // Same cursors, longer series: the cached result must be recomputed. PLOT_CHECK(p.measurement(m).valid); PLOT_CHECK_NEAR(p.measurement(m).ymax, 20.0, 1e-9); PLOT_CHECK_NEAR(p.measurement(m).dx, 18.0, 1e-9); } /// 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(i))); s.acquire(); PLOT_CHECK_NEAR(p.measurement(m).ymin, 41.0 + static_cast(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& 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 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 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 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 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 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& xt = p.x_axis(ax).ticks; const std::vector& yt = p.y_axis(ay).ticks; PLOT_CHECK(!xt.empty()); PLOT_CHECK(!yt.empty()); PLOT_CHECK(g.size() == xt.size() + yt.size()); for (const plot::Tick& t : xt) PLOT_CHECK(has_vertical(g, t.value, t.major)); for (const plot::Tick& t : yt) PLOT_CHECK(has_horizontal(g, t.value, t.major)); // Vertical grid lines span the y range, horizontal ones the x range: data // space only, so the renderer's uniform transform can place them. for (const plot::GridLine& l : g.lines()) { if (l.x0 == l.x1) { PLOT_CHECK_NEAR(l.y0, -1.0, 1e-12); PLOT_CHECK_NEAR(l.y1, 1.0, 1e-12); } else { PLOT_CHECK_NEAR(l.y0, l.y1, 1e-12); PLOT_CHECK_NEAR(l.x0, 0.0, 1e-12); PLOT_CHECK_NEAR(l.x1, 10.0, 1e-12); } } u32 minor = 0; for (const plot::GridLine& l : g.lines()) minor += l.major ? 0u : 1u; PLOT_CHECK(minor > 0); // minor grid is generated too } /// 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& xt = p.x_axis(ax).ticks; const std::vector& 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(xt.size()), count_lines(g, true, true), count_lines(g, true, true) + count_lines(g, true, false), static_cast(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 xt; std::vector 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(xt.size()), static_cast(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(x_only_secondary), static_cast(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(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 majors1 = major_tick_values(one.x_axis(ax1)); const std::vector 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::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::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 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 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(i); if (x < kLo || x > kHi) continue; const f64 v = static_cast(samples[static_cast(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(count), 1e-6); PLOT_CHECK_NEAR(st.rms, std::sqrt(sq / static_cast(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(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(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(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(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::quiet_NaN(); const f64 inf = std::numeric_limits::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 tens(u64 n) { std::vector y(n); for (u64 i = 0; i < n; ++i) y[i] = f32(10.0 * static_cast(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(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, f64 x_origin, std::vector& out, plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override { ++gather_calls_; samples_read_ += end - begin; gather_impl(level, begin, end, x_origin, out, mode); } 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 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 **the nearer of the two samples adjacent to the /// crosshair's x**, never a value between them. Every y asserted here is therefore one of the /// series' own `tens` values -- a number *between* two of them, 15 or 22.5, is precisely what /// this refuses, and what it used to return. The corners of the rule are all covered: a query /// either side of the midpoint, a query exactly on it (the tie), the two exact-sample ends of /// the window (where the span collapses onto one sample), and a one-sample series. PLOT_TEST(crosshair_value_is_the_nearest_sample) { 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. The span still reports // both and the fraction between them -- `t` is a *selector* now, not a blend factor, so the // bracketing is unchanged and only what is built from it differs. 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; // Either side of the midpoint reads the sample it is nearer to, and *is* that sample: 10 and // 20 are `tens` values, where an interpolation would have answered 11 and 19. PLOT_CHECK(scene.plot.crosshair_value(trace, 1.1, y)); PLOT_CHECK_NEAR(y, 10.0, 1e-9); PLOT_CHECK(scene.plot.crosshair_value(trace, 1.9, y)); PLOT_CHECK_NEAR(y, 20.0, 1e-9); // Exactly halfway is the tie, and it reads the *earlier* sample -- the bias // `crosshair_span()` already takes for a degenerate pair. 15.0 is the old answer. PLOT_CHECK(scene.plot.crosshair_value(trace, 1.5, y)); PLOT_CHECK_NEAR(y, 10.0, 1e-9); // A quarter of the way through another pair: sample 2, not 22.5. PLOT_CHECK(scene.plot.crosshair_value(trace, 2.25, y)); PLOT_CHECK_NEAR(y, 20.0, 1e-9); // Just short of the next sample is that sample, so the readout steps rather than ramps. PLOT_CHECK(scene.plot.crosshair_value(trace, 2.99, y)); PLOT_CHECK_NEAR(y, 30.0, 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 no neighbour on the far side. 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. The nearest-sample rule is a rule for an x the // series *brackets*; extending it past the ends would answer with the first or last sample for // any x whatever, which is the clamped lie the header refuses. 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{}, 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 // with no second sample to be nearer to. plot::data::ArraySeries single(std::vector{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. x = 1.5 is the tie, so the row // reads sample 1 -- and the formatted cell is `"10"`, where the interpolating readout showed // `"15"`, a number the series does not contain. 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, 10.0, 1e-9); PLOT_CHECK(crosshair.rows[0].value == "10"); } /// The mark is **snapped to the sample the row quotes**, which is what makes it say *which* /// value is being read. Placing it at the pointer's x with the sample's y put it on neither the /// data nor the polyline, and the further a reader zoomed in the further off it sat -- a mark on /// nothing. /// /// The queries here are all **off** a sample on purpose, so "snapped" has something to be wrong /// about: the assertion is that `row.x` is a `tens` grid position (0, 1, 2, 3) and not the query, /// and that `row.marker` is that position mapped through the trace's own axes. The pixel column /// is computed from the transform the plot already exposes, so this checks the two agree rather /// than re-deriving the mapping. PLOT_TEST(the_crosshair_marker_snaps_to_the_sample_it_reports) { CrosshairScene scene; const u32 trace = scene.plot.add_trace( {&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay, "sine"}); // A query between samples 1 and 2, nearer 2: the point is sample 2 entire -- x = 2 as well as // y = 20 -- so the mark lands on the datum and not a fifth of a division to its left. f64 px = 0.0; f64 py = 0.0; PLOT_CHECK(scene.plot.crosshair_point(trace, 1.8, px, py)); PLOT_CHECK_NEAR(px, 2.0, 1e-12); PLOT_CHECK_NEAR(py, 20.0, 1e-9); // The tie takes the earlier sample, and takes *its* x with it: the same bias in both // coordinates, so the mark and the number can never name different samples. PLOT_CHECK(scene.plot.crosshair_point(trace, 1.5, px, py)); PLOT_CHECK_NEAR(px, 1.0, 1e-12); PLOT_CHECK_NEAR(py, 10.0, 1e-9); // `crosshair_value()` is the same call with the x dropped, so the two cannot disagree. f64 y_only = 0.0; PLOT_CHECK(scene.plot.crosshair_value(trace, 1.5, y_only)); PLOT_CHECK_NEAR(y_only, py, 1e-12); // A failing lookup leaves both outputs alone, as the value form does. PLOT_CHECK(!scene.plot.crosshair_point(trace, 9.0, px, py)); PLOT_CHECK_NEAR(px, 1.0, 1e-12); // And through the layout: the row carries the snapped x, and the marker is that x through the // trace's own transform -- not the crosshair's column, which is where it used to be. scene.plot.set_crosshair(1.8, true); scene.plot.layout(); const plot::CrosshairRow& row = scene.plot.layout().crosshair.rows[0]; PLOT_CHECK(row.valid); PLOT_CHECK_NEAR(row.x, 2.0, 1e-12); PLOT_CHECK_NEAR(row.y, 20.0, 1e-9); const f32 sample_col = static_cast(scene.plot.x_transform(scene.ax).to_screen_x(2.0)); const f32 pointer_col = static_cast(scene.plot.x_transform(scene.ax).to_screen_x(1.8)); PLOT_CHECK_NEAR(row.marker.x, sample_col, 1e-3); PLOT_CHECK_NEAR(row.marker.y, static_cast(scene.plot.y_transform(scene.ay).to_screen_y(20.0)), 1e-3); // The two columns are far apart at this scale, so the check above is not satisfied by both // being nearly the same number. PLOT_CHECK(std::abs(row.marker.x - pointer_col) > 10.0f); // The vertical line stays on the *pointer*: it is where the hover is, and only the mark moved. PLOT_CHECK_NEAR(scene.plot.layout().crosshair.vertical.x + 0.5f * scene.plot.layout().crosshair.vertical.w, pointer_col, 1e-3); } /// The other mode, and the reason it exists: `CrosshairValue::Interpolated` answers "what is the /// signal *at* this instant" by blending the two bracketing samples, for a caller whose data is a /// continuous quantity sampled finely enough that the line between two samples is a model it /// believes. `NearestSample` is the default because that line is an artefact of plotting for most /// sources -- see `CrosshairValue` -- but the question is about the data, so the library offers /// both rather than picking for the caller. /// /// Every number here is one the *other* mode refuses, so the two are pinned apart rather than /// merely exercised: 15 at the tie, 11 and 19 either side of it. The marker moves with the value, /// which is the mode's second half -- an interpolated y belongs at the pointer's x, on the /// polyline, and nowhere else. PLOT_TEST(crosshair_interpolated_mode_blends_the_two_samples) { CrosshairScene scene; const u32 trace = scene.plot.add_trace( {&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay, "sine"}); PLOT_CHECK(scene.plot.crosshair_value_mode() == plot::CrosshairValue::NearestSample); scene.plot.set_crosshair_value_mode(plot::CrosshairValue::Interpolated); PLOT_CHECK(scene.plot.crosshair_value_mode() == plot::CrosshairValue::Interpolated); f64 y = 0.0; PLOT_CHECK(scene.plot.crosshair_value(trace, 1.5, y)); PLOT_CHECK_NEAR(y, 15.0, 1e-9); PLOT_CHECK(scene.plot.crosshair_value(trace, 1.1, y)); PLOT_CHECK_NEAR(y, 11.0, 1e-9); PLOT_CHECK(scene.plot.crosshair_value(trace, 1.9, y)); PLOT_CHECK_NEAR(y, 19.0, 1e-9); // A query *on* a sample reads that sample back exactly in this mode too: the span collapses to // one index, `t` is 0, and `crosshair_interpolate()`'s form returns `y_begin` unrounded. PLOT_CHECK(scene.plot.crosshair_value(trace, 2.0, y)); PLOT_CHECK(y == 20.0); PLOT_CHECK(plot::crosshair_interpolate(10.0, 20.0, 0.0) == 10.0); PLOT_CHECK(plot::crosshair_interpolate(10.0, 20.0, 1.0) == 20.0); PLOT_CHECK_NEAR(plot::crosshair_interpolate(10.0, 20.0, 0.25), 12.5, 1e-12); // Out of the window is still no value: interpolating needs two samples to be between. PLOT_CHECK(!scene.plot.crosshair_value(trace, 4.0, y)); // The point is the *query's* x here, not a sample's, so the mark sits on the polyline where the // number is -- the opposite placement from `NearestSample`, and the mode's visible half. f64 px = 0.0; f64 py = 0.0; PLOT_CHECK(scene.plot.crosshair_point(trace, 1.8, px, py)); PLOT_CHECK_NEAR(px, 1.8, 1e-12); PLOT_CHECK_NEAR(py, 18.0, 1e-9); scene.plot.set_crosshair(1.8, true); scene.plot.layout(); const plot::CrosshairRow& row = scene.plot.layout().crosshair.rows[0]; PLOT_CHECK_NEAR(row.x, 1.8, 1e-12); PLOT_CHECK(row.value == "18"); const f32 pointer_col = static_cast(scene.plot.x_transform(scene.ax).to_screen_x(1.8)); PLOT_CHECK_NEAR(row.marker.x, pointer_col, 1e-3); // Switching back is a plain state change, with no second placement to keep in step: the next // layout reports the sample again. scene.plot.set_crosshair_value_mode(plot::CrosshairValue::NearestSample); scene.plot.layout(); PLOT_CHECK_NEAR(scene.plot.layout().crosshair.rows[0].x, 2.0, 1e-12); PLOT_CHECK_NEAR(scene.plot.layout().crosshair.rows[0].y, 20.0, 1e-9); } /// The mark has to be **bigger than a trace's own sample dots**, because in the default mode it is /// snapped onto one of them: at the same size it reads as one more sample rather than as the one /// the panel is quoting. That is a property of the defaults, not of a drawing call, so it is /// asserted where it is decided -- a later edit that tunes `marker_px` up past the crosshair's is /// the regression, and nothing else in the library would notice. PLOT_TEST(the_crosshair_mark_is_bigger_than_a_sample_dot) { const plot::render::Style style; PLOT_CHECK(style.crosshair_marker_px > style.marker_px); // And heavier than the crosshair's own lines, which are chrome and recede on purpose. PLOT_CHECK(style.crosshair_marker_width > style.crosshair_width); } /// 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{}, 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 nearest 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. x = 1.5 // is the tie between samples 1 and 2, which reads the earlier one: 10, not the 15 an // interpolating readout showed. 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, 10.0, 1e-9); PLOT_CHECK(row.value == "10"); 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 == "10"); } /// 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(crosshair.panel.x), static_cast(crosshair.panel.y), static_cast(crosshair.panel.w), static_cast(crosshair.panel.h), static_cast(axes.x), static_cast(axes.y), static_cast(axes.w), static_cast(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(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, and 1234.5 is the tie, which // reads the earlier -- 34, where the interpolating readout answered 34.5. Both samples are // still read: the bound below is on how much data the lookup touches, not on which end it // then reports. 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.0, 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(kSamples - 1u), static_cast(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(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.0, 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); } // --- hardening wave: R1, R2, B4, and the Series-virtual routing ------------- namespace { /// A minimal **XY-like** series: the sample positions are this test's own array, /// not a uniform grid, and only the lookups the plot layer routes through the /// `Series` virtuals -- `x_at`/`neighbours`/`snap_index` -- are overridden. Any /// plot-layer answer that assumed `x0() + i*dx()` is wrong here by construction, /// which is what makes the fixture a regression test for the *routing* rather than /// for the series: the real XY series (Stream A) must behave the same way, but /// these tests stand on their own. class XyFixture final : public plot::data::Series { public: XyFixture(std::vector x, std::vector y) : x_(std::move(x)), y_(std::move(y)) { pyramid_.reserve(y_.size()); for (const f32 v : y_) pyramid_.append(v); } u64 size() const override { return y_.size(); } f64 x0() const override { return x_.empty() ? 0.0 : x_.front(); } f64 dx() const override { return 1.0; } // nominal; the real positions are x_ plot::Range x_bounds() const override { return x_.empty() ? plot::Range{} : plot::Range{x_.front(), x_.back()}; } plot::Range y_bounds() const override { return {0.0, 20.0}; } const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; } void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector& out, plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override { gather_impl(level, begin, end, x_origin, out, mode); } f64 x_at(u64 i) const override { return x_[static_cast(i)]; } bool neighbours(f64 x, u64& i0, u64& i1) const override { if (x_.empty() || x < x_.front() || x > x_.back()) return false; const auto upper = std::lower_bound(x_.begin(), x_.end(), x); const auto first = static_cast(upper - x_.begin()); if (upper != x_.end() && *upper == x) { i0 = i1 = first; // the query landed on a sample return true; } i1 = first; i0 = first == 0u ? 0u : first - 1u; return true; } u64 snap_index(f64 x) const override { if (x_.empty()) return 0u; u64 i0 = 0; u64 i1 = 0; if (!neighbours(x, i0, i1)) { return x < x_.front() ? 0u : static_cast(x_.size() - 1u); } if (i0 == i1) return i0; const f64 before = x - x_at(i0); const f64 after = x_at(i1) - x; return after < before ? i1 : i0; } protected: void copy_raw(u64 begin, u64 end, f32* out) const override { for (u64 i = begin; i < end && i < y_.size(); ++i) out[i - begin] = y_[i]; } private: std::vector x_; std::vector y_; plot::data::MinMaxPyramid pyramid_; }; /// The XY scene: samples at x = 0, 1, 10 (deliberately non-uniform), y = 0, 10, 20, /// and a window wide enough to frame them all. struct XyScene { XyFixture series{{0.0, 1.0, 10.0}, {0.0f, 10.0f, 20.0f}}; plot::Plot plot; u32 ax = 0; u32 ay = 0; XyScene() { plot.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); ax = plot.add_x_axis({"t", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false}); ay = plot.add_y_axis({"v", {0.0, 20.0}, false, plot::AxisPlace::Left, false}); } }; /// A streaming series with decade-wide spacing on a log axis: sample k sits at /// x = 1 + 10k, so feeding samples walks the axis through decades. struct LogLiveFixture { plot::data::StreamSeries series; plot::Plot graph; u32 ax = 0; explicit LogLiveFixture(u64 push) : series(256u, 1.0, 10.0) { graph.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); ax = graph.add_x_axis({"t", {1.0, 1000.0}, false, plot::AxisPlace::Bottom, /*log=*/true}); graph.add_y_axis({"v", {0.0, 1.0}, false, plot::AxisPlace::Left, false}); graph.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, ax, 0u}); feed(push); graph.set_x_follow(ax, plot::XFollow::Live); graph.auto_fit_x(); graph.layout(); } void feed(u64 n) { for (u64 i = 0; i < n; ++i) series.push(0.5f); series.acquire(); } f64 newest() const { return series.x_bounds().max; } /// The visible window in decades -- what a log follow must hold constant. f64 decades() const { const plot::Range r = graph.x_range(ax); return std::log10(r.max) - std::log10(r.min); } }; } // namespace /// R1: the layout clamps are defined even for a hostile viewport. `set_viewport()` /// normalises negative dimensions at the door; the belt-and-braces guard inside /// `layout()` means the two can never drift out of step -- and this pair is what a /// caller that hands the plot a negative rect straight away exercises, in a build /// where the undefined clamp would be visible (asan). PLOT_TEST(layout_survives_a_negative_viewport) { plot::Plot p; p.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false}); p.add_y_axis({"v", {0.0, 1.0}, false, plot::AxisPlace::Left, false}); p.set_viewport({10.0f, 10.0f, -200.0f, -100.0f}); // normalised to 10x10 zero-size const plot::PlotLayout& lay = p.layout(); PLOT_CHECK(lay.axes.w >= 0.0f); PLOT_CHECK(lay.axes.h >= 0.0f); // And a plot laid out at zero size draws nothing but says nothing either: the // same call again on the guarded dimensions stays well-defined. p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); PLOT_CHECK(p.layout().axes.w > 0.0f); } /// R2: the range setters refuse non-finite endpoints. A stored NaN or infinity /// would hand every transform a garbage mapping *and* clear `auto_scale` -- the /// one way back -- so the call is refused outright: the axis keeps the range it /// had and the auto-scale state it had. PLOT_TEST(range_setters_refuse_non_finite_bounds) { 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", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); // A user-chosen range first, so the auto-scale state the refusal has to preserve // is the "cleared by a gesture" one, which is the state a real caller is in. p.set_x_range(ax, {10.0, 20.0}); PLOT_CHECK(!p.x_axis(ax).auto_scale); p.set_x_range(ax, {std::nan(""), 30.0}); PLOT_CHECK_NEAR(p.x_range(ax).min, 10.0, 1e-9); PLOT_CHECK_NEAR(p.x_range(ax).max, 20.0, 1e-9); PLOT_CHECK(!p.x_axis(ax).auto_scale); // kept as it was, neither lost nor re-armed p.set_x_range(ax, {5.0, std::numeric_limits::infinity()}); PLOT_CHECK_NEAR(p.x_range(ax).min, 10.0, 1e-9); PLOT_CHECK_NEAR(p.x_range(ax).max, 20.0, 1e-9); // The y setter's own refusal, with the same preservation both ways. p.set_y_auto_scale(ay, true); p.set_y_range(ay, {std::nan(""), 0.5}); PLOT_CHECK_NEAR(p.y_range(ay).min, -1.0, 1e-9); PLOT_CHECK_NEAR(p.y_range(ay).max, 1.0, 1e-9); PLOT_CHECK(p.y_axis(ay).auto_scale); // still armed: the fit is not lost p.set_y_range(ay, {-1.0, std::numeric_limits::infinity()}); PLOT_CHECK_NEAR(p.y_range(ay).max, 1.0, 1e-9); PLOT_CHECK(p.y_axis(ay).auto_scale); // A finite range still works, and still disarms. p.set_y_range(ay, {-2.0, 2.0}); PLOT_CHECK_NEAR(p.y_range(ay).min, -2.0, 1e-9); PLOT_CHECK(!p.y_axis(ay).auto_scale); } /// B4, on a log axis: the live window's span and offset are *decades*, so new /// samples slide the window without rescaling it -- the visible decades stay /// exactly what the user zoomed to, whatever the live edge does. (A linear span on /// a log axis is the defect: ten units of span at 1e0 is ten decades, at 1e9 it is /// nothing.) PLOT_TEST(live_log_axis_holds_decades_constant) { LogLiveFixture f(5u); // newest = 41 const f64 decades = f.decades(); PLOT_CHECK(decades > 0.5); PLOT_CHECK_NEAR(f.graph.x_range(f.ax).max, f.newest(), 1e-9); for (int i = 0; i < 12; ++i) { f.feed(2u); // newest += 20 per round f.graph.layout(); PLOT_CHECK_NEAR(f.decades(), decades, 1e-9); // the zoom is preserved PLOT_CHECK_NEAR(f.graph.x_range(f.ax).max, f.newest(), 1e-8); // the edge is too PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live); } } /// B4, scrolled back: a window parked behind the live edge keeps its span *in /// decades* as the stream advances, and the distance behind the edge -- the offset, /// also in decades -- is what survives, not an absolute position. PLOT_TEST(scrolled_back_live_window_keeps_its_decades) { LogLiveFixture f(5u); // newest = 41 f.graph.set_x_range(f.ax, {1.0, 10.0}); // one decade wide, behind the edge const f64 decades = f.decades(); PLOT_CHECK_NEAR(decades, 1.0, 1e-9); const f64 offset = f.graph.x_offset(f.ax); PLOT_CHECK(offset > 0.5); // one decade behind the 41 edge, in log10 PLOT_CHECK_NEAR(f.graph.x_range(f.ax).max, 10.0, 1e-9); f.feed(6u); // newest = 101: the edge has moved a whole decade f.graph.layout(); PLOT_CHECK_NEAR(f.decades(), 1.0, 1e-9); // the span the user chose PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), offset, 1e-12); // the scroll the user chose const plot::Range r = f.graph.x_range(f.ax); PLOT_CHECK_NEAR(std::log10(r.max), std::log10(f.newest()) - offset, 1e-9); PLOT_CHECK(r.max < f.newest()); // genuinely parked behind the edge } /// The XY routing, crosshair half: the span brackets the samples the *array* /// names, and `t` is measured on the samples' own positions -- (5-1)/(10-1) over the /// wide gap, where a uniform-grid guess would have read samples that do not exist at /// the positions it assumed. The gap is what makes the value worth asserting too: /// x = 5 sits 4 from sample 1 and 5 from sample 2, so the nearest-sample rule has a /// real choice to get right, and it only has that choice because `t` came from the /// array. PLOT_TEST(xy_series_crosshair_brackets_its_own_samples) { XyScene scene; const u32 trace = scene.plot.add_trace( {&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay}); const plot::CrosshairSpan span = plot::crosshair_span(scene.series, 5.0); PLOT_CHECK(span.valid); PLOT_CHECK(span.begin == 1u); PLOT_CHECK(span.end == 2u); PLOT_CHECK_NEAR(span.t, 4.0 / 9.0, 1e-12); f64 y = 0.0; PLOT_CHECK(scene.plot.crosshair_value(trace, 5.0, y)); PLOT_CHECK_NEAR(y, 10.0, 1e-9); // sample 1, the nearer end of the gap // And past the gap's midpoint it is the other sample, so the choice is `t`'s and not a // fixed preference for one end. PLOT_CHECK(scene.plot.crosshair_value(trace, 6.0, y)); PLOT_CHECK_NEAR(y, 20.0, 1e-9); // On a sample, at both ends of the array's window, and outside it: the same // closed-window rule a uniform series follows, on the array's own coordinates. const plot::CrosshairSpan on = plot::crosshair_span(scene.series, 1.0); PLOT_CHECK(on.valid); PLOT_CHECK(on.begin == 1u); PLOT_CHECK(on.end == 1u); PLOT_CHECK(scene.plot.crosshair_value(trace, 1.0, y)); PLOT_CHECK_NEAR(y, 10.0, 1e-9); PLOT_CHECK(scene.plot.crosshair_value(trace, 10.0, y)); PLOT_CHECK_NEAR(y, 20.0, 1e-9); PLOT_CHECK(!plot::crosshair_span(scene.series, -1e-9).valid); PLOT_CHECK(!plot::crosshair_span(scene.series, 10.0 + 1e-9).valid); PLOT_CHECK(scene.plot.crosshair_value(trace, 5.5, y)); PLOT_CHECK(!scene.plot.crosshair_value(trace, -1.0, y)); } /// The XY routing, measurement half: a cursor snaps to the sample that is really /// nearest -- 5.0 is nearer sample 1 (distance 4) than sample 2 (distance 5), where /// the old uniform-grid index arithmetic named sample 2 -- and the measurement /// covers exactly the samples the snapped pair brackets. PLOT_TEST(xy_series_measurement_uses_the_true_nearest_samples) { XyScene scene; scene.plot.add_trace({&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay}); // Free cursors first: a cursor's stored x is verbatim under the default snap, // and the *measurement* is what maps a cursor x onto samples -- through the // series' own `snap_index()`, so 5.0 measures from the sample that is really // nearest (x = 1, distance 4) and not from where a uniform `dx()` grid guessed. const u32 ca = scene.plot.add_cursor(5.0, plot::Color::hex(0xFFFFFF)); const u32 cb = scene.plot.add_cursor(9.0, plot::Color::hex(0xFFFFFF)); PLOT_CHECK_NEAR(scene.plot.cursor(ca).x, 5.0, 1e-9); // Free: stored verbatim const u32 measure = scene.plot.add_measure(ca, cb); const plot::Measurement& m = scene.plot.measurement(measure); PLOT_CHECK(m.valid); PLOT_CHECK_NEAR(m.ymin, 10.0, 1e-9); PLOT_CHECK_NEAR(m.ymax, 20.0, 1e-9); PLOT_CHECK_NEAR(m.mean, 15.0, 1e-9); // The snap, armed explicitly, lands on the same true nearest sample: 5.0 pulls // onto x = 1 (distance 4 beats 5), 9.0 onto x = 10 (distance 1 beats 9). scene.plot.set_cursor_snap(ca, plot::SnapMode::NearestSample); PLOT_CHECK_NEAR(scene.plot.cursor(ca).x, 1.0, 1e-9); scene.plot.set_cursor_snap(cb, plot::SnapMode::NearestSample); PLOT_CHECK_NEAR(scene.plot.cursor(cb).x, 10.0, 1e-9); // Region coverage is the same routing: [0.5, 5.0] covers exactly the sample at // x = 1 -- the sample at x = 10 is not inside the interval, whatever a `dx()` // grid would have guessed. The region is `Free`: a snapping region would land on // a sample and change the interval under test. const u32 region = scene.plot.add_region({0.5, 5.0, plot::RegionSnap::Free}); const plot::RegionStats& stats = scene.plot.region_stats(region, 0u); PLOT_CHECK(stats.valid); PLOT_CHECK(stats.count == 1u); PLOT_CHECK_NEAR(stats.mean, 10.0, 1e-9); PLOT_CHECK_NEAR(stats.min, 10.0, 1e-9); PLOT_CHECK_NEAR(stats.max, 10.0, 1e-9); } // A host whose annotation is a readout -- the point where a cursor crosses a trace -- // moves it every frame. The id has to survive that, and a stale id must be ignored // rather than read or written out of bounds, exactly as the cursors' setters behave. // A readout cursor says which readout it belongs to with its colour; the setter keeps the // id, and a stale one changes nothing. PLOT_TEST(a_cursor_can_be_recoloured_by_id) { plot::Plot p; const u32 id = p.add_cursor(2.0, plot::Color::hex(0x111111)); PLOT_CHECK(p.cursor(id).color.b == plot::Color::hex(0x111111).b); p.set_cursor_color(id, plot::Color::hex(0x9BE8A0)); PLOT_CHECK(p.cursor(id).color.g == plot::Color::hex(0x9BE8A0).g); PLOT_CHECK(p.cursor_count() == 1u); // recoloured, not added PLOT_CHECK_NEAR(p.cursor(id).x, 2.0, 1e-12); p.set_cursor_color(42u, plot::Color::hex(0xFFFFFF)); PLOT_CHECK(p.cursor(id).color.g == plot::Color::hex(0x9BE8A0).g); } PLOT_TEST(an_annotation_can_be_moved_by_id) { plot::Plot p; const plot::Annotation pin{1.0, 2.0, "first", plot::Color::hex(0xE8E8E8), 0u, 0u, true}; const u32 id = p.add_annotation(pin); PLOT_CHECK(p.annotation(id).x == 1.0); PLOT_CHECK(p.annotation(id).text == std::string("first")); plot::Annotation moved = pin; moved.x = 3.5; moved.y = -1.25; moved.text = "moved"; p.set_annotation(id, moved); PLOT_CHECK_NEAR(p.annotation(id).x, 3.5, 1e-12); PLOT_CHECK_NEAR(p.annotation(id).y, -1.25, 1e-12); PLOT_CHECK(p.annotation(id).text == std::string("moved")); PLOT_CHECK(p.annotation_count() == 1u); // moved, not added // Parked rather than removed, like a cursor: the id stays valid and reads back hidden. plot::Annotation parked = moved; parked.visible = false; p.set_annotation(id, parked); PLOT_CHECK(!p.annotation(id).visible); PLOT_CHECK(p.annotation_count() == 1u); // A stale id changes nothing (and reads a default, as the accessors promise). plot::Annotation other = pin; other.x = 99.0; p.set_annotation(42u, other); PLOT_CHECK_NEAR(p.annotation(id).x, 3.5, 1e-12); } PLOT_TEST(the_selection_frame_is_carried_and_taken_off) { // The box-zoom frame is the host's to set -- the plot has no controller and learns nothing about // pointers -- so what it owes is to carry it: out through `layout()` unchanged, because it is // already in viewport pixels and a second derivation would be a second rounding of the same // rectangle, and gone when the host takes it off, because a frame left behind claims the selection // is still open. plot::Plot p; p.set_viewport({0, 0, 800, 400}); PLOT_CHECK(p.layout().selection.w == 0.0f && p.layout().selection.h == 0.0f); // nothing swept p.set_selection_rect({10.0f, 20.0f, 30.0f, 40.0f}); PLOT_CHECK_NEAR(p.selection_rect().x, 10.0, 1e-6); PLOT_CHECK_NEAR(p.layout().selection.y, 20.0, 1e-6); PLOT_CHECK_NEAR(p.layout().selection.w, 30.0, 1e-6); PLOT_CHECK_NEAR(p.layout().selection.h, 40.0, 1e-6); p.clear_selection_rect(); PLOT_CHECK_NEAR(p.selection_rect().w, 0.0, 1e-6); PLOT_CHECK_NEAR(p.layout().selection.h, 0.0, 1e-6); } // --- phosphor density ------------------------------------------------------- namespace { /// A two-sample flat series: one segment across the whole window at y = 0, which is the /// simplest geometry a density can be read against -- one cell per column and nothing else. std::vector flat_pair() { return std::vector{0.0f, 0.0f}; } /// A plot with one flat trace over a 640x480 viewport, laid out. The axes rect it returns is /// the one `accumulate_density()` sizes the grid to, so a test reads both together. struct DensityFixture { plot::data::ArraySeries series; plot::Plot plot; plot::u32 ax = 0; plot::u32 ay = 0; explicit DensityFixture(std::vector y = flat_pair(), plot::f64 dx = 1.0) : series(std::move(y), 0.0, dx) { plot.set_viewport({0.0f, 0.0f, 640.0f, 480.0f}); ax = plot.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false}); ay = plot.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); PLOT_CHECK(plot.add_trace({&series, plot::Color::hex(0x00E070), 1.5f, ax, ay, "flat"}) == 0u); plot.set_x_range(ax, {0.0, 1.0}); plot.set_y_range(ay, {-1.0, 1.0}); plot.layout(); } }; } // namespace /// The grid is the screen's, not the data's: one cell per device pixel of the axes rect **up to /// `kDensityCellCap`**, and nothing at all until the density is switched on. The figures here are /// the ones the API's memory claim rests on -- `cell_count * 4` bytes, whatever the trigger has /// done and whatever the window is. PLOT_TEST(a_density_grid_is_the_axes_in_device_pixels) { DensityFixture f; const plot::Rect axes = f.plot.layout().axes; // The 640x480 viewport this fixture asks for is past the cap, so its grid is the axes rect // *scaled down* -- the rule the library states, written out here so the assertion is arithmetic // rather than a recorded number. A grid under the cap is the rect itself, which the 320x240 leg // at the end reads. const auto capped = [](plot::u32 w, plot::u32 h) { std::pair out{w, h}; const plot::u64 pixels = static_cast(w) * static_cast(h); if (pixels > static_cast(plot::kDensityCellCap)) { const plot::f64 s = std::sqrt(static_cast(plot::kDensityCellCap) / static_cast(pixels)); out.first = std::max(1u, static_cast(static_cast(w) * s)); out.second = std::max(1u, static_cast(static_cast(h) * s)); } return out; }; const auto [columns, rows] = capped(static_cast(std::ceil(axes.w)), static_cast(std::ceil(axes.h))); // The row a device y lands in is read through the grid's cells-per-*whole*-pixel, which is what // the deposit uses (see `deposit_density_segment()`): 1 while the grid is the rect. const plot::f32 sy = static_cast(rows) / std::ceil(axes.h); PLOT_CHECK(static_cast(columns) * rows <= plot::kDensityCellCap); // Off is the default, and off is *nothing*: no grid, no ink, and an accumulation that // deposits nothing at all. PLOT_CHECK(!f.plot.density().enabled); f.plot.accumulate_density(); PLOT_CHECK(f.plot.density_grid(0u).cell_count() == 0u); PLOT_CHECK(f.plot.density_grid(0u).ink() == 0.0); PLOT_CHECK(f.plot.density_grid(0u).peak() == 0.0f); plot::DensityConfig cfg; cfg.enabled = true; f.plot.set_density(cfg); PLOT_CHECK(f.plot.density().enabled); f.plot.accumulate_density(); const plot::DensityGrid& grid = f.plot.density_grid(0u); PLOT_CHECK(grid.columns() == columns); PLOT_CHECK(grid.rows() == rows); PLOT_CHECK(grid.cell_count() == static_cast(columns) * rows); // `4 bytes * cells`, and never more than the cap: the whole point of the bound. const plot::usize bytes = grid.cell_count() * sizeof(plot::f32); PLOT_CHECK(bytes <= static_cast(plot::kDensityCellCap) * sizeof(plot::f32)); std::printf("[density] grid=%ux%u cells=%zu grid_bytes=%zu ink=%.0f peak=%.0f\n", grid.columns(), grid.rows(), grid.cell_count(), bytes, grid.ink(), static_cast(grid.peak())); // One flat trace over the whole window lights every column exactly once: one segment, no // column reached twice, so the ink *is* the width and the peak is one pass. PLOT_CHECK_NEAR(grid.ink(), static_cast(columns), 1.0); PLOT_CHECK_NEAR(grid.peak(), 1.0, 1e-4); // ... and all of it in one row, the one y = 0 maps to. const plot::Transform ty = f.plot.y_transform(f.ay); const int row = static_cast(std::floor((ty.to_screen_y(0.0) - axes.y) * sy)); plot::u32 lit = 0; for (plot::u32 r = 0; r < grid.rows(); ++r) { for (plot::u32 c = 0; c < grid.columns(); ++c) { if (grid.at(c, r) <= 0.0f) continue; ++lit; PLOT_CHECK(static_cast(r) == row); } } PLOT_CHECK(lit == columns); // The accumulation is a *screen* image: a plot that has moved to another axes rect gets a // grid of that size and nothing carried over from the old one -- and that rect is under the // cap, so the grid is it *exactly*: one cell a device pixel, no scaling at all. f.plot.set_viewport({0.0f, 0.0f, 320.0f, 240.0f}); f.plot.layout(); f.plot.accumulate_density(); const plot::DensityGrid& small = f.plot.density_grid(0u); const plot::Rect small_axes = f.plot.layout().axes; PLOT_CHECK(small.columns() == static_cast(std::ceil(small_axes.w))); PLOT_CHECK(small.rows() == static_cast(std::ceil(small_axes.h))); PLOT_CHECK(small.cell_count() <= plot::kDensityCellCap); PLOT_CHECK(small.columns() < columns); PLOT_CHECK_NEAR(small.ink(), static_cast(small.columns()), 1.0); } /// The cap is what keeps the resolution from following the window: a grid is never more than /// `kDensityCellCap` cells, whatever the axes rect is, so the memory a display holds and the frame /// pass over it are numbers and not functions of how wide the window was dragged. The scaling is /// **uniform**, so a cell stays square and the picture keeps the axes' aspect -- the reading that /// separates this from a cap on one axis, which would stretch every cell by the other one's ratio. PLOT_TEST(a_density_grid_is_capped_whatever_the_window) { DensityFixture f; f.plot.set_viewport({0.0f, 0.0f, 3840.0f, 2160.0f}); f.plot.layout(); plot::DensityConfig cfg; cfg.enabled = true; f.plot.set_density(cfg); f.plot.accumulate_density(); const plot::DensityGrid& grid = f.plot.density_grid(0u); const plot::Rect axes = f.plot.layout().axes; PLOT_CHECK(static_cast(grid.columns()) * grid.rows() <= plot::kDensityCellCap); PLOT_CHECK(grid.cell_count() * sizeof(plot::f32) <= 1'048'576u); // ... and it is as large as the cap allows: flooring a uniform scale costs at most the two // columns and rows the truncation drops, so the grid is within a hair of the whole budget. PLOT_CHECK(static_cast(grid.cell_count()) > 0.99 * static_cast(plot::kDensityCellCap)); // Uniform: the grid's aspect is the axes rect's, to the rounding of the two floors. PLOT_CHECK_NEAR(static_cast(grid.columns()) / grid.rows(), static_cast(axes.w) / axes.h, 0.01); // A cap on the *cells* also returns a full sweep's worth of ink: one flat pass over the window // is one per column however fine or coarse the grid is. PLOT_CHECK_NEAR(grid.ink(), static_cast(grid.columns()), 1.0); PLOT_CHECK_NEAR(grid.peak(), 1.0, 1e-4); std::printf("[density] capped grid=%ux%u cells=%zu bytes=%zu of a %ux%u axes\n", grid.columns(), grid.rows(), grid.cell_count(), grid.cell_count() * sizeof(plot::f32), static_cast(std::ceil(axes.w)), static_cast(std::ceil(axes.h))); } /// Per acquisition the image is multiplied by the factor *before* the new acquisition is /// deposited, so the sweep on screen is the brightest and the one behind it is the factor /// down: the schedule a phosphor's afterglow has, and the reading that says which way round /// the halving goes. PLOT_TEST(a_density_halves_per_acquisition_and_keeps_the_newest_brightest) { DensityFixture f; plot::DensityConfig cfg; cfg.enabled = true; cfg.decay = plot::DensityDecay::PerAcquisition; cfg.per_acquisition = 0.5f; f.plot.set_density(cfg); f.plot.accumulate_density(); // The grid is sized by the first accumulation, so the width is read after it -- and the ink // of one pass *is* the width: one segment across the window, one cell per column. const double width = static_cast(f.plot.density_grid(0u).columns()); const double one = f.plot.density_grid(0u).ink(); PLOT_CHECK_NEAR(one, width, 1.0); f.plot.accumulate_density(); const double two = f.plot.density_grid(0u).ink(); PLOT_CHECK_NEAR(two, width * 1.5, 2.0); PLOT_CHECK_NEAR(f.plot.density_grid(0u).peak(), 1.5f, 1e-3); f.plot.accumulate_density(); const double three = f.plot.density_grid(0u).ink(); PLOT_CHECK_NEAR(three, width * 1.75, 2.0); PLOT_CHECK_NEAR(f.plot.density_grid(0u).peak(), 1.75f, 1e-3); // The steady state of a stream that keeps sweeping: 1 + 1/2 + 1/4 + ... = 2 passes. for (int i = 0; i < 40; ++i) f.plot.accumulate_density(); PLOT_CHECK_NEAR(f.plot.density_grid(0u).peak(), 2.0f, 1e-3); // A factor of 0 is "only the newest acquisition is on screen": the grid is the last pass. cfg.per_acquisition = 0.0f; f.plot.set_density(cfg); f.plot.accumulate_density(); PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), width, 1.0); PLOT_CHECK_NEAR(f.plot.density_grid(0u).peak(), 1.0f, 1e-3); std::printf("[density] per-acquisition ink: 1=%.0f 2=%.0f 3=%.0f peak_steady=%.3f\n", one, two, three, 2.0); } /// The time constant is the other question: the image fades while you watch, by the frame's own /// `dt` against a constant stated in seconds, and it is the *caller* who advances it -- there is /// no clock in the library, which is what makes the reading reproducible. PLOT_TEST(a_density_fades_by_its_time_constant) { DensityFixture f; plot::DensityConfig cfg; cfg.enabled = true; cfg.decay = plot::DensityDecay::TimeConstant; cfg.time_constant_s = 2.0f; f.plot.set_density(cfg); f.plot.accumulate_density(); const double full = f.plot.density_grid(0u).ink(); // A frame that has not been measured yet (dt <= 0) does not wipe the grid, and neither does // a frame under `PerAcquisition`, which fades in `accumulate_density()` instead. f.plot.decay_density(0.0); PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), full, 1e-6); f.plot.decay_density(-1.0); PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), full, 1e-6); // Quarter of a time constant: exp(-0.25). f.plot.decay_density(0.5); const double quarter = full * std::exp(-0.25); PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), quarter, quarter * 1e-4); // Ten more constants and the display is gone to within a rounding: 4.5e-5 of the ink. for (int i = 0; i < 10; ++i) f.plot.decay_density(2.0); PLOT_CHECK(f.plot.density_grid(0u).ink() < quarter * 1e-3); PLOT_CHECK(f.plot.density_grid(0u).peak() < 1e-3f); // A non-positive constant freezes the grid rather than fading it. cfg.time_constant_s = 0.0f; f.plot.set_density(cfg); f.plot.accumulate_density(); const double frozen = f.plot.density_grid(0u).ink(); f.plot.decay_density(100.0); PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), frozen, 1e-6); // And `PerAcquisition` fades in `accumulate_density()` alone: a frame that only passed // changes nothing. cfg.decay = plot::DensityDecay::PerAcquisition; f.plot.set_density(cfg); const double before = f.plot.density_grid(0u).ink(); f.plot.decay_density(1.0); PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), before, 1e-6); std::printf("[density] time-constant ink: full=%.0f quarter=%.1f frozen=%.0f\n", full, quarter, frozen); } /// Switching off drops the image at once -- not at the next acquisition, which a trigger that /// has stopped firing never sends -- and turning it back on starts from an empty grid. PLOT_TEST(switching_a_density_off_drops_the_image_at_once) { DensityFixture f; plot::DensityConfig cfg; cfg.enabled = true; f.plot.set_density(cfg); f.plot.accumulate_density(); PLOT_CHECK(f.plot.density_grid(0u).ink() > 0.0); f.plot.clear_density(); PLOT_CHECK(f.plot.density_grid(0u).ink() == 0.0); PLOT_CHECK(f.plot.density_grid(0u).peak() == 0.0f); // The clear keeps the allocation -- the grid is still the screen's -- so the next // accumulation does not have to reallocate it. PLOT_CHECK(f.plot.density_grid(0u).cell_count() > 0u); f.plot.accumulate_density(); PLOT_CHECK(f.plot.density_grid(0u).ink() > 0.0); cfg.enabled = false; f.plot.set_density(cfg); PLOT_CHECK(f.plot.density_grid(0u).cell_count() == 0u); PLOT_CHECK(f.plot.density_grid(0u).ink() == 0.0); // ... and an accumulation while it is off deposits nothing (it is not a hidden build-up). f.plot.accumulate_density(); PLOT_CHECK(f.plot.density_grid(0u).cell_count() == 0u); } /// What is deposited is where the *trace* is, and nowhere else: a trace off the visible y range /// leaves no ink rather than a band along the edge it left by, and a NaN sample is a gap that /// joins nothing. PLOT_TEST(a_density_deposits_only_where_the_trace_is) { // y = 5 against a [-1, 1] axis: the segment's whole column span is above the axes. DensityFixture off(std::vector{5.0f, 5.0f}); plot::DensityConfig cfg; cfg.enabled = true; off.plot.set_density(cfg); off.plot.accumulate_density(); PLOT_CHECK_NEAR(off.plot.density_grid(0u).ink(), 0.0, 1e-9); // ... and the same series inside the axis deposits one cell per column, in two rows: the // first half at y = -1 and the second at y = +1, joined by a diagonal. DensityFixture split(std::vector{-1.0f, 1.0f}); split.plot.set_density(cfg); split.plot.accumulate_density(); const plot::DensityGrid& grid = split.plot.density_grid(0u); PLOT_CHECK(grid.ink() >= static_cast(grid.columns())); const plot::Transform ty = split.plot.y_transform(split.ay); const plot::Rect axes = split.plot.layout().axes; // The row a device y lands in is the grid's own mapping: the axis rect scaled by the grid's // cells-per-pixel (1 while the rect is under `kDensityCellCap`, less past it). const plot::f32 sy = axes.h > 0.0f ? static_cast(grid.rows()) / std::ceil(axes.h) : 1.0f; // The deposit keeps one ULP of the bottom edge off its row, so `y = -1` -- which maps *onto* // the bottom edge -- lands in the last row rather than one past the end. const auto row_of = [&](plot::f64 y) { return static_cast(std::clamp( static_cast(std::floor((ty.to_screen_y(y) - axes.y) * sy)), 0, static_cast(grid.rows()) - 1)); }; PLOT_CHECK(grid.at(0u, row_of(-1.0)) > 0.0f); PLOT_CHECK(grid.at(grid.columns() - 1u, row_of(1.0)) > 0.0f); // Inverted on screen: y = +1 is the *lower* row index, which is the one y flip of the mapping. PLOT_CHECK(row_of(1.0) < row_of(-1.0)); // A NaN in the middle is a gap: it joins nothing, so the trace is deposited as two runs and // the columns where the gap is stay empty -- where the same series with the sample filled in // lights every one of them. Five samples at x = 0, 0.25, 0.5, 0.75, 1.0. const auto column_ink = [](const plot::DensityGrid& g, plot::u32 column) { double sum = 0.0; for (plot::u32 r = 0; r < g.rows(); ++r) sum += static_cast(g.at(column, r)); return sum; }; DensityFixture whole(std::vector{0.0f, 0.0f, 0.0f, 0.0f, 0.0f}, 0.25); whole.plot.set_density(cfg); whole.plot.accumulate_density(); DensityFixture gap( std::vector{0.0f, 0.0f, std::numeric_limits::quiet_NaN(), 0.0f, 0.0f}, 0.25); gap.plot.set_density(cfg); gap.plot.accumulate_density(); const plot::DensityGrid& whole_grid = whole.plot.density_grid(0u); const plot::DensityGrid& gap_grid = gap.plot.density_grid(0u); const plot::Transform tx = gap.plot.x_transform(gap.ax); const plot::Rect gap_axes = gap.plot.layout().axes; const plot::f32 gsx = gap_axes.w > 0.0f ? static_cast(gap_grid.columns()) / std::ceil(gap_axes.w) : 1.0f; const plot::u32 column = static_cast(std::clamp( static_cast(std::floor((tx.to_screen_x(0.5) - gap_axes.x) * gsx)), 0, static_cast(gap_grid.columns()) - 1)); PLOT_CHECK(column_ink(whole_grid, column) > 0.0); // the sample is there: the column is lit PLOT_CHECK(column_ink(gap_grid, column) == 0.0); // the NaN is a gap: nothing joins across it PLOT_CHECK(gap_grid.ink() < whole_grid.ink()); std::printf("[density] off-range ink=%.0f split ink=%.0f gap ink=%.0f whole ink=%.0f\n", off.plot.density_grid(0u).ink(), grid.ink(), gap_grid.ink(), whole_grid.ink()); } /// **One grid per trace.** A plot of two signals holds two images, each deposited from its own /// trace and drawn in its own colour (the colour is the renderer's, and here the *count* is what is /// read): the two traces at two levels leave ink in two different rows, so one grid holding one /// trace's ink and not the other's is the reading that says the images are not shared. PLOT_TEST(a_density_holds_one_grid_per_trace) { plot::data::ArraySeries lo(std::vector{-0.5f, -0.5f}, 0.0, 1.0); plot::data::ArraySeries hi(std::vector{0.5f, 0.5f}, 0.0, 1.0); plot::Plot p; p.set_viewport({0.0f, 0.0f, 640.0f, 480.0f}); const plot::u32 ax = p.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false}); const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); PLOT_CHECK(p.add_trace({&lo, plot::Color::hex(0x4FA3FF), 1.5f, ax, ay, "lo"}) == 0u); PLOT_CHECK(p.add_trace({&hi, plot::Color::hex(0xFF8A4F), 1.5f, ax, ay, "hi"}) == 1u); p.set_x_range(ax, {0.0, 1.0}); p.set_y_range(ay, {-1.0, 1.0}); p.layout(); plot::DensityConfig cfg; cfg.enabled = true; p.set_density(cfg); p.accumulate_density(); PLOT_CHECK(p.trace_count() == 2u); const plot::DensityGrid& g0 = p.density_grid(0u); const plot::DensityGrid& g1 = p.density_grid(1u); // Both are sized to the one axes rect, and each holds exactly its own trace's one pass: the ink // is the width in each grid, so neither picked up the other trace's ink (two traces would be two // widths in a shared grid). PLOT_CHECK(g0.cell_count() > 0u); PLOT_CHECK(g1.cell_count() == g0.cell_count()); PLOT_CHECK_NEAR(g0.ink(), static_cast(g0.columns()), 1.0); PLOT_CHECK_NEAR(g1.ink(), static_cast(g1.columns()), 1.0); const plot::Transform ty = p.y_transform(ay); const plot::Rect axes = p.layout().axes; const plot::f32 sy = axes.h > 0.0f ? static_cast(g0.rows()) / std::ceil(axes.h) : 1.0f; const auto row_of = [&](plot::f64 y) { return static_cast(std::floor((ty.to_screen_y(y) - axes.y) * sy)); }; const plot::u32 lo_row = row_of(-0.5); const plot::u32 hi_row = row_of(0.5); PLOT_CHECK(lo_row != hi_row); PLOT_CHECK(g0.at(0u, lo_row) > 0.0f); PLOT_CHECK(g1.at(0u, hi_row) > 0.0f); PLOT_CHECK(g0.at(0u, hi_row) == 0.0f); // no trace but `lo` is in the lower grid PLOT_CHECK(g1.at(0u, lo_row) == 0.0f); // An out-of-range trace reads an empty image rather than anything borrowed. PLOT_CHECK(p.density_grid(2u).cell_count() == 0u); PLOT_CHECK(p.density_grid(9u).ink() == 0.0); std::printf("[density] per-trace grids: cells=%zu each ink=%.0f/%.0f rows lo=%u hi=%u\n", g0.cell_count(), g0.ink(), g1.ink(), lo_row, hi_row); }