Files
udpscope/src/plot_host.cpp
T
Martino Ferrari e14e1ee069 trigger: a phosphor display keeps the last N acquisitions; and a Force button
Two changes in the trigger pane, landed together.

**Persistence.** Trigger mode keeps the last N published acquisitions and draws them fading toward
the background, N being a panel control (1..8, default 1) with N=1 meaning today's behaviour
exactly, so the knob is the switch. The layers are the accumulate path's own -- one layer per
(signal, age) in the row's free trace slots, named with the accumulate vocabulary so
`layer_base_name()` strips the suffix and `signal_color()` resolves the base signal -- so a layer is
not a signal: the signal list, the colours and the measurements see one row per signal.
`ScopeModel` retains the outgoing snapshot in seven atomic `shared_ptr` slots (no lock, no copy; the
snapshot already exists) and a layer's channel is resolved **by name inside the snapshot**, because
the channel list is renumbered by any structural change since that acquisition was cut.
`RowSet::sync_layers()` re-plans from the rows and the depth alone, so a latch costs no rebuild, and
`event_data_window()` skips layer slots so an older acquisition can never re-pin the axis. The
schedule is 0.5^age -- the acquisition on screen at full brightness and each older one half the
previous, independent of N, so raising the depth adds dimmer acquisitions rather than rescaling the
ones already on screen. Memory is bounded by the ring's own `--buffer-mb` rather than by the depth,
which is the bound that cannot be driven into an OOM, and the panel says when either bound bites.
This also wired up `set_trace_alpha()`, which the accumulate path was setting and nothing was
reading -- so the documented 0.35 translucency of an accumulation was opaque in fact.

**Force.** In the trigger pane beside Arm/Disarm, shown only while trigger mode is on: one
acquisition now, with the configured window. It is a synthetic edge rather than a second publisher
-- the engine opens one capture at the present moment and everything else is the ordinary latch, so
a forced acquisition is cut, pinned, framed, recorded and cached exactly like a latched one, and
the button owns no window and no publish. The pre-trigger gate is deliberately skipped (capture now
is a request for the history that exists, and the snapshot reports its own shorter pre_s), a
capture already filling is refused rather than restarted, and the state afterwards is the mode's:
Normal stays armed, Single disarms. The request is one atomic flag written under the model mutex
with `armed` -- the producer reads `armed` at the top of a packet and the request at the bottom, so
a request written between the two would be dropped -- and consumed at the end of a packet, after
the channel loop and before the pending publish.

**A defect fixed in passing, in the function Force was modelled on:** `on_arm_clicked()` passed
`TriggerConfig::channel` (an index into `channels()`) to `set_signal_enabled()` (an index into
`signals()`). With one signal acquired, arming switched on the stream's clock signal and left the
watched signal untouched -- and since a signal the panel was not showing is a change, it rebuilt
the buffer under the click, dropping the very pre-trigger window the arming, and now the Force, was
about to capture. Measured before: `0.0100 s of history behind it`; after: `2.5299 s`.
`trigger_signal_index()` is now the one resolution, used by both buttons.
2026-10-16 16:46:17 +02:00

1283 lines
58 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "plot_host.hpp"
#include <algorithm>
#include <cmath>
namespace udpscope {
namespace {
/// A small qualitative palette, one colour per *signal*. It is indexed by the signal
/// rather than by the trace slot so that a signal plotted on several rows is the same
/// colour in all of them: the signal list's chip is what ties a row of that list to a
/// trace on screen, and a colour that depended on where the signal landed would name a
/// different trace in every row.
constexpr plot::u32 kPalette[] = {
0x4FA3FFu, 0xFF8A4Fu, 0x5FD08Au, 0xE86A9Cu, 0xC9A227u, 0x8F7BFFu, 0x4FD0D0u, 0xFF6B6Bu,
};
constexpr std::size_t kPaletteSize = sizeof(kPalette) / sizeof(kPalette[0]);
/// The chip of a signal that is acquired but on no plot, and of one that is not even
/// acquired. Mid grey: it has to read as "no trace" next to any palette entry.
constexpr plot::u32 kUnplottedColor = 0x6E6E6Eu;
constexpr plot::u32 kCursorAColor = 0xFFD24Fu;
constexpr plot::u32 kCursorBColor = 0x7FE0FFu;
constexpr plot::u32 kRulerColor = 0xB0B0B0u;
constexpr plot::u32 kRulerBColor = 0x8FA8C8u;
/// The measurements' own colour, distinct from both cursor rulers so a readout mark cannot be
/// mistaken for something the user placed.
constexpr plot::u32 kMeasureColor = 0x9BE8A0u;
constexpr plot::u32 kTriggerColor = 0xFF5B5Bu;
/// A found hit's colour: the trigger marker's claim ("the scope fired here") in a cooler hue,
/// because a hit is the same condition found in *history* and the two are on screen together --
/// the live marker's red must stay the one that means "this is the event the capture is about".
constexpr plot::u32 kSearchColor = 0x7FB2FFu;
/// The *selected* hit's colour: the one the arrows are standing on, and the one a pause was taken
/// around. Sixteen identical lines with no way to tell which of them the readout is quoting is a
/// picture that cannot be read, so the current hit is brighter than its neighbours.
constexpr plot::u32 kSearchCurrentColor = 0xE8D37Fu;
/// The smallest difference a `TickFormat::Time` label can *write* at the magnitude `t` -- what
/// the last digit of a label at that magnitude is worth. `format_time` writes three significant
/// figures below a minute (of the SI-prefixed mantissa under a second, of the seconds above it),
/// whole seconds through `m:ss`/`h:mm:ss` up to a day, and three figures of days above. So the
/// resolution is a step function of the *magnitude* and not of the span -- which is the whole
/// reason a relative axis is ever needed, and the reason a span threshold alone gets it wrong in
/// both directions.
double time_label_resolution(double t) {
t = std::fabs(t);
if (!std::isfinite(t) || !(t > 0.0)) return 0.0;
if (t >= 86400.0) return 86400.0 * std::pow(10.0, std::floor(std::log10(t / 86400.0)) - 2.0);
if (t >= 60.0) return 1.0;
return std::pow(10.0, std::floor(std::log10(t)) - 2.0);
}
constexpr double kParked = -1.0e12; ///< hides an instrument without removing it
/// The measurement marks' stroke: thinner than the chrome that frames the plot (a trace is 1.5,
/// the axis lines and the trigger marker `Style::line_width_px`), and broken, so a readout over
/// the data is distinguishable by *form* from the instruments around it.
constexpr float kMeasureStrokeWidth = 0.75f;
constexpr float kMeasureDashOn = 4.0f;
constexpr float kMeasureDashOff = 4.0f;
/// The gap between two rows, in device pixels. The rows used to be separate widgets in
/// a box, so there was a widget gap; a figure tiles its viewport, and without a gap two
/// stacked frames read as one tall plot with a line through it.
constexpr plot::f32 kRowGap = 6.0f;
/// The suffix that names a persistence layer, in the accumulate path's own vocabulary --
/// ` (A)`, ` (B)`, ` #4` -- so that `layer_base_name()` takes it back off, and the signal list, the
/// colour resolver, the measurements and the layout writer all keep seeing one signal where the
/// plot draws several traces of it.
std::string layer_suffix(int age) {
if (age == 1) return " (A)";
if (age == 2) return " (B)";
return " #" + std::to_string(age + 1);
}
} // namespace
float RowSet::persist_alpha(int age) noexcept {
// Half per acquisition, so the seventh is 1/128 -- and halves, not a slope, because that is what
// "exponential decay" means and because it is the one schedule that can be read off a line of
// numbers: 1, 0.5, 0.25, 0.125. It does not depend on the depth, so raising N adds dimmer
// acquisitions instead of restating the ones already on screen.
float a = 1.0f;
for (int i = 0; i < age; ++i) a *= 0.5f;
return a;
}
std::string layer_base_name(const std::string& name) {
const std::size_t a = name.rfind(" (");
if (a != std::string::npos && a + 3 < name.size() && name[a + 3] == ')' &&
name[a + 2] >= 'A' && name[a + 2] <= 'B') {
return name.substr(0, a);
}
const std::size_t h = name.rfind(" #");
if (h != std::string::npos && h + 2 < name.size()) {
bool digits = true;
for (std::size_t i = h + 2; i < name.size(); ++i) {
if (name[i] < '0' || name[i] > '9') digits = false;
}
if (digits) return name.substr(0, h);
}
return name;
}
plot::Color RowSet::palette_color(int index) {
const std::size_t i = static_cast<std::size_t>(index < 0 ? 0 : index) % kPaletteSize;
return plot::Color::hex(kPalette[i]);
}
plot::Color RowSet::unplotted_color() { return plot::Color::hex(kUnplottedColor); }
plot::Color RowSet::signal_color(const std::string& signal, int palette_index) const {
// The exact name first, then the signal's: a colour the user picked for `sine` is the colour of
// every layer of it, so a comparison's `sine (B)` is not a signal of its own colour.
auto it = signal_colors_.find(signal);
if (it == signal_colors_.end()) it = signal_colors_.find(layer_base_name(signal));
if (it != signal_colors_.end()) return plot::Color::hex(it->second);
return palette_color(palette_index);
}
void RowSet::mark_signal_dirty(const std::string& signal) {
for (int i = 0; i < kMaxRows; ++i) {
const Row& r = row(i);
for (int s = 0; s < kSlotsPerRow; ++s) {
if (r.signal_name[static_cast<std::size_t>(s)] != signal) continue;
row_dirty_[static_cast<std::size_t>(i)] = true;
break;
}
}
}
void RowSet::set_signal_color(const std::string& signal, plot::u32 rgb) {
if (signal.empty()) return;
// Masked to 24 bits: the palette is `0xRRGGBB` and `Color::hex()` reads it as such, so an alpha
// byte left in the high octet by a caller converting from a `GdkRGBA` would be silently ignored
// by the plot and silently written to the session file.
const plot::u32 want = rgb & 0x00FFFFFFu;
const auto it = signal_colors_.find(signal);
if (it != signal_colors_.end() && it->second == want) return;
signal_colors_[signal] = want;
mark_signal_dirty(signal);
}
void RowSet::clear_signal_color(const std::string& signal) {
if (signal_colors_.erase(signal) == 0) return;
mark_signal_dirty(signal);
}
RowSet::RowSet() { rebuild_all(); }
void RowSet::set_row_count(int rows) {
const int want = std::clamp(rows, 1, kMaxRows);
if (want == rows_) return;
rows_ = want;
// A `Figure`'s row count is fixed at construction, so a new count is a new figure.
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;
// A layer travels with the slot that draws it: it *is* part of what that slot holds. The tick's
// `sync_layers()` re-plans them all next frame in any case, which is what heals a row that has
// moved under a plan made for the old one.
dst.layer = src.layer;
dst.slot_alpha = src.slot_alpha;
dst.ruler_y = src.ruler_y;
dst.ruler_b_y = src.ruler_b_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.layer = {0, 0, 0, 0};
r.slot_alpha = {1.0f, 1.0f, 1.0f, 1.0f};
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(
{"", {-1.0, 1.0}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
r.axis_y = r.plot->add_y_axis(
{"", {-1.0, 1.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
// Traces are drawn in the order they were created, and a `Plot` cannot reorder them: the
// *dimmest* traces are created first and a signal's own trace last, so the newest acquisition is
// the ink on top. A layer drawn over the event would take its colour out of the event's own trace
// wherever the two agree -- the opposite of what a persistence display shows.
std::array<int, kSlotsPerRow> order{};
for (int s = 0; s < kSlotsPerRow; ++s) order[static_cast<std::size_t>(s)] = s;
std::stable_sort(order.begin(), order.end(), [&r](int a, int b) {
return r.slot_alpha[static_cast<std::size_t>(a)] < r.slot_alpha[static_cast<std::size_t>(b)];
});
for (const int s : order) {
const std::size_t i = static_cast<std::size_t>(s);
plot::Color color = signal_color(r.signal_name[i], r.color_index[i]);
// The slot's own brightness times the figure-wide one the group views set: a persistence
// layer's decay and an accumulation's translucency are the same knob, and `set_trace_alpha()`
// is what reads it here. It was set and never read before, which is why an accumulation's
// traces were opaque.
color.a = r.slot_alpha[i] * trace_alpha_;
r.plot->add_trace({&r.slots[i], color, 1.2f, r.axis_x, r.axis_y, r.name[i]});
}
r.cursor_a = r.plot->add_cursor(0.0, plot::Color::hex(kCursorAColor));
r.cursor_b = r.plot->add_cursor(0.0, plot::Color::hex(kCursorBColor));
r.ruler = r.plot->add_h_ruler(0.0, plot::Color::hex(kRulerColor));
r.ruler_b = r.plot->add_h_ruler(0.0, plot::Color::hex(kRulerBColor));
// The `mix` crossings, one pair per trace slot, hidden until that mode is on. Created
// here because a `Plot` fixes its annotation set the same way it fixes its traces --
// `add_annotation()` only appends -- so the markers are made up front and moved.
for (int s = 0; s < kSlotsPerRow; ++s) {
plot::Annotation a{0.0, 0.0, "", plot::Color::hex(kCursorAColor), r.axis_x, r.axis_y, false};
plot::Annotation b{0.0, 0.0, "", plot::Color::hex(kCursorBColor), r.axis_x, r.axis_y, false};
r.crossing_a[static_cast<std::size_t>(s)] = r.plot->add_annotation(a);
r.crossing_b[static_cast<std::size_t>(s)] = r.plot->add_annotation(b);
}
// The measurements' readout marks: a fixed pool, parked. Levels are rulers (they read a y)
// and times are markers (they read an x), so the pool has one of each per slot.
for (int m = 0; m < kMeasureMarks; ++m) {
const std::size_t mi = static_cast<std::size_t>(m);
r.measure_h[mi] = r.plot->add_h_ruler(kParked, plot::Color::hex(kMeasureColor));
r.measure_v[mi] = r.plot->add_cursor(kParked, plot::Color::hex(kMeasureColor));
// Readouts, not instruments: the pointer must not be able to hover or grab one. Parking
// them outside the axis and re-placing every frame does not stop a *drag* -- the
// controller holds the marker for the gesture's life, so it follows the pointer between
// frames and the readout looks draggable. Excluding it from the hit test is the only
// place that can say no.
r.plot->set_cursor_selectable(r.measure_h[mi], false);
r.plot->set_cursor_selectable(r.measure_v[mi], false);
// Thin and dashed: a measurement's mark is an *annotation over the data* -- a measured
// level, a peak, a pair of extremes -- and the solid 1.5 px chrome stroke it used to take
// made it read as another instrument framing the plot. The trigger marker below keeps that
// stroke on purpose: it marks an event the scope acted on, which is a different claim.
r.plot->set_cursor_stroke(r.measure_h[mi], kMeasureStrokeWidth,
plot::Vec2{kMeasureDashOn, kMeasureDashOff});
r.plot->set_cursor_stroke(r.measure_v[mi], kMeasureStrokeWidth,
plot::Vec2{kMeasureDashOn, kMeasureDashOff});
}
// The search hits' pool, parked, created for the same reason the measurement pool above is:
// `add_cursor()` only appends, so every mark a scan could ever want exists from the start.
for (int m = 0; m < kSearchMarks; ++m) {
const std::size_t mi = static_cast<std::size_t>(m);
r.search_v[mi] = r.plot->add_cursor(kParked, plot::Color::hex(kSearchColor));
r.plot->set_cursor_selectable(r.search_v[mi], false);
r.plot->set_cursor_stroke(r.search_v[mi], kMeasureStrokeWidth,
plot::Vec2{kMeasureDashOn, kMeasureDashOff});
}
r.trigger_marker = r.plot->add_cursor(kParked, plot::Color::hex(kTriggerColor));
// A marker, not an instrument: it must sit exactly on the edge's time, so its
// snap is Free and the controller never rounds it onto a sample.
r.plot->set_cursor_snap(r.trigger_marker, plot::SnapMode::Free);
// And it is a *readout* like the measurement marks above, for the same reason: it says where the
// scope put the edge, which is not something the pointer may drag. Parking it outside the axis
// does not stop a drag -- the controller holds the marker for the gesture's life and follows the
// pointer between frames -- so excluding it from the hit test is the only place that can say no.
r.plot->set_cursor_selectable(r.trigger_marker, false);
// No `set_x_follow(Live)`: the link group owns the window *and* the follow state, and
// `Figure::link_x()` refuses a member whose x axis still follows on its own.
}
void RowSet::set_row_weights(std::vector<float> weights) {
// The count is not checked here: `apply_row_weights()` sizes it to the rows that exist, so a layout
// with rows added or removed keeps working instead of silently losing its sizing.
row_weights_ = std::move(weights);
if (figure_ != nullptr) apply_row_weights();
}
void RowSet::apply_row_weights() {
if (row_weights_.empty() || figure_ == nullptr) return;
row_weights_.resize(static_cast<std::size_t>(rows_), 1.0f);
(void)figure_->set_row_weights(row_weights_);
}
void RowSet::rebuild_all() {
figure_ = std::make_unique<plot::Figure>(static_cast<plot::u32>(rows_));
figure_->set_row_gap(kRowGap);
apply_row_weights();
x_group_ = figure_->add_x_link();
for (int i = 0; i < kMaxRows; ++i) {
Row& r = row(i);
row_dirty_[static_cast<std::size_t>(i)] = false;
if (i >= rows_) {
r.plot = nullptr; // not a row of this figure: nothing to plot into
continue;
}
install(r, i, figure_->plot(static_cast<plot::u32>(i)));
place_cursors(r);
if (trigger_marker_on_) {
r.plot->set_cursor_x(r.trigger_marker, trigger_marker_x_, plot::CursorPlacement::Free);
}
figure_->link_x(static_cast<plot::u32>(i), x_group_);
}
apply_gutters();
// A new figure starts with no window at all, so the one the rows were showing has to
// be restated -- as the pin it was, if an event is on screen: assigning a trace or
// changing the row count while a capture is displayed must not throw the view back
// onto the live edge, which in trigger mode nothing would move it off again until the
// next latch.
if (pinned_) {
pin_event_window(pinned_pre_, pinned_post_);
} else {
set_time_window(window_s_);
}
}
bool RowSet::rebuild_if_dirty() {
bool any = false;
for (int i = 0; i < kMaxRows; ++i) {
if (row_dirty_[static_cast<std::size_t>(i)]) any = true;
}
if (!any) return false;
// One row's legend text changed, which `Plot` cannot express, so its traces have to
// be created again -- and a row belongs to a figure, so the figure is rebuilt. That
// costs the plots' CPU state and nothing else: the host keeps its GL context and is
// handed the new figure.
rebuild_all();
return true;
}
void RowSet::assign(int row_index, int slot, std::string signal_name,
std::string display_name, int color_index) {
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return;
Row& r = row(row_index);
const std::size_t s = static_cast<std::size_t>(slot);
r.signal_name[s] = std::move(signal_name);
r.channel[s] = -1; // re-resolved by the next bind()
r.name[s] = std::move(display_name);
r.color_index[s] = color_index;
// Whatever the slot held, it is a *signal's* trace now: a drop onto a slot a persistence layer
// was using is the user's arrangement, and the tick's plan moves the layers around it next frame.
r.layer[s] = 0;
r.slot_alpha[s] = 1.0f;
row_dirty_[static_cast<std::size_t>(row_index)] = true;
}
void RowSet::assign_layer(int row_index, int slot, std::string signal_name,
std::string display_name, int color_index, int age, float alpha) {
assign(row_index, slot, std::move(signal_name), std::move(display_name), color_index);
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return;
Row& r = row(row_index);
const std::size_t s = static_cast<std::size_t>(slot);
r.layer[s] = age;
r.slot_alpha[s] = alpha;
row_dirty_[static_cast<std::size_t>(row_index)] = true;
}
void RowSet::clear_layers() {
for (int i = 0; i < kMaxRows; ++i) {
Row& r = row(i);
for (int s = 0; s < kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
if (r.layer[si] == 0) continue;
assign(i, s, "", "", r.color_index[si]);
}
}
}
int RowSet::sync_layers(int depth, int* want) {
const int ages = std::clamp(depth, 1, kMaxPersistDepth) - 1;
// What each slot should hold: 0 a signal's own trace (kept as it is), -1 free, >0 a layer age.
std::array<std::array<int, kSlotsPerRow>, kMaxRows> plan{};
std::array<std::array<std::string, kSlotsPerRow>, kMaxRows> names{};
int wanted = 0;
for (int r = 0; r < rows_; ++r) {
Row& row_state = row(r);
for (int s = 0; s < kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
// A layer slot is free for re-planning; a signal's own trace is not.
plan[static_cast<std::size_t>(r)][si] =
row_state.layer[si] > 0 ? -1 : (row_state.signal_name[si].empty() ? -1 : 0);
}
}
// Layer by layer, as the accumulate path fills its own: age 1 for every signal first, then age 2,
// so a row with room for two layers draws the *sharper* one for each of its signals rather than
// four ages of the first one.
for (int age = 1; age <= ages; ++age) {
for (int r = 0; r < rows_; ++r) {
const std::size_t ri = static_cast<std::size_t>(r);
Row& row_state = row(r);
for (int s = 0; s < kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
if (plan[ri][si] != 0) continue;
++wanted;
int target = -1;
for (int t = 0; t < kSlotsPerRow; ++t) {
if (plan[ri][static_cast<std::size_t>(t)] == -1) {
target = t;
break;
}
}
if (target < 0) continue; // this row is full: it persists as far as it can
const std::size_t ti = static_cast<std::size_t>(target);
plan[ri][ti] = age;
names[ri][ti] = row_state.signal_name[si] + layer_suffix(age);
}
}
}
int placed = 0;
for (int r = 0; r < rows_; ++r) {
const std::size_t ri = static_cast<std::size_t>(r);
Row& row_state = row(r);
for (int s = 0; s < kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
const int now = plan[ri][si];
const int had = row_state.layer[si];
if (now > 0) {
++placed;
if (had == now && row_state.signal_name[si] == names[ri][si]) continue; // already right
assign_layer(r, s, names[ri][si], names[ri][si], row_state.color_index[si], now,
persist_alpha(now));
} else if (had > 0) {
assign(r, s, "", "", row_state.color_index[si]); // nothing wants this slot any more
}
}
}
if (want != nullptr) *want = wanted;
return placed;
}
int RowSet::layer_of(int row_index, int slot) const {
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return 0;
return row(row_index).layer[static_cast<std::size_t>(slot)];
}
float RowSet::slot_alpha_of(int row_index, int slot) const {
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return 1.0f;
return row(row_index).slot_alpha[static_cast<std::size_t>(slot)];
}
int RowSet::layer_slots() const {
int n = 0;
for (int i = 0; i < kMaxRows; ++i) {
const Row& r = row(i);
for (int s = 0; s < kSlotsPerRow; ++s) {
if (r.layer[static_cast<std::size_t>(s)] > 0) ++n;
}
}
return n;
}
std::vector<std::pair<int, float>> RowSet::row_layers(int row_index) const {
std::vector<std::pair<int, float>> out;
if (row_index < 0 || row_index >= kMaxRows) return out;
const Row& r = row(row_index);
for (int s = 0; s < kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
if (r.layer[si] > 0) out.push_back({r.layer[si], r.slot_alpha[si]});
}
// Oldest first: the order the decay has to read in.
std::sort(out.begin(), out.end(),
[](const auto& a, const auto& b) { return a.first > b.first; });
return out;
}
bool RowSet::slot_bound(int row_index, int slot) const {
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return false;
return !row(row_index).signal_name[static_cast<std::size_t>(slot)].empty();
}
int RowSet::channel_of(int row_index, int slot) const {
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return -1;
return row(row_index).channel[static_cast<std::size_t>(slot)];
}
void RowSet::bind(ScopeModel& model, DisplayMode mode) {
const std::vector<SignalInfo>& signals = model.signals();
const std::vector<ChannelInfo>& channels = model.channels();
for (int i = 0; i < kMaxRows; ++i) {
Row& r = row(i);
for (int s = 0; s < kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
if (r.signal_name[si].empty()) {
r.channel[si] = -1;
r.slots[si].bind(nullptr);
continue;
}
// Resolve the stable identity every frame: the channel list is rebuilt (and
// renumbered) whenever a signal is enabled, disabled or re-described, and a
// stored index would then point at a different signal's channel.
//
// A persistence layer resolves the name *under its suffix*: `sine (A)` is `sine`, one
// acquisition back, and it is the same signal's trace -- so it is acquired (or not) with its
// signal, drawn in its colour, and counted by nothing that counts signals.
const std::string base_name =
r.layer[si] > 0 ? layer_base_name(r.signal_name[si]) : r.signal_name[si];
int signal_index = -1;
for (std::size_t k = 0; k < signals.size(); ++k) {
if (signals[k].name == base_name) {
signal_index = static_cast<int>(k);
break;
}
}
int channel = -1;
if (signal_index >= 0) {
for (std::size_t c = 0; c < channels.size(); ++c) {
if (static_cast<int>(channels[c].signal) == signal_index) {
channel = static_cast<int>(c);
break;
}
}
}
if (r.layer[si] > 0) {
// A layer holds no channel: every reader that asks "is this slot a trace of a signal"
// -- the trace counts, the cursors pane's `mix` mode, the measurement marks, the layout
// writer -- tests `channel >= 0`, so a layer is invisible to all of them by construction.
r.channel[si] = -1;
// It draws only while a *snapshot* is on screen, and only if its signal is still acquired
// (switching a signal off takes its traces off every plot, layers included), and it
// resolves its channel **by name inside the snapshot**: the channel list may have been
// rebuilt and renumbered many times since that acquisition was cut, which is exactly why
// the trigger engine names its channel too.
std::shared_ptr<const EventSnapshot> snap;
if (channel >= 0 && (mode == DisplayMode::Held || mode == DisplayMode::Capturing)) {
snap = model.persist_event(static_cast<std::size_t>(r.layer[si]));
}
std::size_t layer_channel = snap != nullptr ? snap->channel_names.size() : 0;
if (snap != nullptr) {
for (std::size_t c = 0; c < snap->channel_names.size(); ++c) {
if (snap->channel_names[c] == base_name) {
layer_channel = c;
break;
}
}
}
if (snap != nullptr && layer_channel < snap->values.size()) {
r.slots[si].bind_event(std::move(snap), layer_channel);
} else {
r.slots[si].bind(nullptr);
}
continue;
}
r.channel[si] = channel;
if (channel < 0) {
// Its signal is present but not acquired: keep the identity (so enabling it
// brings the trace back) and plot nothing meanwhile.
r.slots[si].bind(nullptr);
continue;
}
// The source is the frame's one `DisplayMode`, decided by the caller: `Held` is
// the frozen snapshot, `Waiting` is blank (armed, nothing captured yet), and both
// `Live` and `Capturing` are the live ring -- what differs between those two is
// the *clock*, which the display epoch carries, not the binding.
const std::size_t ci = static_cast<std::size_t>(channel);
switch (mode) {
case DisplayMode::Paused:
// The view frozen at the pause. The ring behind it keeps filling, which is the point
// of a pause that does not stop acquisition.
if (auto frozen = model.pause_view()) {
r.slots[si].bind_event(std::move(frozen), ci);
} else {
r.slots[si].bind(nullptr);
}
break;
case DisplayMode::Held:
if (auto snap = model.event()) {
r.slots[si].bind_event(std::move(snap), ci);
} else {
r.slots[si].bind(nullptr);
}
break;
case DisplayMode::Waiting:
r.slots[si].bind(nullptr);
break;
case DisplayMode::Live:
case DisplayMode::Capturing:
r.slots[si].bind(model.live().shared_channel(ci));
break;
}
}
}
}
void RowSet::set_invalid_spans(ScopeModel& model, DisplayMode mode) {
spans_.clear();
if (mode == DisplayMode::Live || mode == DisplayMode::Capturing) {
// `Hole::x_begin`/`x_end` are stream seconds; the plots' x is `stream_t + epoch`.
const f64 epoch = model.display_epoch();
for (const Hole& h : model.holes()) {
// `repaired()` and not a second list: the band keeps reporting a repaired hole, in the
// fainter tint, until its slots age out of the ring and `forget_holes_before()` drops it.
spans_.push_back(plot::InvalidSpan{h.x_begin + epoch, h.x_end + epoch, h.repaired()});
}
}
for (int i = 0; i < rows_; ++i) {
Row& r = row(i);
// Every row shares the one x window, so every row gets the same spans: a hole is a gap in
// the stream and not in one signal.
if (r.plot) r.plot->set_invalid_spans(spans_);
}
}
bool RowSet::live_edge(double& newest) const {
bool any = false;
for (int i = 0; i < rows_; ++i) {
const Row& r = row(i);
if (!r.plot) continue;
double edge = 0.0;
// Follow-independent: it reads the row's streaming traces, so a row whose own axis
// is `Off` (which every member of a link group is) still reports its edge.
if (!r.plot->x_live_edge(r.axis_x, edge)) continue;
newest = any ? std::max(newest, edge) : edge;
any = true;
}
return any;
}
void RowSet::set_time_window(double seconds) {
window_s_ = std::max(0.05, seconds);
pinned_ = false;
if (!figure_) return;
figure_->set_x_link_follow(x_group_, plot::XFollow::Live);
double newest = 0.0;
if (live_edge(newest)) {
// Under a following group the range states the span and -- through its maximum --
// where the window sits behind the live edge, so a maximum *at* the edge is what
// makes the offset zero and parks the window on the newest sample. Asking for
// `{0, window}` here instead would state an offset of a whole epoch.
figure_->set_x_link_range(x_group_, {newest - window_s_, newest});
} else {
// No edge to read: there are no samples bound yet, which is the state every structural
// change leaves behind -- a re-plan drops the rings' history, and this function is called
// from `apply_time_window()`, which is what re-plans. So this is not a rare fallback; it
// is the axis a mode change is drawn with until the first sample lands.
//
// Which is why it states the window the same way the branch above does, with its maximum
// *at* the edge the live view puts at x = 0. `{0, window}` -- what it asked for before --
// is an offset of a whole window by the rule the comment above states, so the frames
// between the re-plan and the first sample drew the time base one screen to the left of
// where the following frames drew it. Two or three frames, every time an event or a
// waveform was opened: a trace that jumps a screen sideways and comes back is the
// "the time base flickers and it is unusable" report.
figure_->set_x_link_range(x_group_, {-window_s_, 0.0});
}
}
void RowSet::set_y_auto(int row_index, bool on) {
Row& r = row(row_index);
if (!r.plot) return;
r.plot->set_y_auto_scale(r.axis_y, on);
if (on) r.plot->auto_fit_y(r.axis_y);
}
void RowSet::set_y_range(int row_index, double lo, double hi) {
Row& r = row(row_index);
if (!r.plot) return;
const double a = std::min(lo, hi);
const double b = std::max(lo, hi);
if (!std::isfinite(a) || !std::isfinite(b) || !(b > a)) return;
r.plot->set_y_range(r.axis_y, {a, b});
}
void RowSet::place_cursors(Row& r, plot::CursorPlacement placement) {
if (!r.plot) return;
const bool on = cursors_on_;
// Which instruments the mode uses; the rest are parked (a `Plot` cannot drop a
// cursor, so hiding is the only way to take one off screen).
const bool vertical = on && cursor_mode_ != CursorMode::Horizontal;
const bool horizontal = on && (cursor_mode_ == CursorMode::Horizontal ||
cursor_mode_ == CursorMode::Both);
const double a = vertical ? cursor_a_x_ : kParked;
const double b = vertical ? cursor_b_x_ : kParked;
r.plot->set_cursor_x(r.cursor_a, a, placement);
r.plot->set_cursor_x(r.cursor_b, b, placement);
r.plot->set_cursor_y(r.ruler, horizontal ? r.ruler_y : kParked, placement);
r.plot->set_cursor_y(r.ruler_b, horizontal ? r.ruler_b_y : kParked, placement);
}
void RowSet::set_cursor_mode(CursorMode mode) {
if (mode == cursor_mode_) return;
cursor_mode_ = mode;
for (int i = 0; i < kMaxRows; ++i) place_cursors(row(i));
place_crossings();
}
void RowSet::place_crossings() {
const bool mix = cursor_mode_ == CursorMode::Mix && cursors_visible();
for (int i = 0; i < kMaxRows; ++i) {
Row& r = row(i);
if (!r.plot) continue;
for (int s = 0; s < kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
const plot::u32 ida = r.crossing_a[si];
const plot::u32 idb = r.crossing_b[si];
if (ida >= r.plot->annotation_count() || idb >= r.plot->annotation_count()) continue;
plot::Annotation a = r.plot->annotation(ida);
plot::Annotation b = r.plot->annotation(idb);
double xa = 0.0;
double ya = 0.0;
double xb = 0.0;
double yb = 0.0;
// `crosshair_point()`, not `crosshair_value()`: the number beside this dot is a *stored
// sample's*, so the dot belongs on that sample and not on the marker's own column. The
// two differ by up to half a sample period, which is nothing at a whole-window view and
// the whole width of the panel once the view is zoomed to a handful of samples -- the
// state in which a reader is asking "which point is that?" in the first place. It leaves
// the dot a little off its vertical line by design: the line says *where the marker is*
// and the dot says *which datum is being quoted*, and when the marker is between two
// samples those are not the same place.
const bool has_a =
mix && r.plot->crosshair_point(static_cast<plot::u32>(s), cursor_a_x_, xa, ya);
const bool has_b =
mix && r.plot->crosshair_point(static_cast<plot::u32>(s), cursor_b_x_, xb, yb);
a.visible = has_a;
b.visible = has_b;
if (has_a) {
a.x = xa;
a.y = ya;
}
if (has_b) {
b.x = xb;
b.y = yb;
}
r.plot->set_annotation(ida, a);
r.plot->set_annotation(idb, b);
}
}
}
double RowSet::ruler_y(int r) const {
return r >= 0 && r < rows_ ? row(r).ruler_y : 0.0;
}
double RowSet::ruler_b_y(int r) const {
return r >= 0 && r < rows_ ? row(r).ruler_b_y : 0.0;
}
int RowSet::crossings_visible() const {
int n = 0;
for (int i = 0; i < rows_; ++i) {
const Row& r = row(i);
if (!r.plot) continue;
for (int s = 0; s < kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
if (r.crossing_a[si] < r.plot->annotation_count() &&
r.plot->annotation(r.crossing_a[si]).visible) {
++n;
}
if (r.crossing_b[si] < r.plot->annotation_count() &&
r.plot->annotation(r.crossing_b[si]).visible) {
++n;
}
}
}
return n;
}
bool RowSet::crossing_at(int i, int slot, bool second, double& x, double& y) const {
if (i < 0 || i >= rows_ || slot < 0 || slot >= kSlotsPerRow) return false;
const Row& r = row(i);
if (!r.plot) return false;
const std::size_t si = static_cast<std::size_t>(slot);
const plot::u32 id = second ? r.crossing_b[si] : r.crossing_a[si];
if (id >= r.plot->annotation_count()) return false;
const plot::Annotation& a = r.plot->annotation(id);
if (!a.visible) return false;
x = a.x;
y = a.y;
return true;
}
void RowSet::set_measure_marks(const std::vector<MeasureMark>& marks) {
measure_marks_placed_ = static_cast<int>(marks.size());
for (int i = 0; i < kMaxRows; ++i) {
Row& r = row(i);
if (!r.plot) continue;
int h = 0;
int v = 0;
for (const MeasureMark& m : marks) {
if (m.row != i) continue;
if (m.level) {
if (h >= kMeasureMarks) continue;
const std::size_t mi = static_cast<std::size_t>(h++);
r.plot->set_cursor_y(r.measure_h[mi], m.value, plot::CursorPlacement::Free);
r.plot->set_cursor_color(r.measure_h[mi], plot::Color::hex(m.color));
} else {
if (v >= kMeasureMarks) continue;
const std::size_t mi = static_cast<std::size_t>(v++);
r.plot->set_cursor_x(r.measure_v[mi], m.value, plot::CursorPlacement::Free);
r.plot->set_cursor_color(r.measure_v[mi], plot::Color::hex(m.color));
}
}
// What the measurements left over is parked rather than kept: a stale readout at the old
// window's position is worse than none.
for (; h < kMeasureMarks; ++h) {
r.plot->set_cursor_y(r.measure_h[static_cast<std::size_t>(h)], kParked);
}
for (; v < kMeasureMarks; ++v) {
r.plot->set_cursor_x(r.measure_v[static_cast<std::size_t>(v)], kParked);
}
}
}
void RowSet::set_search_marks(const std::vector<double>& xs, int current) {
const int n = static_cast<int>(xs.size()) < kSearchMarks ? static_cast<int>(xs.size())
: kSearchMarks;
search_marks_placed_ = n;
for (int i = 0; i < kMaxRows; ++i) {
Row& r = row(i);
if (!r.plot) continue;
// Every row, not the hit's own row: a hit is an *instant on the shared x window*, so the
// mark lines up down the column the way the vertical cursors do. There is no "the hit's
// row" to pick -- the condition was found on the trigger's channel, which may not be
// plotted at all.
for (int m = 0; m < n; ++m) {
const std::size_t mi = static_cast<std::size_t>(m);
r.plot->set_cursor_x(r.search_v[mi], xs[mi], plot::CursorPlacement::Free);
// The colour is re-set every frame rather than only when the selection moves: the pool is
// re-ordered each frame as the view rolls, so slot `m` is not the same hit it was.
r.plot->set_cursor_color(r.search_v[mi],
plot::Color::hex(m == current ? kSearchCurrentColor
: kSearchColor));
}
for (int m = n; m < kSearchMarks; ++m) {
r.plot->set_cursor_x(r.search_v[static_cast<std::size_t>(m)], kParked);
}
}
}
bool RowSet::update_time_labels() {
Row& first = row(0);
if (!first.plot || !figure_) return false;
const plot::Range view = first.plot->x_range(first.axis_x);
const double span = view.span();
if (!(span > 0.0) || !std::isfinite(span)) return false;
// A relative axis is the answer to one problem only: a tick step the label form cannot write.
// `TickFormat::Time` carries three significant digits, so a 1 ms step around a 12 s stamp
// writes `12.3s` on every tick -- there the ticks have to be re-anchored and the anchor named.
// The test is therefore the *step against the resolution at the axis' own magnitude*, never the
// span: the two part company in both directions, and both cases are real here. A 2 s window at
// twenty minutes of uptime is labelled `20:00` throughout and needs the anchor although its
// span is wide; a 50 ms trigger window spanning [-25ms, +25ms] is labelled `-20ms .. +20ms`
// perfectly well and needs nothing, although its span is narrow. That second case is what made
// a triggered sub-second view carry a `t0` caption *and* re-anchor its ticks away from the
// edge, which is the one position a triggered axis exists to show.
// The 1.05 band is the switch's hysteresis, so a step sitting on the boundary cannot flicker
// between two forms while a zoom is nudged, and the `1 - 1e-9` is a slack the *entry* needs for
// its own reason: a step read back from two tick values and a resolution from `pow(10, n)`
// differ in their last bits even when they are the same number, and `step == res` is a readable
// axis (`1.73s, 1.74s, 1.75s` -- one in the last digit is exactly enough), so a decision taken
// on those bits picked a different form from one frame to the next.
const double mag = std::max(std::fabs(view.min), std::fabs(view.max));
const double res = time_label_resolution(mag);
// The step the labels are actually written at: the majors the *last* layout produced. They are
// a function of the range and the axis' pixel width, not of the label form, so reading them
// back here is no feedback loop.
double step = 0.0;
{
const plot::Axis& ax = first.plot->x_axis(first.axis_x);
double prev = 0.0;
bool have = false;
for (const plot::Tick& t : ax.ticks) {
if (!t.major) continue;
if (have) {
step = t.value - prev;
break;
}
prev = t.value;
have = true;
}
}
// No ticks yet -- the frames before the first layout. The form is a property of the labels, so
// with no labels there is nothing to decide, and keeping the current one is right where
// guessing a step from the span is not: a guess that entered the relative form would be
// *latched* there by the hysteresis above.
const bool want_relative =
step > 0.0 ? step < res * (relative_x_ ? 1.05 : 1.0 - 1.0e-9) : relative_x_;
const bool changed = want_relative != relative_x_;
relative_x_ = want_relative;
for (int i = 0; i < kMaxRows; ++i) {
Row& r = row(i);
if (!r.plot) continue;
// The axis takes the origin *from its own range* at refresh while relative: the host cannot
// predict where the follow will place the window (that is derived from the newest sample at
// layout time), and a value written here is a frame of scroll stale -- more than a
// millisecond-scale window shows. `x_origin_` is read back below for the caption.
r.plot->set_x_axis_origin_from_range(r.axis_x, relative_x_);
if (!relative_x_) r.plot->set_x_axis_origin(r.axis_x, 0.0);
// The caption belongs with the labels, which a figure draws once, on the bottom row (the
// one whose x axis is the figure's). Every other row gets an empty one, which draws nothing.
const bool bottom = i == rows_ - 1;
if (!relative_x_ || !bottom) {
r.plot->set_x_note("");
continue;
}
char text[64];
// The anchor the *drawn* labels were built with: the axis' own origin, which the last
// layout set from its range. Reading it back (rather than keeping our own copy) is what
// makes the reader's addition exact -- label plus caption is that tick's absolute time.
const double shown = r.plot->x_axis_origin(r.axis_x);
x_origin_ = shown;
std::snprintf(text, sizeof(text), "t0 = %.6g s", shown);
// Chrome, not an annotation: an annotation is anchored to a *data* coordinate, so the caption
// crawled across the plot as the view panned and drifted as it zoomed -- and it came with the
// annotation's own pin cross, which pointed at nothing. `set_x_note()` places it in the axes'
// bottom-left corner in pixels, on the legend's backplate, which is where the labels it
// qualifies are.
r.plot->set_x_note(text);
}
return changed;
}
void RowSet::hold_pinned_window() {
if (!figure_ || !pinned_) return;
// `Off` as well as the range: a rebuild or a mode change may have left the group following,
// and a following group would re-derive the window from a live edge on the next layout --
// ignoring the pin altogether.
figure_->set_x_link_follow(x_group_, plot::XFollow::Off);
const plot::Range pin = pinned_range();
// The *request*: what the user's gesture left on the row's own axis. A member's `set_x_range`
// -- what the controller writes -- is not adopted into the group while the group is not
// following, so reading the group here only ever saw the previous clamp's answer and the wheel
// did nothing at all. The group is preferred when the member has *not* moved since our own last
// statement, which is what keeps a fresh pin (`pin_event_window()`, `pin_pause_window()`, both
// of which write the group in this same tick) from being reverted to the member's stale window.
plot::Range now = figure_->x_link_range(x_group_);
if (rows_ > 0 && row(0).plot != nullptr) {
const plot::Range member = row(0).plot->x_range(row(0).axis_x);
const bool member_is_ours = std::abs(member.min - pinned_stated_.min) < 1e-12 &&
std::abs(member.max - pinned_stated_.max) < 1e-12;
if (!member_is_ours) now = member;
}
const double pin_span = pin.span();
// **A zoom *in* is allowed.** The rule is that the view may not leave the time window, and a
// window narrower than the event's is still inside it -- it shows less of the same event. This
// used to re-state the pin every frame, which made the wheel do nothing at all in single
// trigger mode, so an acquired event could not be examined. What is clamped is the *span* (at
// the pin's) and the *position* (inside it): together, "never outside".
if (!(pin_span > 0.0) || !std::isfinite(pin_span)) return;
const double span = std::clamp(now.span(), std::min(1.0e-6, pin_span), pin_span);
const double lo = std::clamp(now.min, pin.min, pin.max - span);
pinned_stated_ = plot::Range{lo, lo + span};
figure_->set_x_link_range(x_group_, pinned_stated_);
}
void RowSet::pin_pause_window(double lo, double hi) {
// The pin is the **snapshot's own span**, not the window that was requested when the pause began.
// The cut is clamped to what the ring holds, so a window that reaches back past the oldest
// retained sample produces a *shorter* snapshot -- and pinning the axis to the request left the
// difference as an empty margin on the left, visible before any zoom and made worse by one
// (zooming in moved the view *into* it). The event pin already works this way ("the event axis is
// pinned from the snapshot's own stamps, not from the request"); this is the pause agreeing.
const plot::Range pin{lo, hi};
if (!(pin.span() > 0.0) || !std::isfinite(pin.min) || !std::isfinite(pin.max)) return;
for (int i = 0; i < kMaxRows; ++i) {
Row& r = row(i);
if (!r.plot) continue;
r.plot->set_x_follow(r.axis_x, plot::XFollow::Off);
r.plot->set_x_range(r.axis_x, pin);
}
set_data_pin(pin.min, pin.max);
if (figure_) {
figure_->set_x_link_follow(x_group_, plot::XFollow::Off);
figure_->set_x_link_range(x_group_, pin);
}
}
bool RowSet::readouts_locked() const noexcept {
for (int i = 0; i < kMaxRows; ++i) {
const Row& r = row(i);
if (!r.plot) continue;
if (r.plot->cursor(r.trigger_marker).selectable) return false;
for (const plot::u32 id : r.measure_h) {
if (r.plot->cursor(id).selectable) return false;
}
for (const plot::u32 id : r.measure_v) {
if (r.plot->cursor(id).selectable) return false;
}
for (const plot::u32 id : r.search_v) {
if (r.plot->cursor(id).selectable) return false;
}
}
return true;
}
bool RowSet::event_data_window(double& lo, double& hi) const {
bool have = false;
for (int i = 0; i < rows_; ++i) {
const Row& r = row(i);
if (!r.plot) continue;
for (int s = 0; s < kSlotsPerRow; ++s) {
// A persistence layer is bound to an event series too, and it is the *past*: letting one
// widen this window would move the pin -- and with it the framing and the numbers a pinned
// measurement reads -- every time an older acquisition happened to be wider than the one on
// screen. What is pinned is the current event's window, and only that.
if (r.layer[static_cast<std::size_t>(s)] > 0) continue;
const EventSeries* ev = r.slots[static_cast<std::size_t>(s)].bound_event();
if (ev == nullptr || ev->size() < 2) continue;
const plot::Range b = ev->x_bounds();
if (!std::isfinite(b.min) || !std::isfinite(b.max) || !(b.max > b.min)) continue;
lo = have ? std::min(lo, b.min) : b.min;
hi = have ? std::max(hi, b.max) : b.max;
have = true;
}
}
return have;
}
void RowSet::set_data_pin(double lo, double hi) {
if (!(hi > lo) || !std::isfinite(lo) || !std::isfinite(hi)) return;
pinned_ = true;
pinned_pre_ = -lo;
pinned_post_ = hi;
}
void RowSet::unpin_pause_window() {
// The same path the window control and a go-live take: re-arm the follow and frame the newest
// span, so a resume lands on the data that arrived while paused rather than on the frozen
// frame's left edge.
set_time_window(window_s_);
}
void RowSet::fit_x() {
if (!figure_) return;
if (!pinned_) {
// Live: the same path the window control and a resume take, with the window it already has.
// That re-arms the follow and parks the newest `window_s_` at the live edge, which is the
// whole of what a live view may show.
set_time_window(window_s_);
return;
}
// Pinned (a held event or a paused view): the pin's full range, and the pin stays. The
// *members* have to be written as well as the group, because `hold_pinned_window()` prefers a
// member that has moved since our own last statement -- that is how it tells a gesture from its
// own echo -- so writing the group alone would be reverted to the zoomed-in member next frame.
const plot::Range pin = pinned_range();
if (!(pin.span() > 0.0) || !std::isfinite(pin.min) || !std::isfinite(pin.max)) return;
for (int i = 0; i < rows_; ++i) {
Row& r = row(i);
if (r.plot) r.plot->set_x_range(r.axis_x, pin);
}
pinned_stated_ = pin;
figure_->set_x_link_range(x_group_, pin);
}
void RowSet::sync_cursors(int drag_row) {
if (!cursors_visible() || drag_row < 0 || drag_row >= rows_) return;
// Only a mode that *shows* the vertical pair reads them back. With them parked (the
// `horizontal` mode) whatever the plot reports is our own park coming back -- and a
// snapped cursor parked far outside the axis reads back as something finite, which
// would be adopted as a gesture and destroy the shared times.
Row& r = row(drag_row);
if (!r.plot) return;
// A gesture writes the *plot*; while it runs, this is its only visible result. A ruler is its
// own row's value -- nothing to share, but it still has to be read back or the pane would
// keep showing wherever the app last put it.
const bool rulers = cursor_mode_ == CursorMode::Horizontal || cursor_mode_ == CursorMode::Both;
if (rulers) {
const double y = r.plot->cursor(r.ruler).y;
const double yb = r.plot->cursor(r.ruler_b).y;
if (y > kParked * 0.5) r.ruler_y = y;
if (yb > kParked * 0.5) r.ruler_b_y = yb;
}
bool push = false;
if (cursor_mode_ != CursorMode::Horizontal) {
const double a = r.plot->cursor(r.cursor_a).x;
const double b = r.plot->cursor(r.cursor_b).x;
if (a > kParked * 0.5 && b > kParked * 0.5) {
if (a != cursor_a_x_) {
cursor_a_x_ = a;
push = true;
}
if (b != cursor_b_x_) {
cursor_b_x_ = b;
push = true;
}
}
}
if (push) {
// `Free`: the value came out of the plot a moment ago and is already the free position the
// controller is tracking; snapping it here would fight the hand that is still moving it.
for (int i = 0; i < rows_; ++i) place_cursors(row(i), plot::CursorPlacement::Free);
}
place_crossings();
}
void RowSet::clamp_to_data() {
if (!figure_) return;
// The rows share one window, so they share one bound: the union of what they hold.
// Taking each row's own would let a row whose signal was enabled a moment ago clamp
// the window every row is looking at.
plot::Range data{};
bool have = false;
for (int i = 0; i < rows_; ++i) {
Row& r = row(i);
if (!r.plot) continue;
const plot::data::Series* series = nullptr;
for (int s = 0; s < kSlotsPerRow && series == nullptr; ++s) {
if (!r.signal_name[static_cast<std::size_t>(s)].empty()) {
series = r.slots[static_cast<std::size_t>(s)].bound();
}
}
if (series == nullptr || series->size() < 2) continue;
const plot::Range b = series->x_bounds();
if (!std::isfinite(b.min) || !std::isfinite(b.max) || !(b.max > b.min)) continue;
data = have ? plot::Range{std::min(data.min, b.min), std::max(data.max, b.max)} : b;
have = true;
}
if (!have) return;
const double span = figure_->x_link_range(x_group_).span();
if (!(span > 0.0) || !std::isfinite(span)) return;
// The bound is the **requested window itself** -- what the window control states, capped by
// `--max-window` -- and not `retained`, how much the ring happens to hold.
//
// The difference is the whole point of this function, and both halves of it have bitten:
// clamping to `retained` alone broke the *early* case, because the first frames after a signal
// is enabled hold a couple of hundred samples, a requested 2 s window was clamped to the
// ~0.02 s they covered, and nothing here ever grows a span back -- the view stayed pinned to
// the newest 20 ms of a 6.5 s buffer for the rest of the run. Loosening the bound to
// `max(retained, window_s_)` fixed that and broke the *late* case instead: while the ring is
// still filling the two are equal, but once it holds more than the window the bound becomes the
// whole ring, so zooming out walked the view past the window into data the window was never set
// to show -- "more points than the time window in the buffer, and it still zooms out of it".
// `window_s_` is the right bound in both states: it never shrinks a window the caller asked
// for, and it is the app's own statement of what the view *is*. A partly filled buffer is left
// with an empty left-hand side, which is what a scope that just started should look like.
//
// The span only. A pan is the group's `offset` behind its live edge and the group
// clamps it against what its members retain on every layout, so there is nothing
// here to do about `view.min < data.min` -- and doing it anyway would state a maximum
// away from the live edge, i.e. ask the group to scroll back.
if (!(window_s_ > 0.0)) return;
// A *relative* tolerance, not an exact comparison: the range handed to the group is exactly
// `window_s_` wide, but the group's span comes back through f64 and can land an ULP above it --
// and then a clamp that re-frames on `span > window_s_` re-frames *every frame*, which is a
// flickering view rather than a clamped one. The tolerance is far below a pixel of data.
if (span > window_s_ * (1.0 + 1.0e-9)) {
++clamp_fires_;
figure_->set_x_link_range(x_group_, {data.max - window_s_, data.max});
}
}
void RowSet::pin_event_window(double pre_s, double post_s) {
window_s_ = std::max(1.0e-3, pre_s + post_s);
pinned_ = true;
pinned_pre_ = pre_s;
pinned_post_ = post_s;
if (!figure_) return;
// The captured event sits at [-pre, +post] with the edge at 0, and the bound series is
// the immutable snapshot. The group must stop following first: Live re-derives the
// window from a live edge, and with nothing streaming it would keep placing the window
// from the last stale edge instead of the event.
figure_->set_x_link_follow(x_group_, plot::XFollow::Off);
figure_->set_x_link_range(x_group_, {-pre_s, post_s});
}
void RowSet::fit_all() {
for (int i = 0; i < rows_; ++i) {
Row& r = row(i);
if (r.plot) r.plot->auto_fit_y(r.axis_y);
}
// The x half is `fit_x()`'s exactly -- the zoom undone, the time window untouched -- and
// sharing it is what keeps the two fits from drifting apart.
fit_x();
}
void RowSet::note_y_gutter(double need_px) {
// No slack added here, and that is load-bearing: `need_px` is the gutter the row was
// *laid out with*, which is already the maximum of its labels and the floor imposed last
// frame -- so a few pixels added to it are added again next frame. The `+ 6.0` this used
// to carry made the gutter a ratchet that climbed 6 px a frame until it hit the cap
// below, which is the whole reason the left margin was `200` of an 870 px viewport (and
// the `axes.w changes: 17` beside it). The label padding the reservation below adds is
// the slack, once.
double want = std::min(need_px, 200.0);
// Reserve room for the widest label the formatter can produce, once, with the
// font the labels are actually drawn in.
if (!y_gutter_reserved_) {
for (int i = 0; i < rows_; ++i) {
Row& r = row(i);
if (!r.plot) continue;
const plot::render::TextBackend* text = r.plot->text_backend();
if (text == nullptr) continue;
const plot::render::Style& style = r.plot->style();
const double pad = double(style.device(style.grid_label_pad));
// A sign, three significant figures and a one-glyph SI prefix: the widest label
// `format_si()` writes for an axis with no unit, measured rather than counted in
// characters. Its `%.3g` fallback is wider (`-1.23e-45`) and was what this reserved,
// which cost a *third* of the gutter for a label no signal on a `Si` axis produces --
// it needs a value past 1e24, and `note_y_gutter()` is a monotone maximum over the
// measured need, so one that did appear widens the gutter for good anyway.
want = std::max(want, double(text->measure("-1.23m")) + 2.0 * pad + 2.0);
// The other side, from the same metrics. An x tick label is *centred* on its tick and
// the newest tick sits on the axes' right edge (a live axis is [-window, 0]), so half
// the widest time label is what the rightmost one needs to be drawn at all -- and that
// is also the band which keeps the newest sample's ink off the frame.
right_gutter_px_ = 0.5 * double(text->measure("-1.23ms")) + pad;
y_gutter_reserved_ = true;
break;
}
}
if (want > y_gutter_px_) y_gutter_px_ = want;
}
void RowSet::apply_gutters() {
if (!figure_) return;
if (y_gutter_px_ > 0.0) figure_->set_min_y_gutter(static_cast<f32>(y_gutter_px_));
figure_->set_right_gutter(static_cast<f32>(right_gutter_px_));
}
void RowSet::set_trigger_marker(double x, bool visible) {
trigger_marker_on_ = visible;
trigger_marker_x_ = x;
const double want = visible ? x : kParked;
for (int i = 0; i < rows_; ++i) {
Row& r = row(i);
if (!r.plot) continue;
// Re-assert against what the plot *holds*, not against a cached value: the
// marker is a cursor, so the input layer will happily drag it, and a marker the
// user can move off the trigger instant is not marking anything. Comparing
// with the plot also keeps the steady state write-free (a write invalidates the
// plot's cached measurements).
if (r.plot->cursor(r.trigger_marker).x == want) continue;
r.plot->set_cursor_x(r.trigger_marker, want, plot::CursorPlacement::Free);
}
}
void RowSet::set_cursors_visible(bool on) {
cursors_on_ = on;
for (int i = 0; i < kMaxRows; ++i) place_cursors(row(i));
}
void RowSet::reset_cursors() {
for (int i = 0; i < kMaxRows; ++i) {
Row& r = row(i);
if (!r.plot) continue;
const plot::Range xr = r.plot->x_range(r.axis_x);
const double span = xr.span() > 0.0 ? xr.span() : window_s_;
cursor_a_x_ = xr.min + 0.25 * span;
cursor_b_x_ = xr.min + 0.75 * span;
const double y_lo = r.plot->y_range(r.axis_y).min;
const double y_span = r.plot->y_range(r.axis_y).span();
r.ruler_y = y_lo + 0.35 * (y_span > 0.0 ? y_span : 1.0);
r.ruler_b_y = y_lo + 0.65 * (y_span > 0.0 ? y_span : 1.0);
}
// Every row's instruments are written from what was just set. Without this the plots
// keep their build-time cursors (x = 0) and `sync_cursors()` -- which reads the plots
// because a *gesture* writes them -- adopts that zero as a moved cursor.
for (int i = 0; i < kMaxRows; ++i) place_cursors(row(i));
place_crossings();
}
} // namespace udpscope