671 lines
27 KiB
C++
671 lines
27 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;
|
|
|
|
constexpr double kParked = -1.0e12; ///< hides an instrument without removing it
|
|
|
|
/// 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;
|
|
|
|
} // namespace
|
|
|
|
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); }
|
|
|
|
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;
|
|
|
|
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.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(
|
|
{"time", {-1.0, 1.0}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
|
|
r.axis_y = r.plot->add_y_axis(
|
|
{"value", {-1.0, 1.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
|
|
for (int s = 0; s < kSlotsPerRow; ++s) {
|
|
const std::size_t i = static_cast<std::size_t>(s);
|
|
r.plot->add_trace(
|
|
{&r.slots[i], palette_color(r.color_index[i]), 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);
|
|
}
|
|
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);
|
|
// 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::rebuild_all() {
|
|
figure_ = std::make_unique<plot::Figure>(static_cast<plot::u32>(rows_));
|
|
figure_->set_row_gap(kRowGap);
|
|
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_);
|
|
}
|
|
if (y_gutter_px_ > 0.0) apply_y_gutter();
|
|
// 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;
|
|
row_dirty_[static_cast<std::size_t>(row_index)] = true;
|
|
}
|
|
|
|
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.
|
|
int signal_index = -1;
|
|
for (std::size_t k = 0; k < signals.size(); ++k) {
|
|
if (signals[k].name == r.signal_name[si]) {
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
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::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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
figure_->set_x_link_range(x_group_, {0.0, window_s_});
|
|
}
|
|
}
|
|
|
|
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) {
|
|
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);
|
|
r.plot->set_cursor_x(r.cursor_a, vertical ? cursor_a_x_ : kParked);
|
|
r.plot->set_cursor_x(r.cursor_b, vertical ? cursor_b_x_ : kParked);
|
|
r.plot->set_cursor_y(r.ruler, horizontal ? r.ruler_y : kParked);
|
|
r.plot->set_cursor_y(r.ruler_b, horizontal ? r.ruler_b_y : kParked);
|
|
}
|
|
|
|
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 ya = 0.0;
|
|
double yb = 0.0;
|
|
const bool has_a = mix && r.plot->crosshair_value(static_cast<plot::u32>(s), cursor_a_x_, ya);
|
|
const bool has_b = mix && r.plot->crosshair_value(static_cast<plot::u32>(s), cursor_b_x_, yb);
|
|
a.visible = has_a;
|
|
b.visible = has_b;
|
|
if (has_a) {
|
|
a.x = cursor_a_x_;
|
|
a.y = ya;
|
|
}
|
|
if (has_b) {
|
|
b.x = cursor_b_x_;
|
|
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::fit_x() {
|
|
Row& first = row(0);
|
|
if (!first.plot || !figure_) return;
|
|
first.plot->auto_fit_x();
|
|
const plot::Range fitted = first.plot->x_range(first.axis_x);
|
|
const double span = fitted.span();
|
|
if (!std::isfinite(span) || !(span > 0.0)) return;
|
|
// The same two paths `fit_all()` takes: a held event owns its window and states it
|
|
// through the link group, a live view re-arms the follow and frames the newest span.
|
|
if (pinned_) {
|
|
window_s_ = span;
|
|
figure_->set_x_link_range(x_group_, fitted);
|
|
} else {
|
|
set_time_window(span);
|
|
}
|
|
}
|
|
|
|
void RowSet::sync_cursors() {
|
|
if (!cursors_visible()) 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.
|
|
// Anything within this much of what we last pushed is our own placement coming back
|
|
// (the plot stores f64, so an exact compare would be brittle across a rebuild).
|
|
const auto moved = [](double a, double b) {
|
|
return std::fabs(a - b) > 1.0e-9 * std::max(1.0, std::fabs(b));
|
|
};
|
|
bool push = false;
|
|
for (int i = 0; i < rows_; ++i) {
|
|
Row& r = row(i);
|
|
if (!r.plot) continue;
|
|
// A gesture writes the *plot*; this is the only place its result is visible. 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;
|
|
}
|
|
if (cursor_mode_ == CursorMode::Horizontal) continue; // the pair is parked: not a value
|
|
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) continue; // parked: not a value
|
|
if (moved(a, cursor_a_x_)) {
|
|
cursor_a_x_ = a;
|
|
push = true;
|
|
}
|
|
if (moved(b, cursor_b_x_)) {
|
|
cursor_b_x_ = b;
|
|
push = true;
|
|
}
|
|
}
|
|
if (push) {
|
|
for (int i = 0; i < rows_; ++i) place_cursors(row(i));
|
|
}
|
|
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 retained = data.max - data.min;
|
|
const double span = figure_->x_link_range(x_group_).span();
|
|
if (!(span > 0.0) || !std::isfinite(span)) return;
|
|
// The *requested* window is intent, and a buffer that has not filled it yet must not
|
|
// be allowed to redefine it. This runs every frame and cannot tell "the user zoomed
|
|
// out past the data" from "the ring has only just started filling", and the first
|
|
// frames after a signal is enabled hold a couple of hundred samples: clamping to
|
|
// those shrank a requested 2 s window to ~0.02 s, and nothing here ever grows a span
|
|
// back, so the view stayed pinned to the newest 20 ms of a 6.5 s buffer for the rest
|
|
// of the run. That is the loudest form of the "live trace is clipped, only the most
|
|
// recent part is visible, data is there because the crosshair reads it" defect -- the
|
|
// data was never clipped at all, the window was. Clamping against `max(retained,
|
|
// window_s_)` leaves a partly filled window empty on its left, 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.
|
|
const double limit = std::max(retained, window_s_);
|
|
if (span > limit) figure_->set_x_link_range(x_group_, {data.max - limit, 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);
|
|
}
|
|
Row& first = row(0);
|
|
if (!first.plot || !figure_) return;
|
|
// Row 0 fits its own x to the data it holds; that span becomes the shared window, and
|
|
// stating it through the group is what puts every row on it.
|
|
first.plot->auto_fit_x();
|
|
const plot::Range fitted = first.plot->x_range(first.axis_x);
|
|
const double span = fitted.span();
|
|
if (!std::isfinite(span) || !(span > 0.0)) return;
|
|
if (pinned_) {
|
|
// Fitting a captured event: the window is the snapshot's own `[-pre, +post]` span,
|
|
// and `set_time_window()` would re-arm the follow and re-frame on a live edge that
|
|
// trigger mode has nothing bound to.
|
|
window_s_ = span;
|
|
figure_->set_x_link_range(x_group_, fitted);
|
|
} else {
|
|
set_time_window(span);
|
|
}
|
|
}
|
|
|
|
void RowSet::note_y_gutter(double need_px) {
|
|
double want = std::min(need_px + 6.0, 200.0);
|
|
// Reserve room for the widest label the formatter can produce, once, with the
|
|
// font the labels are actually drawn in (the embedded font is monospaced, so a
|
|
// character count is an exact width).
|
|
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 advance = double(text->measure("0123456789")) / 10.0;
|
|
const double pad = double(style.device(style.grid_label_pad));
|
|
// "-1.23e-45" is the longest label `format_si()`'s fallback can print.
|
|
want = std::max(want, 9.0 * advance + 2.0 * pad + 2.0);
|
|
y_gutter_reserved_ = true;
|
|
break;
|
|
}
|
|
}
|
|
if (want > y_gutter_px_) y_gutter_px_ = want;
|
|
}
|
|
|
|
void RowSet::apply_y_gutter() {
|
|
if (!figure_ || !(y_gutter_px_ > 0.0)) return;
|
|
figure_->set_min_y_gutter(static_cast<f32>(y_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
|