// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari #pragma once #include #include #include #include namespace udpscope { using plot::f32; using plot::f64; using plot::u32; using plot::u64; /// One captured trigger event, immutable once published: per-channel values over /// a shared, edge-relative time stamp array (the edge sits at exactly x = 0, the /// window spans [-pre, +post]). /// /// The plot binds to a snapshot while the trigger holds an event. Because the /// snapshot can never change after publication, nothing can drag, re-base or /// refresh the view while it is held -- the failures the previous design (two /// live ring buffers switched by a pending flag, plus a display-epoch shift) /// could not stop. struct EventSnapshot { std::vector stamps; ///< edge-relative seconds, monotonic std::vector> values; ///< one array per channel, same length double pre_s = 0.0; ///< seconds of pre-trigger data kept double post_s = 0.0; ///< seconds of post-trigger data kept std::vector y; ///< per-channel value bounds, precomputed }; /// A plot::data::Series view of one channel of an EventSnapshot. The stamps are /// near-uniform (the arrival clock), so dx is the mean spacing and x_at returns /// the exact stamp; gather decimates by 2^level like the ring series does. class EventSeries final : public plot::data::Series { public: explicit EventSeries(std::shared_ptr snap, std::size_t channel) : snap_(std::move(snap)), channel_(channel) {} /// Identity for rebind: same snapshot object and channel means the existing /// EventSeries already shows this event. bool same(const std::shared_ptr& snap, std::size_t channel) const { return snap_ == snap && channel_ == channel; } /// The shorter of the two arrays this series reads. `gather()` indexes `stamps` /// and `values[channel]` at the same i, so reporting the stamp count alone would /// be an out-of-bounds read the moment they differ. u64 size() const override { if (channel_ >= snap_->values.size()) return 0; return static_cast( std::min(snap_->stamps.size(), snap_->values[channel_].size())); } f64 x0() const override { return snap_->stamps.empty() ? 0.0 : snap_->stamps.front(); } f64 dx() const override { const auto& s = snap_->stamps; return s.size() > 1 ? (s.back() - s.front()) / static_cast(s.size() - 1) : 1.0; } f64 x_at(u64 i) const override { const auto& s = snap_->stamps; return i < s.size() ? s[static_cast(i)] : 0.0; } plot::Range x_bounds() const override { const auto& s = snap_->stamps; return s.empty() ? plot::Range{0.0, 0.0} : plot::Range{s.front(), s.back()}; } plot::Range y_bounds() const override { return channel_ < snap_->y.size() ? snap_->y[channel_] : plot::Range{0.0, 0.0}; } const plot::data::MinMaxPyramid& pyramid() const override { return empty_pyramid_; } bool streaming() const noexcept override { return false; } void gather(u32 level, u64 begin, u64 end, std::vector& out_xy, plot::data::GatherMode /*mode*/ = plot::data::GatherMode::Envelope) const override { if (channel_ >= snap_->values.size()) return; const auto& vals = snap_->values[channel_]; const auto& stamps = snap_->stamps; const u64 n = size(); const u64 stride = u64{1} << level; if (stride == 0 || begin >= n) return; if (end > n) end = n; // x **relative to x0()**, which is the library's gather contract: the renderer // adds `x0()` back before mapping to the screen. Emitting the absolute stamp made // it add x0 twice, so the whole capture was drawn one `x0()` -- a full // pre-trigger window -- to the left of the axis it had just been measured // against. The event's own stamps are small (edge-relative, so [-pre, +post]), // which is why that shift looked like a plausible picture: with a 50/50 split the // right half of the plot was simply empty, zooming out showed the trace ending at // the x = 0 marker, and zooming in past the shift showed nothing at all while the // crosshair -- which reads `x_at()`, never `gather()` -- went on reporting the // right values. const f64 base = x0(); out_xy.reserve(out_xy.size() + static_cast((end - begin) / stride * 2 + 4)); for (u64 i = (begin / stride) * stride; i < end; i += stride) { out_xy.push_back(static_cast(stamps[static_cast(i)] - base)); out_xy.push_back(vals[static_cast(i)]); } } bool neighbours(f64 x, u64& i0, u64& i1) const override { const u64 n = size(); if (n < 2) return false; const f64 step = dx(); if (!(step > 0.0)) return false; f64 idx = (x - x0()) / step; if (idx < 0.0) idx = 0.0; if (idx > static_cast(n - 1)) idx = static_cast(n - 1); i0 = static_cast(idx); i1 = i0 + 1 < n ? i0 + 1 : i0; return true; } u64 snap_index(f64 x) const override { u64 i0 = 0, i1 = 0; if (!neighbours(x, i0, i1)) return 0; return i1; } /// SlotSeries::copy_raw forwards here (public: the base's hook is protected, /// and SlotSeries is a different class). void raw_values(u64 begin, u64 end, f32* out) const { const auto& vals = snap_->values[channel_]; const u64 n = static_cast(vals.size()); if (end > n) end = n; for (u64 i = begin; i < end; ++i) out[static_cast(i - begin)] = vals[static_cast(i)]; } protected: void copy_raw(u64 begin, u64 end, f32* out) const override { raw_values(begin, end, out); } private: std::shared_ptr snap_; std::size_t channel_; plot::data::MinMaxPyramid empty_pyramid_{}; }; } // namespace udpscope