// 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; /// 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(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(s)] != signal) continue; row_dirty_[static_cast(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 order{}; for (int s = 0; s < kSlotsPerRow; ++s) order[static_cast(s)] = s; std::stable_sort(order.begin(), order.end(), [&r](int a, int b) { return r.slot_alpha[static_cast(a)] < r.slot_alpha[static_cast(b)]; }); for (const int s : order) { const std::size_t i = static_cast(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(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); // 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(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 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(rows_), 1.0f); (void)figure_->set_row_weights(row_weights_); } void RowSet::rebuild_all() { figure_ = std::make_unique(static_cast(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(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_); } 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(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; // 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(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(slot); r.layer[s] = age; r.slot_alpha[s] = alpha; row_dirty_[static_cast(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(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, kMaxRows> plan{}; std::array, 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(s); // A layer slot is free for re-planning; a signal's own trace is not. plan[static_cast(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(r); Row& row_state = row(r); for (int s = 0; s < kSlotsPerRow; ++s) { const std::size_t si = static_cast(s); if (plan[ri][si] != 0) continue; ++wanted; int target = -1; for (int t = 0; t < kSlotsPerRow; ++t) { if (plan[ri][static_cast(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(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(r); Row& row_state = row(r); for (int s = 0; s < kSlotsPerRow; ++s) { const std::size_t si = static_cast(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(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(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(s)] > 0) ++n; } } return n; } std::vector> RowSet::row_layers(int row_index) const { std::vector> 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(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(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. // // 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(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; } } } 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 snap; if (channel >= 0 && (mode == DisplayMode::Held || mode == DisplayMode::Capturing)) { snap = model.persist_event(static_cast(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(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(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(s), cursor_a_x_, xa, ya); const bool has_b = mix && r.plot->crosshair_point(static_cast(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(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::set_search_marks(const std::vector& xs, int current) { const int n = static_cast(xs.size()) < kSearchMarks ? static_cast(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(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(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(s)] > 0) continue; const EventSeries* ev = r.slots[static_cast(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(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 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(y_gutter_px_)); figure_->set_right_gutter(static_cast(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