// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari #include "plot_host.hpp" #include #include 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(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(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(s)] = r.plot->add_annotation(a); r.crossing_b[static_cast(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(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(static_cast(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(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(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(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(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(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(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(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(slot)]; } void RowSet::bind(ScopeModel& model, DisplayMode mode) { const std::vector& signals = model.signals(); const std::vector& 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(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(k); break; } } int channel = -1; if (signal_index >= 0) { for (std::size_t c = 0; c < channels.size(); ++c) { if (static_cast(channels[c].signal) == signal_index) { channel = static_cast(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(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(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(s), cursor_a_x_, ya); const bool has_b = mix && r.plot->crosshair_value(static_cast(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(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(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& marks) { measure_marks_placed_ = static_cast(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(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(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(h)], kParked); } for (; v < kMeasureMarks; ++v) { r.plot->set_cursor_x(r.measure_v[static_cast(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(s)].empty()) { series = r.slots[static_cast(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(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