Correct downsampling and better layout

This commit is contained in:
Martino Ferrari committed 2026-10-02 08:19:10 +02:00
1 parent 0d741c401b
commit 30db44eaa0
13 files changed
+1511 -252

No files matched your search

+37 -4
View File
@@ -319,6 +319,17 @@ void Acquisition::on_data(const udps_frame_t* frame) {
BufferStore& store = model_.live();
const u64 g = part.time_stride;
// The filter's groups are `g` long, so a re-plan has to start a fresh group rather than
// finish the one it was in with the wrong length.
if (acc_stride_ != g || acc_channels_ != channels.size()) {
acc_stride_ = g;
acc_channels_ = channels.size();
value_acc_.assign(channels.size(), 0.0);
value_acc_n_.assign(channels.size(), 0);
time_acc_ = 0.0;
time_acc_n_ = 0;
}
// Calibration is read per frame; it only changes under the same lock.
TriggerEngine& engine = engine_;
const int trig_channel = tc.channel;
@@ -424,7 +435,16 @@ void Acquisition::on_data(const udps_frame_t* frame) {
last_stamp_used = rel;
last_stamp_ = rel;
if (tick % g == g - 1) store.time().push(static_cast<f32>(rel));
// The stored stamp is the *mean* of the group, not its last member: that is where the
// boxcar's group delay puts the averaged value, and stamping it at the end of the
// group would shift every trace half a stored sample late.
time_acc_ += rel;
++time_acc_n_;
if (tick % g == g - 1) {
store.time().push(static_cast<f32>(time_acc_ / static_cast<double>(time_acc_n_)));
time_acc_ = 0.0;
time_acc_n_ = 0;
}
// The latch belongs to this sample, but it cannot be published from inside the
// channel loop: `publish_event()` clamps the slice to every value ring's
@@ -438,8 +458,6 @@ void Acquisition::on_data(const udps_frame_t* frame) {
double latch_edge = 0.0;
for (std::size_t i = 0; i < channels.size(); ++i) {
const u64 period = g * part.stride[i];
if (tick % period != period - 1) continue;
const ChannelInfo& ch = channels[i];
// A signal's columns are consecutive samples of the same channel: a scalar
// (one element) carries one value per batched cycle, a block signal carries
@@ -449,8 +467,23 @@ void Acquisition::on_data(const udps_frame_t* frame) {
? udps_frame_value(frame, static_cast<std::uint32_t>(ch.signal), 0,
std::min<std::uint32_t>(k, ch.elements - 1))
: udps_frame_value(frame, static_cast<std::uint32_t>(ch.signal), k, 0);
const f32 value = static_cast<f32>(raw * ch.gain + ch.offset);
// Every sample is read and accumulated even when only one in `period` is stored:
// that is the whole point of the filter. Skipping the read -- which is what the
// old stride did -- is what made decimation alias.
value_acc_[i] += raw * ch.gain + ch.offset;
++value_acc_n_[i];
const u64 period = g * part.stride[i];
if (tick % period != period - 1) continue;
const f32 value =
static_cast<f32>(value_acc_[i] / static_cast<double>(value_acc_n_[i]));
value_acc_[i] = 0.0;
value_acc_n_[i] = 0;
store.values(i).push(value);
// The trigger sees the *stored* signal, not the wire signal. An edge found in a
// sample that is not in the buffer would be cut from data that does not contain it,
// and with the filter in front of it a spike above the stored Nyquist is exactly
// the thing that must not latch -- it is not in the record the user would get.
if (static_cast<int>(i) == trig_channel) {
if (engine.process(value, rel)) {
latch_here = true;
+14
View File
@@ -77,6 +77,20 @@ class Acquisition {
double arrival_epoch_ = 0.0;
bool have_arrival_epoch_ = false;
std::vector<double> stamps_;
/// The decimation filter's state: a running sum per channel (and one for the shared
/// clock) over the group of base samples that becomes one stored sample. A boxcar
/// average, which is what turns decimation into a *resampling* rather than a stride:
/// its first null sits at the stored rate, so what would fold back is attenuated
/// instead of reappearing as a false low frequency. Cleared whenever the partition
/// changes, since the group length is `time_stride`.
std::vector<double> value_acc_;
std::vector<std::uint64_t> value_acc_n_;
double time_acc_ = 0.0;
std::uint64_t time_acc_n_ = 0;
/// The partition the accumulators were sized for, so a re-plan resets them rather than
/// averaging across the boundary.
std::uint64_t acc_stride_ = 0;
std::size_t acc_channels_ = 0;
std::chrono::steady_clock::time_point started_{};
};
+9 -2
View File
@@ -3,6 +3,10 @@
#include "config.hpp"
// For `RowSet::kMaxRows`: `--plots` names a row count, and one constant for the ceiling
// is what stops the message and the clamp from drifting apart.
#include "plot_host.hpp"
#include <cstdio>
#include <cstdlib>
#include <stdexcept>
@@ -48,7 +52,8 @@ void print_usage(std::FILE* out, const char* argv0) {
" --max-window SEC largest time window the toolbar offers (default 600)\n"
" --holdoff SEC default trigger hold-off (default 0.010)\n"
" --recv-buffer BYTES SO_RCVBUF for the receiver socket (default 8388608)\n"
" --plots N initial number of stacked plots, 1..4 (default 1)\n"
" --plots N stacked plots to start with, 1..8 (default 1); rows are\n"
" added and removed from the layout editor\n"
" -h, --help this text\n",
argv0);
}
@@ -88,7 +93,9 @@ Config parse_args(int argc, char** argv) {
} else if (a == "--plots" || a.rfind("--plots=", 0) == 0) {
const std::string v = value_of(argc, argv, i, "--plots");
const long n = std::strtol(v.c_str(), nullptr, 10);
if (n < 1 || n > 4) fail("--plots must be 1..4: " + v);
if (n < 1 || n > RowSet::kMaxRows) {
fail("--plots must be 1.." + std::to_string(RowSet::kMaxRows) + ": " + v);
}
c.plots = static_cast<int>(n);
} else {
fail("unknown argument: " + a);
+2 -1
View File
@@ -27,7 +27,8 @@ struct Config {
double trigger_holdoff_s = 0.010;
int recv_buffer_bytes = 8 * 1024 * 1024;
/// Number of stacked plots (rows) to start with, 1..4.
/// Number of stacked plots (rows) to start with, 1..`RowSet::kMaxRows`. Only the
/// starting point: rows are added and removed from the layout editor.
int plots = 1;
};
+88 -9
View File
@@ -2,6 +2,7 @@
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <plot/data/minmax_pyramid.hpp>
#include <plot/data/series.hpp>
#include <algorithm>
@@ -30,6 +31,18 @@ struct EventSnapshot {
double pre_s = 0.0; ///< seconds of pre-trigger data kept
double post_s = 0.0; ///< seconds of post-trigger data kept
std::vector<plot::Range> y; ///< per-channel value bounds, precomputed
/// One LOD pyramid per channel, built once here rather than by the series.
///
/// It is the *renderer's* performance contract that needs it: `level_for()` descends
/// to level 0 on a pyramid with no levels, so a snapshot without one is drawn from
/// every raw sample it holds, every frame, for as long as the event is held -- a
/// 2 s capture at 1 MS/s staged 6 million segments across three traces and took
/// ~100 ms a frame, which is the "large data is laggy" report. It belongs to the
/// snapshot and not to `EventSeries` because the snapshot is immutable and shared:
/// built here it costs one O(n) pass per channel inside the copy loop that is
/// already O(n), and the same channel on two rows shares it instead of building and
/// storing it twice.
std::vector<plot::data::MinMaxPyramid> pyramids;
};
/// A plot::data::Series view of one channel of an EventSnapshot. The stamps are
@@ -70,19 +83,35 @@ class EventSeries final : public plot::data::Series {
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_; }
/// The snapshot's pyramid for this channel (see `EventSnapshot::pyramids`). The
/// empty one only stands in for a channel the snapshot does not carry, where the
/// series has no samples either.
const plot::data::MinMaxPyramid& pyramid() const override {
return channel_ < snap_->pyramids.size() ? snap_->pyramids[channel_] : empty_pyramid_;
}
bool streaming() const noexcept override { return false; }
/// Envelope geometry per whole bucket, raw samples at the two partial edges --
/// the same shape as `ChannelBuffer::gather()`, and for the same two reasons: the
/// emitted geometry must span `[begin, end)` exactly and never reach outside it,
/// and a *bucket's extremes* are what a scope has to draw. The decimation this
/// replaced took every `2^level`-th sample, which hides exactly the spike a
/// capture was triggered on -- and, since the level was always 0 before the
/// snapshot carried a pyramid, it never even ran.
///
/// Indices need no rebasing: the pyramid is built from sample 0 and nothing is ever
/// dropped from it, so `window_base()` is 0 and a window index *is* the absolute one
/// (unlike the ring, whose window slides).
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
plot::data::GatherMode /*mode*/ =
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;
const u64 b = std::min(begin, n);
const u64 e = std::min(std::max(end, b), n);
if (b >= e) return;
// 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
@@ -94,11 +123,61 @@ class EventSeries final : public plot::data::Series {
// 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<std::size_t>((end - begin) / stride * 2 + 4));
for (u64 i = (begin / stride) * stride; i < end; i += stride) {
out_xy.push_back(static_cast<f32>(stamps[static_cast<std::size_t>(i)] - base));
out_xy.push_back(vals[static_cast<std::size_t>(i)]);
const auto rel = [&](u64 i) {
return static_cast<f32>(stamps[static_cast<std::size_t>(i)] - base);
};
const auto emit_raw = [&](u64 from, u64 to) {
for (u64 i = from; i < to; ++i) {
out_xy.push_back(rel(i));
out_xy.push_back(vals[static_cast<std::size_t>(i)]);
}
};
if (level == 0) {
emit_raw(b, e);
return;
}
level = std::min(level, plot::data::kMaxPyramidLevel);
const plot::data::MinMaxPyramid& pyr = pyramid();
const u64 span = u64{plot::data::kBucket} << level;
const u64 lbase = pyr.level_base(level);
const u64 lend = lbase + pyr.level_size(level);
const u64 first = std::max(b / span + (b % span == 0 ? u64{0} : u64{1}), lbase);
const u64 last = std::min(e / span, lend);
if (first >= last) {
emit_raw(b, e);
return;
}
if (b < first * span) emit_raw(b, std::min(first * span, e));
for (u64 bi = first; bi < last; ++bi) {
const u32 count = pyr.bucket_count(level, bi);
switch (mode) {
case plot::data::GatherMode::Nearest: {
const u64 centre = bi * span + span / 2;
out_xy.push_back(rel(centre));
out_xy.push_back(vals[static_cast<std::size_t>(centre)]);
break;
}
case plot::data::GatherMode::Mean: {
if (count == 0) break; // no finite sample: no mean to draw
const u64 centre = bi * span + span / 2;
out_xy.push_back(rel(centre));
out_xy.push_back(static_cast<f32>(pyr.bucket_sum(level, bi) / f64(count)));
break;
}
default: { // Envelope: min at the bucket's first sample, max at its last
if (count == 0) break;
const plot::data::MinMax m = pyr.bucket(level, bi);
out_xy.push_back(rel(bi * span));
out_xy.push_back(m.min);
out_xy.push_back(rel(std::min((bi + 1) * span, e) - 1));
out_xy.push_back(m.max);
break;
}
}
}
if (last * span < e) emit_raw(last * span, e);
}
bool neighbours(f64 x, u64& i0, u64& i1) const override {
+59 -11
View File
@@ -7,6 +7,7 @@
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <stdexcept>
#include <string>
#include <unordered_map>
@@ -29,7 +30,8 @@ u64 Partition::total_samples() const noexcept {
return total;
}
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes) {
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes,
double target_span_s) {
Partition p;
const std::size_t n = channel_dt.size();
p.stride.assign(n, 1);
@@ -48,16 +50,12 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
if (!(base_dt > 0.0)) base_dt = 1.0e-3;
p.base_dt = base_dt;
// No per-channel decimation, and none on the shared clock either. A stride of
// `round(dt_i / dt_fastest)` throws away all but one sample per stride, and when
// that stride equals the signal's own period -- a 2 kHz sine on a 1 MHz clock,
// stride 500 -- every kept sample sits at the same phase: the channel plots as a
// flat line. Decimating the shared clock to make a 600 s window fit did the same
// to *every* channel. The LOD min/max pyramid is what preserves the extremes at
// draw time, and it needs the real samples; the buffer simply holds as many as
// it holds, and the window dropdown is capped to that span.
// No *per-channel* decimation. A stride of `round(dt_i / dt_fastest)` keeps one sample
// in every stride, and when that stride equals the signal's own period -- a 2 kHz sine
// on a 1 MHz clock, stride 500 -- every kept sample sits at the same phase and the
// channel plots as a flat line. The shared clock is decimated below instead, which
// affects every channel equally and goes through the ingest filter.
for (std::size_t i = 0; i < n; ++i) p.stride[i] = 1;
p.time_stride = 1;
const u64 budget_bytes = static_cast<u64>(std::max(1.0, buffer_bytes));
const u64 budget = budget_bytes / sizeof(f32);
@@ -97,6 +95,23 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
for (std::size_t i = 0; i < n; ++i) {
p.value_capacity[i] = capacity_for(p.time_capacity, p.stride[i]);
}
// Only now, with the ring as long as the budget can buy, decimate the shared clock
// enough for the requested window to fit in it: deciding this earlier would over-
// decimate by whatever factor the growth loop was about to add. A power of two keeps
// the ring capacities (themselves powers of two) in step and keeps `tick % period`
// cheap. The cap stops a nonsense request -- a window far past `--max-window` -- from
// spinning here; at 2^20 the stored rate is six decades below the wire rate and the
// picture is averages of averages anyway.
u64 m = 1;
if (target_span_s > 0.0) {
while (m < (u64(1) << 20) &&
static_cast<double>(time_capacity) * static_cast<double>(m) * base_dt <
target_span_s) {
m *= 2;
}
}
p.time_stride = m;
p.span_s = static_cast<double>(p.time_capacity) * static_cast<double>(p.time_stride) * base_dt;
return p;
}
@@ -184,12 +199,37 @@ void ScopeModel::rebuild_locked() {
return;
}
part_ = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0);
part_ = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0,
target_span_.load(std::memory_order_relaxed));
store_ = std::make_unique<BufferStore>(part_, part_.span_s);
if (trigger_.channel >= static_cast<int>(channels_.size())) trigger_.channel = -1;
}
bool ScopeModel::set_target_span(double seconds) {
if (!(seconds > 0.0)) return false;
std::lock_guard<std::mutex> lock(mutex_);
const double previous = target_span_.exchange(seconds, std::memory_order_relaxed);
if (channels_.empty()) return false;
// Re-plan only if the factor moves. The planner is cheap, so ask it rather than
// reimplementing its arithmetic here -- two expressions for the same decision is how
// the buffer and the view end up disagreeing about the stored rate.
std::vector<double> dts;
dts.reserve(channels_.size());
for (const ChannelInfo& c : channels_) dts.push_back(c.dt);
const Partition want = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0, seconds);
if (want.time_stride == part_.time_stride) return false;
std::fprintf(stderr,
"[udpscope] window %.4f s -> %.4f s: decimation %llu -> %llu, stored rate "
"%.4g -> %.4g S/s (anti-alias %.4g Hz); buffer rebuilt\n",
previous, seconds, static_cast<unsigned long long>(part_.time_stride),
static_cast<unsigned long long>(want.time_stride), part_.stored_rate(),
want.stored_rate(), want.antialias_hz());
std::fflush(stderr);
rebuild_locked();
return true;
}
bool ScopeModel::is_time_signal(const std::vector<SignalInfo>& signals,
std::size_t signal) {
if (signal >= signals.size()) return false;
@@ -331,15 +371,23 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
snap->values.resize(channels_.size());
snap->y.resize(channels_.size(), plot::Range{0.0, 0.0});
snap->pyramids.resize(channels_.size());
for (std::size_t c = 0; c < channels_.size(); ++c) {
auto& vals = snap->values[c];
vals.resize(static_cast<std::size_t>(n));
if (n > 0) src.values(c).copy_from(first, n, vals.data());
f32 lo = vals.empty() ? 0.0f : vals.front();
f32 hi = lo;
// The LOD index is folded into the bounds pass that was already here, so the
// snapshot costs one O(n) walk per channel rather than two. Without a pyramid
// the renderer's `level_for()` descends to level 0 and the whole capture is
// drawn raw on every frame the event is held: see `EventSnapshot::pyramids`.
auto& pyr = snap->pyramids[c];
pyr.reserve(n);
for (f32 v : vals) {
lo = std::min(lo, v);
hi = std::max(hi, v);
pyr.append(v);
}
snap->y[c] = plot::Range{lo, hi};
}
+33 -5
View File
@@ -69,7 +69,12 @@ struct ChannelInfo {
/// coincide and every slice is the same size.
struct Partition {
u64 time_capacity = 0; ///< time-base slots retained
u64 time_stride = 1; ///< base ticks per time-base slot (reserved; 1)
/// Base ticks per stored slot: the decimation factor the requested window forced. 1
/// until the window needs more samples than the budget can hold. The ingest path
/// *averages* each group of this many samples rather than keeping one of them (see
/// `Acquisition::on_data()`), so this is a sample rate reduction with an anti-alias
/// filter in front of it, not a stride.
u64 time_stride = 1;
std::vector<u64> stride; ///< time slots per channel sample (>= 1)
std::vector<u64> value_capacity; ///< samples retained per channel
double base_dt = 1.0; ///< seconds per base tick
@@ -79,12 +84,23 @@ struct Partition {
u64 total_bytes() const noexcept { return total_samples() * sizeof(f32); }
/// Seconds between two time-base slots (the sample interval of every channel).
double slot_dt() const noexcept { return base_dt * static_cast<double>(time_stride); }
/// The rate samples are actually stored at, in samples per second.
double stored_rate() const noexcept {
const double dt = slot_dt();
return dt > 0.0 ? 1.0 / dt : 0.0;
}
/// The anti-alias filter's corner, in Hz: half the stored rate, which is the new
/// Nyquist. The boxcar the ingest path averages with has its first null at the stored
/// rate itself, so everything that would fold back is attenuated.
double antialias_hz() const noexcept { return 0.5 * stored_rate(); }
};
/// Plans a partition for `channel_dt` cadences within `buffer_bytes`, targeting a
/// span of `target_span_s` seconds. Throws std::runtime_error when the budget
/// cannot hold even one sample per channel.
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes);
/// Plans a partition for `channel_dt` cadences within `buffer_bytes`, covering at least
/// `target_span_s` seconds of stream -- decimating (with the ingest filter) by whatever
/// power of two that takes. Throws std::runtime_error when the budget cannot hold even
/// one sample per channel.
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes,
double target_span_s);
/// One of the two identical sample buffers: a shared time-base ring plus one
/// value ring and one `ChannelBuffer` per channel.
@@ -287,6 +303,14 @@ class ScopeModel {
}
double event_span() const noexcept { return event_span_.load(std::memory_order_relaxed); }
/// The window the buffer has to cover, which is the display window. Re-plans the
/// partition -- and so drops the history -- only when the decimation factor it implies
/// actually changes, so walking the window inside one decade costs nothing. Takes
/// `mutex()` itself; do not call it holding the lock.
/// Returns true when the buffer was rebuilt.
bool set_target_span(double seconds);
double target_span() const noexcept { return target_span_.load(std::memory_order_relaxed); }
/// Trigger state for the UI, published by the acquisition thread. Atomics
/// rather than fields of `TriggerConfig` because the producer writes the arm
/// state when a single-shot trigger disarms itself, and the frame tick reads it
@@ -321,6 +345,10 @@ class ScopeModel {
StreamStats stats_{};
f64 epoch_ = 0.0;
std::atomic<double> event_span_{1.0};
/// The window the buffer is planned to cover. Read by `rebuild_locked()`, which a
/// CONFIG can reach from the producer thread, so it is an atomic rather than a plain
/// field.
std::atomic<double> target_span_{0.0};
std::atomic<bool> trigger_pending_{false};
std::atomic<double> capture_edge_{0.0};
std::atomic<std::shared_ptr<const EventSnapshot>> event_; ///< the event on screen
+47
View File
@@ -54,6 +54,53 @@ void RowSet::set_row_count(int rows) {
rebuild_all();
}
void RowSet::carry_row(Row& dst, const Row& src) {
dst.signal_name = src.signal_name;
dst.name = src.name;
dst.channel = src.channel;
dst.color_index = src.color_index;
dst.cursors_visible = src.cursors_visible;
dst.cursor_a_x = src.cursor_a_x;
dst.cursor_b_x = src.cursor_b_x;
dst.ruler_y = src.ruler_y;
// The slot's series stays where it is, but what it is bound to belonged to the row
// this state came *from*. `bind()` re-resolves every slot next frame; clearing it here
// is what stops the frame in between from drawing the moved row's data under the new
// row's legend.
for (SlotSeries& s : dst.slots) s.bind(nullptr);
}
void RowSet::clear_row(Row& r) {
r.signal_name = {};
r.name = {};
r.channel = {-1, -1, -1, -1};
r.color_index = {0, 1, 2, 3};
r.cursors_visible = false;
r.cursor_a_x = 0.0;
r.cursor_b_x = 0.0;
r.ruler_y = 0.0;
for (SlotSeries& s : r.slots) s.bind(nullptr);
}
bool RowSet::insert_row(int at) {
if (rows_ >= kMaxRows) return false;
const int pos = std::clamp(at, 0, rows_);
for (int i = rows_; i > pos; --i) carry_row(row(i), row(i - 1));
clear_row(row(pos));
++rows_;
rebuild_all();
return true;
}
bool RowSet::remove_row(int at) {
if (rows_ <= 1 || at < 0 || at >= rows_) return false;
for (int i = at; i + 1 < rows_; ++i) carry_row(row(i), row(i + 1));
--rows_;
clear_row(row(rows_));
rebuild_all();
return true;
}
void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
r.plot = &p;
r.axis_x = r.plot->add_x_axis(
+26 -1
View File
@@ -41,7 +41,12 @@ namespace udpscope {
/// context and is simply handed the new figure.
class RowSet {
public:
static constexpr int kMaxRows = 4;
/// The ceiling on rows, not the count: rows are added and removed one at a time
/// (`insert_row()`, `remove_row()`) rather than picked off a list of fixed layouts.
/// A cap still exists because `storage_` is the rows' persistent state and has to be
/// allocated somewhere, and because the figure keeps a floor height per row -- past
/// about this many a row is chrome and no plot.
static constexpr int kMaxRows = 8;
static constexpr int kSlotsPerRow = 4;
RowSet();
@@ -93,6 +98,17 @@ class RowSet {
/// Shows `rows` rows. A changed count rebuilds the figure, since a `Figure`'s row
/// count is fixed at construction.
void set_row_count(int rows);
/// Inserts an empty row at `at` (0 = above the first row, `row_count()` = below the
/// last), pushing the rows at and below it down. Returns false at `kMaxRows`.
///
/// The rows below move as *rows*: their trace assignments, colours and cursors follow
/// them, so inserting above row 1 does not renumber what row 1 was plotting. The
/// figure's `Plot`s are not what moves -- they are rebuilt and re-dealt -- which is
/// the same mechanism a row-count change already used.
bool insert_row(int at);
/// Removes row `at` and pulls the rows below it up. Returns false when only one row
/// is left: a figure with no rows has nothing to draw and no way back.
bool remove_row(int at);
Row& row(int i) { return storage_[static_cast<std::size_t>(i)]; }
const Row& row(int i) const { return storage_[static_cast<std::size_t>(i)]; }
@@ -192,6 +208,15 @@ class RowSet {
static plot::Color unplotted_color();
private:
/// Moves one row's *persistent* state -- what it plots, in which colours, where its
/// cursors are -- onto another row. Deliberately not a `Row` assignment: a `Row` also
/// carries the figure handles (`plot`, the axis and cursor ids) and the `SlotSeries`
/// objects the traces point at, and those belong to the row's *position*, not to its
/// contents. `rebuild_all()` re-deals them straight after.
static void carry_row(Row& dst, const Row& src);
/// Back to an empty row: nothing plotted, the default palette entries, no cursors.
static void clear_row(Row& r);
void install(Row& r, int row_index, plot::Plot& p);
void place_cursors(Row& r);
/// The newest x any row's live traces hold, which is what the group follows.
+795 -186
View File
File diff suppressed because it is too large. Load diff