feat(plot)!: harden pipeline, XY series, dash/markers, touch, packaging
CI / debug (tests + all adapters) (push) Canceled after 0s
CI / coverage (floor + measured table) (push) Canceled after 0s
CI / asan (AddressSanitizer) (push) Canceled after 0s

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:
Martino Ferrari committed 2026-09-30 17:32:06 +02:00
1 parent f132a5ec92
commit 38ef459897
77 files changed
+5905 -688

No files matched your search

+76
View File
@@ -786,6 +786,82 @@ PLOT_TEST(a_following_group_advances_all_rows_together) {
}
}
/// B4, on a following *log* group: the group's span and offset live in the shared
/// axis' axis space -- decades -- exactly as a single `Live` axis' do, so the
/// window slides with the newest sample without rescaling. The visible decades
/// stay what was asked for on every row, and a member panned mid-follow restates
/// its scroll state in the same decades rather than in a linear width that would
/// decay as the edge moved.
PLOT_TEST(a_following_log_group_holds_decades) {
plot::data::StreamSeries left(1024u, 1.0, 10.0); // samples at x = 1, 11, 21, ...
plot::data::StreamSeries right(1024u, 1.0, 10.0);
for (int i = 0; i < 30; ++i) left.push(0.5f);
for (int i = 0; i < 20; ++i) right.push(0.5f);
left.acquire();
right.acquire();
plot::Figure figure(2u);
figure.set_viewport(whole_figure());
for (plot::u32 row = 0; row < 2u; ++row) {
plot::Plot& p = figure.plot(row);
p.add_x_axis({"t", {1.0, 1000.0}, false, plot::AxisPlace::Bottom, /*log=*/true});
p.add_y_axis({"v", {0.0, 1.0}, false, plot::AxisPlace::Left, false});
}
figure.plot(0u).add_trace({&left, plot::Color::hex(0x4FA3FF), 1.5f, 0u, 0u});
figure.plot(1u).add_trace({&right, plot::Color::hex(0xFF8080), 1.5f, 0u, 0u});
const plot::u32 group = figure.add_x_link();
PLOT_CHECK(figure.link_x(0u, group));
PLOT_CHECK(figure.link_x(1u, group));
PLOT_CHECK(figure.set_x_link_follow(group, plot::XFollow::Live));
const auto decades_of = [](const plot::Plot& p) {
const plot::Range r = p.x_range(0u);
return std::log10(r.max) - std::log10(r.min);
};
// One decade wide, ending at the fastest row's edge.
const plot::f64 edge0 = left.x_bounds().max;
PLOT_CHECK(figure.set_x_link_range(group, {edge0 / 10.0, edge0}));
figure.layout();
PLOT_CHECK_NEAR(decades_of(figure.plot(0u)), 1.0, 1e-9);
PLOT_CHECK_NEAR(decades_of(figure.plot(1u)), 1.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).max, edge0, 1e-8);
// The streams advance at different rates; the window follows the group's edge --
// the faster row's -- and holds its decade on every row.
for (int step = 1; step <= 8; ++step) {
for (int i = 0; i < 3; ++i) left.push(0.5f);
for (int i = 0; i < 1; ++i) right.push(0.5f);
left.acquire();
right.acquire();
figure.layout();
PLOT_CHECK_NEAR(decades_of(figure.plot(0u)), 1.0, 1e-9);
PLOT_CHECK_NEAR(decades_of(figure.plot(1u)), 1.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).max, left.x_bounds().max, 1e-8);
PLOT_CHECK_NEAR(figure.plot(1u).x_range(0u).max, left.x_bounds().max, 1e-8);
}
// A member panned while the group follows: the adopted scroll state is the
// adopted range's span *in decades* -- two here (edge/1000 to edge/10 is a
// factor of 100) -- and the offset one decade behind the edge, both of which
// survive the next frames' advances.
const plot::f64 edge = left.x_bounds().max;
figure.plot(1u).set_x_range(0u, {edge / 1000.0, edge / 10.0});
figure.layout();
PLOT_CHECK_NEAR(decades_of(figure.plot(0u)), 2.0, 1e-9);
PLOT_CHECK_NEAR(decades_of(figure.plot(1u)), 2.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).max, edge / 10.0, 1e-8);
for (int step = 0; step < 3; ++step) {
left.push(0.5f);
left.acquire();
figure.layout();
PLOT_CHECK_NEAR(decades_of(figure.plot(0u)), 2.0, 1e-9);
PLOT_CHECK_NEAR(decades_of(figure.plot(1u)), 2.0, 1e-9);
}
PLOT_CHECK(figure.x_link_follow(group) == plot::XFollow::Live);
}
// ------------------------------------------------- the crosshair (spec §15)
namespace {