feat(plot)!: harden pipeline, XY series, dash/markers, touch, packaging
Breaking: Series::gather emits x relative to x0() and takes a GatherMode; consumers reconstruct absolute x in f64 before mapping (series.hpp documents the contract). Bugs, each pinned by a fail-before regression test: - render: f32 staging quantised epoch-time x into 128 px columns; every data-space draw now stages to_space(x) - to_space(origin) with the origin folded into the offset uniform in f64 (epoch/zero-origin twins rasterise identically) - ticks: major test was ULP-fragile and blanked epoch axes; majors are an integer-index test and are never truncated by the minor cap - gather: LOD envelope emitted buckets straddling [begin,end); ceil/floor bucket edges + raw lead/trail spans - live follow: log x axes held a linear span and zoomed every frame; span and offset now live in axis space (plot + both figure link-group paths) - input: a zero-notch wheel disarmed auto_scale on every axis; dy==0 pans x, factor == 1 / inf refused in wheel and drag zoom - render: the single text flush painted legend names over the crosshair readout panel; two batches, under flushed before the panel - data: NaN dropped from min/max unless it seeded the fold; non-finite samples are now excluded from every aggregate and from cached bounds Robustness: viewport normalisation (no clamp UB), non-finite range refusal, pyramid level cap at 57 (no shift/div UB through public gather), rounded ring capacity documented + pre-sized, GL blend func and UNPACK_ALIGNMENT save/restore, Renderer::release_gl() teardown, u64 buffer epochs, annotation captions clamped, button-aware GLFW row capture, cached figure row rects (no per-event layout), ruler-pair measurement refused, span-nonfinite guards in axis_ends. Features: XY series (sorted x, binary-search neighbours/snap, LOD over sample index); StreamSeries::freeze(); ArraySeries::push(); per-trace dash and markers (scatter at width 0) with auto or literal stride; GatherMode Envelope/Nearest/Mean via bucket_sum/sumsq; Style::line_aa_px; per-glyph text colour (readout values in trace colour); wheel-dx x pan; arrow-key pan; Escape cancels a drag and restores captured ranges; modifier-masked box zoom (Shift both, Ctrl x, Alt y); double-click reset via PointerEvent::time_s; ImGui key repeat unified; pinch gestures on Qt and GTK; pixelDelta wheel path and DevicePixelRatioChange handling; GNUInstallDirs, SOVERSION, SameMinorVersion, generated plot/version.hpp, relocatable plot.pc, licence install, FILES_MATCHING, GitHub Actions CI (debug/coverage/asan). Suite 283 -> 350 cases, 0 failed; coverage 93.9% (3730/3971) over 24 files, floor 80/90 held; ASAN preset suite green; all four demos run headless (the GTK scripted-resize check needs a window manager and fails identically on the parent commit); readme_main.cpp committed and compiles with the README command; pkg-config and find_package consumption verified from a relocated install prefix.
This commit is contained in:
1 parent
f132a5ec92
commit
38ef459897
77 files changed
+5905
-688
No files matched your search
+292
-2
@@ -1939,10 +1939,11 @@ class CountingSeries final : public plot::data::Series {
|
||||
}
|
||||
plot::Range y_bounds() const override { return {0.0, 99.0}; }
|
||||
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
|
||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out) const override {
|
||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out,
|
||||
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override {
|
||||
++gather_calls_;
|
||||
samples_read_ += end - begin;
|
||||
gather_impl(level, begin, end, out);
|
||||
gather_impl(level, begin, end, out, mode);
|
||||
}
|
||||
|
||||
u64 gather_calls() const noexcept { return gather_calls_; }
|
||||
@@ -2294,3 +2295,292 @@ PLOT_TEST(crosshair_lookup_does_not_scan) {
|
||||
// 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<f64> x, std::vector<f32> 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, std::vector<f32>& out,
|
||||
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override {
|
||||
gather_impl(level, begin, end, out, mode);
|
||||
}
|
||||
|
||||
f64 x_at(u64 i) const override { return x_[static_cast<std::size_t>(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<u64>(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<u64>(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<f64> x_;
|
||||
std::vector<f32> 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<f64>::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<f64>::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 the interpolation fraction 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.
|
||||
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 + 10.0 * (4.0 / 9.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);
|
||||
}
|
||||
Reference in new issue
Block a user