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
+44
-19
@@ -12,8 +12,12 @@
|
||||
namespace plot::data {
|
||||
namespace {
|
||||
|
||||
/// Samples covered by one level-l bucket.
|
||||
constexpr u64 level_span(u32 l) { return u64{kBucket} << l; }
|
||||
/// Samples covered by one level-l bucket. Clamped to kMaxPyramidLevel: the
|
||||
/// shift past level 57 would overflow u64 (UB), and a bucket wider than any
|
||||
/// u64 sample count is the same bucket as the widest one anyway.
|
||||
constexpr u64 level_span(u32 l) {
|
||||
return u64{kBucket} << (l > kMaxPyramidLevel ? kMaxPyramidLevel : l);
|
||||
}
|
||||
|
||||
/// Level-l buckets needed to hold n samples (ceiling division).
|
||||
constexpr u64 buckets_for(u64 n, u32 l) {
|
||||
@@ -72,11 +76,13 @@ void cover_buckets(const MinMaxPyramid& p, u64 b0, u64 b1, Fn&& fn) {
|
||||
}
|
||||
|
||||
/// Folds the decomposition of [b0, b1) into a min/max Range. Returns a
|
||||
/// zero-span Range when there is nothing to fold.
|
||||
/// zero-span Range when there is nothing to fold. Buckets with no finite
|
||||
/// sample (count 0) are absent, not zero-valued, and are skipped.
|
||||
Range fold_range(const MinMaxPyramid& p, u64 b0, u64 b1) {
|
||||
Range r{};
|
||||
bool first = true;
|
||||
cover_buckets(p, b0, b1, [&](u32 l, u64 i) {
|
||||
if (p.bucket_count(l, i) == 0) return;
|
||||
const MinMax m = p.bucket(l, i);
|
||||
if (first) {
|
||||
r.min = f64(m.min);
|
||||
@@ -96,10 +102,13 @@ void MinMaxPyramid::add_level() { levels_.emplace_back(); }
|
||||
|
||||
void MinMaxPyramid::reserve(u64 n) {
|
||||
// Materialise every level n samples can need -- level 0 plus one per doubling
|
||||
// of the bucket -- so a run of appends never grows the level vector. See the
|
||||
// header for why the bucket deques themselves cannot be pre-sized.
|
||||
// of the bucket, capped at kMaxPyramidLevel -- so a run of appends never grows
|
||||
// the level vector. See the header for why the bucket deques themselves cannot
|
||||
// be pre-sized.
|
||||
u32 want = 1;
|
||||
for (u64 span = level_span(0); span <= n && want < 63; span <<= 1) ++want;
|
||||
for (u64 span = level_span(0); span <= n && want <= kMaxPyramidLevel; span <<= 1) {
|
||||
++want;
|
||||
}
|
||||
if (levels_.size() < want) levels_.resize(want);
|
||||
}
|
||||
|
||||
@@ -115,20 +124,25 @@ void MinMaxPyramid::push(u32 l, u64 k, const Entry& e) {
|
||||
}
|
||||
|
||||
void MinMaxPyramid::append(f32 v) {
|
||||
const f64 vd = f64(v);
|
||||
// The new sample always lands in the in-progress level-0 bucket, which is the
|
||||
// only bucket any append ever touches: a bucket becomes an entry exactly once,
|
||||
// complete, and is never rewritten.
|
||||
if (count_ % kBucket == 0) {
|
||||
pending_.mm.min = v;
|
||||
pending_.mm.max = v;
|
||||
pending_.sum = vd;
|
||||
pending_.sumsq = vd * vd;
|
||||
} else {
|
||||
pending_.mm.min = std::min(pending_.mm.min, v);
|
||||
pending_.mm.max = std::max(pending_.mm.max, v);
|
||||
if (count_ % kBucket == 0) pending_ = Entry{};
|
||||
// Non-finite samples occupy their absolute position -- bucket boundaries are
|
||||
// positional, so NaN or not the sample advances count_ and completes its
|
||||
// bucket -- but they are excluded from every aggregate (see the header).
|
||||
if (std::isfinite(v)) {
|
||||
const f64 vd = f64(v);
|
||||
if (pending_.count == 0) {
|
||||
pending_.mm.min = v;
|
||||
pending_.mm.max = v;
|
||||
} else {
|
||||
pending_.mm.min = std::min(pending_.mm.min, v);
|
||||
pending_.mm.max = std::max(pending_.mm.max, v);
|
||||
}
|
||||
pending_.sum += vd;
|
||||
pending_.sumsq += vd * vd;
|
||||
++pending_.count;
|
||||
}
|
||||
++count_;
|
||||
if (count_ % kBucket != 0) return; // the bucket is still partial: nothing to push
|
||||
@@ -141,7 +155,8 @@ void MinMaxPyramid::append(f32 v) {
|
||||
// Cascade: level l gains an entry exactly when kBucket << l divides the sample
|
||||
// count, so the walk stops at the first level that gained nothing -- amortised
|
||||
// O(1) per sample (1/kBucket + 1/(2*kBucket) + ... = 1/(kBucket/2) merges).
|
||||
for (u32 l = 1; l < 63; ++l) {
|
||||
// Capped at kMaxPyramidLevel: past it the span shift would overflow.
|
||||
for (u32 l = 1; l <= kMaxPyramidLevel; ++l) {
|
||||
const u64 span = level_span(l);
|
||||
if (count_ % span != 0) break;
|
||||
const u64 k = count_ / span - 1; // the level-l bucket that just completed
|
||||
@@ -152,10 +167,14 @@ void MinMaxPyramid::append(f32 v) {
|
||||
const Entry& a = at(l - 1, child);
|
||||
const Entry& b = at(l - 1, child + 1);
|
||||
Entry m;
|
||||
m.mm.min = std::min(a.mm.min, b.mm.min);
|
||||
m.mm.max = std::max(a.mm.max, b.mm.max);
|
||||
// An empty child (count 0) has zero sums and a meaningless mm: it must not
|
||||
// vote 0 into the merge, so a merge with it is just the other child.
|
||||
m.mm = a.count == 0 ? b.mm : (b.count == 0 ? a.mm
|
||||
: MinMax{std::min(a.mm.min, b.mm.min),
|
||||
std::max(a.mm.max, b.mm.max)});
|
||||
m.sum = a.sum + b.sum;
|
||||
m.sumsq = a.sumsq + b.sumsq;
|
||||
m.count = a.count + b.count;
|
||||
push(l, k, m);
|
||||
}
|
||||
}
|
||||
@@ -225,6 +244,12 @@ u64 MinMaxPyramid::level_size(u32 l) const noexcept {
|
||||
|
||||
MinMax MinMaxPyramid::bucket(u32 l, u64 i) const { return at(l, i).mm; }
|
||||
|
||||
f64 MinMaxPyramid::bucket_sum(u32 l, u64 i) const { return at(l, i).sum; }
|
||||
|
||||
f64 MinMaxPyramid::bucket_sumsq(u32 l, u64 i) const { return at(l, i).sumsq; }
|
||||
|
||||
u32 MinMaxPyramid::bucket_count(u32 l, u64 i) const { return at(l, i).count; }
|
||||
|
||||
Range MinMaxPyramid::reduce_buckets(u64 begin, u64 end) const {
|
||||
const u64 e = std::min(end, size());
|
||||
if (begin >= e) return Range{0.0, 0.0};
|
||||
@@ -265,7 +290,7 @@ u32 MinMaxPyramid::level_for(u64 n, f64 samples_per_pixel) const {
|
||||
const f64 ratio = samples_per_pixel / f64(kBucket);
|
||||
if (n == 0 || !(ratio > 1.0)) return 0;
|
||||
const f64 lg = std::floor(std::log2(ratio));
|
||||
u32 l = lg >= f64(62) ? 62u : static_cast<u32>(lg);
|
||||
u32 l = lg >= f64(kMaxPyramidLevel) ? kMaxPyramidLevel : static_cast<u32>(lg);
|
||||
while (l > 0) {
|
||||
const bool exists = l < level_count() && level_size(l) >= 2;
|
||||
if (exists && buckets_for(n, l) >= 2) break;
|
||||
|
||||
Reference in new issue
Block a user