Initial commit
This commit is contained in:
commit
8059b90de7
29 files changed
+7893
No files matched your search
@@ -0,0 +1,215 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "model.hpp"
|
||||
|
||||
#include <plot/plot/figure.hpp>
|
||||
#include <plot/plot/plot.hpp>
|
||||
|
||||
#include <array>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
namespace udpscope {
|
||||
|
||||
/// The stacked plots (spec item 4).
|
||||
///
|
||||
/// The rows are the rows of **one `plot::Figure`**, rendered by one
|
||||
/// `plot::gtk4::PlotArea` in figure mode. The figure owns everything the rows have
|
||||
/// to agree on, which is why it is one figure and not N plots:
|
||||
///
|
||||
/// - the x window, through a single x link group every row joins. A gesture on any
|
||||
/// row changes that row's range, the group adopts it and pushes it to the others
|
||||
/// (`Figure::sync_x_links()`, run from `layout()`), so there is no "which row
|
||||
/// acted" to work out and nothing to propagate by hand;
|
||||
/// - the live follow, which is the *group's* (`set_x_link_follow`). A member's own
|
||||
/// axis must be `XFollow::Off` -- `Figure::link_x()` refuses a `Live` member --
|
||||
/// because a following axis would reinterpret the pushed range as its own span and
|
||||
/// offset behind its own edge, which is per-row drift by construction;
|
||||
/// - the left gutter, so a vertical feature lands in the same pixel column in every
|
||||
/// row (`Figure::layout()` lays each row out twice and imposes the maximum);
|
||||
/// - the crosshair, placed on every row at one x by `PlotArea`'s figure-mode sync.
|
||||
///
|
||||
/// A row's `Plot` cannot drop a trace, so every row carries a fixed set of trace
|
||||
/// slots created up front; assigning a signal to a slot is a rebind of that slot's
|
||||
/// `SlotSeries`, and only a *name* change -- which `Plot` cannot express -- forces a
|
||||
/// rebuild (see `row_dirty()`). A rebuild is of the whole figure: `Figure`'s row
|
||||
/// count is fixed at construction, so there is one path for "a row's traces changed"
|
||||
/// and "the row count changed", and it costs no GL state -- the area keeps its
|
||||
/// context and is simply handed the new figure.
|
||||
class RowSet {
|
||||
public:
|
||||
static constexpr int kMaxRows = 4;
|
||||
static constexpr int kSlotsPerRow = 4;
|
||||
|
||||
RowSet();
|
||||
|
||||
struct Row {
|
||||
/// The figure's row, borrowed. Null for a row beyond `row_count()`, which is how
|
||||
/// every loop here and in the UI stays inside the rows that exist. Invalidated by
|
||||
/// `rebuild_all()`, which builds a new figure.
|
||||
plot::Plot* plot = nullptr;
|
||||
std::array<SlotSeries, kSlotsPerRow> slots;
|
||||
/// Model channel per trace slot. Must start at -1: `std::array<Row, N>{}`
|
||||
/// zero-initialises a `Row`, and a zero here reads as "slot 0 already holds
|
||||
/// channel 0", which makes every drop look like the row is full.
|
||||
/// What the slot plots, as a *stable identity*: a channel index is positional,
|
||||
/// and enabling/disabling a signal or a new CONFIG renumbers the channel list,
|
||||
/// so an index would silently start plotting a different signal. Empty name =
|
||||
/// free slot.
|
||||
std::array<std::string, kSlotsPerRow> signal_name;
|
||||
/// The channel index `signal_name`/`element` resolved to, filled by `bind()`.
|
||||
std::array<int, kSlotsPerRow> channel = {-1, -1, -1, -1};
|
||||
/// The trace's legend name (the channel's display name), which is what the
|
||||
/// plot was built with.
|
||||
std::array<std::string, kSlotsPerRow> name;
|
||||
/// The palette entry the slot's trace is drawn in. A *signal's* index, not the
|
||||
/// slot's: a signal dropped on two rows has to be the same colour in both, or the
|
||||
/// signal list's colour chip cannot name a trace. Supplied by the caller, because
|
||||
/// only it knows the signal list this indexes into. A trace's colour is fixed at
|
||||
/// `add_trace()`, which is why this is stored rather than resolved per frame --
|
||||
/// changing it needs a rebuild, and `assign()` already forces one.
|
||||
std::array<int, kSlotsPerRow> color_index = {0, 1, 2, 3};
|
||||
plot::u32 axis_x = 0;
|
||||
plot::u32 axis_y = 0;
|
||||
plot::u32 cursor_a = 0;
|
||||
plot::u32 cursor_b = 0;
|
||||
plot::u32 ruler = 0;
|
||||
/// The vertical line marking the trigger instant, drawn whenever an event is
|
||||
/// on screen (and parked outside the axis otherwise). Not the user's cursor:
|
||||
/// it keeps its position whatever the cursor toggle says.
|
||||
plot::u32 trigger_marker = 0;
|
||||
/// Off by default: the cursors are switched on from the toolbar, and a rebuild
|
||||
/// (any drop, any row-count change) must not switch them back off.
|
||||
bool cursors_visible = false;
|
||||
double cursor_a_x = 0.0;
|
||||
double cursor_b_x = 0.0;
|
||||
double ruler_y = 0.0;
|
||||
};
|
||||
|
||||
int row_count() const noexcept { return rows_; }
|
||||
/// Shows `rows` rows. A changed count rebuilds the figure, since a `Figure`'s row
|
||||
/// count is fixed at construction.
|
||||
void set_row_count(int rows);
|
||||
Row& row(int i) { return storage_[static_cast<std::size_t>(i)]; }
|
||||
const Row& row(int i) const { return storage_[static_cast<std::size_t>(i)]; }
|
||||
|
||||
/// The figure the rows live in. The host must be handed this again after every
|
||||
/// `rebuild_all()`.
|
||||
plot::Figure& figure() noexcept { return *figure_; }
|
||||
|
||||
/// Builds a new figure: one `Plot` per visible row with its axes, cursors and fixed
|
||||
/// trace slots, all joined by one x link group.
|
||||
void rebuild_all();
|
||||
/// Rebuilds if any row's traces changed, and reports whether it did -- in which case
|
||||
/// the caller re-points its host at `figure()`.
|
||||
bool rebuild_if_dirty();
|
||||
bool row_dirty(int i) const { return row_dirty_[static_cast<std::size_t>(i)]; }
|
||||
|
||||
/// Binds a row's trace slot to a signal element (`signal_name` empty frees it).
|
||||
/// `color_index` is the signal's palette entry (see `Row::color_index`).
|
||||
void assign(int row, int slot, std::string signal_name, std::string display_name,
|
||||
int color_index);
|
||||
/// Whether a slot plots anything.
|
||||
bool slot_bound(int row, int slot) const;
|
||||
/// The channel index a row/slot holds, or -1.
|
||||
int channel_of(int row, int slot) const;
|
||||
|
||||
/// Rebinds every slot to what it should show this frame: the model's live buffer,
|
||||
/// or -- in trigger mode -- the captured event. Called every frame, because the
|
||||
/// channel list is renumbered by any structural change.
|
||||
void bind(ScopeModel& model);
|
||||
/// Publishes the live buffer's new samples.
|
||||
void acquire(ScopeModel& model) { model.acquire_all(); }
|
||||
|
||||
/// Frames the newest `seconds` of the stream: the link group follows the live edge,
|
||||
/// with its window ending on that edge (which is what resets any pan the user had).
|
||||
void set_time_window(double seconds);
|
||||
double time_window() const noexcept { return window_s_; }
|
||||
|
||||
void set_y_auto(int row, bool on);
|
||||
void set_y_range(int row, double lo, double hi);
|
||||
|
||||
void set_cursors_visible(bool on);
|
||||
void reset_cursors();
|
||||
|
||||
/// Places the trigger marker at stream time `x` (seconds since the epoch), or
|
||||
/// parks it when there is no event on screen.
|
||||
void set_trigger_marker(double x, bool visible);
|
||||
|
||||
/// Keeps the shared x window inside the data the rings actually hold: zooming out
|
||||
/// stops at the whole retained span. A time window is a view of the acquisition, so
|
||||
/// there is nothing to see outside it -- zooming into empty time only loses the data
|
||||
/// off the edge of the plot. Only the *span*: a following group clamps its own pan
|
||||
/// against what its members retain (`Figure::place_live_window()`), while a wheel
|
||||
/// notch multiplies the span and nothing in the library bounds that.
|
||||
void clamp_to_data();
|
||||
|
||||
/// Pins the shared x window to the captured event: `[-pre, +post]` with the edge at
|
||||
/// 0, so nothing outside the pre+post window is visible.
|
||||
void pin_event_window(double pre_s, double post_s);
|
||||
|
||||
/// Fits every row's y axis to the data it holds, and re-frames the shared x window
|
||||
/// on what row 0 retains (the same thing the plot's own `f` key does). The window
|
||||
/// the toolbar reports becomes the fitted span, so the dropdown can say what is
|
||||
/// actually on screen.
|
||||
void fit_all();
|
||||
|
||||
/// Reports the left gutter the rows currently need, and reserves at least the width
|
||||
/// of the widest *plausible* y label.
|
||||
///
|
||||
/// The gutter is measured from the y tick labels, and `TickFormat::Si` makes their
|
||||
/// width change as the auto-fitted range moves (`1.5` becomes `15`, `150`, `1.5k`),
|
||||
/// so a gutter that follows them drags the whole axes -- and with it the x axis --
|
||||
/// left and right by a few pixels every frame. Since `set_min_y_gutter()` is a
|
||||
/// floor and the library still takes the larger of the two, a floor smaller than
|
||||
/// the labels cannot stop that; the reservation therefore covers the widest label
|
||||
/// `format_si()` can produce (three significant digits, a sign and a prefix, or the
|
||||
/// `%.3g` fallback) plus the label padding, measured once with the plot's own font.
|
||||
/// Both reservations only ever grow, so the gutter is constant in practice.
|
||||
void note_y_gutter(double need_px);
|
||||
|
||||
/// Applies the stable gutter as the figure's floor. Call once per frame, before the
|
||||
/// frame is drawn.
|
||||
///
|
||||
/// It has to be the *figure's* floor and not each row's: `Figure::layout()` clears
|
||||
/// every row's `set_min_y_gutter()` to measure what its own labels need, so a floor
|
||||
/// written onto the rows survives exactly until the next layout.
|
||||
void apply_y_gutter();
|
||||
|
||||
/// The palette entry `index`, wrapped. Keyed by the *signal*, so that the colour a
|
||||
/// trace is drawn in is the colour the signal list's chip shows, on every row the
|
||||
/// signal appears in.
|
||||
static plot::Color palette_color(int index);
|
||||
/// The colour of a signal that is on no plot, which the signal list shows instead of
|
||||
/// leaving its chip blank -- a blank chip is a narrower cell than a filled one, so the
|
||||
/// names beside it shifted sideways as signals were placed.
|
||||
static plot::Color unplotted_color();
|
||||
|
||||
private:
|
||||
void install(Row& r, int row_index, plot::Plot& p);
|
||||
void place_cursors(Row& r);
|
||||
/// The newest x any row's live traces hold, which is what the group follows.
|
||||
bool live_edge(double& newest) const;
|
||||
|
||||
std::unique_ptr<plot::Figure> figure_;
|
||||
/// The one x link group every visible row belongs to. Groups are numbered from 1.
|
||||
plot::u32 x_group_ = 0;
|
||||
int rows_ = 1;
|
||||
double window_s_ = 2.0;
|
||||
double trigger_marker_x_ = 0.0;
|
||||
bool trigger_marker_on_ = false;
|
||||
/// The event window currently pinned, so a rebuild can restate it instead of
|
||||
/// re-framing on the live edge.
|
||||
double pinned_pre_ = 0.0;
|
||||
double pinned_post_ = 0.0;
|
||||
bool pinned_ = false;
|
||||
double y_gutter_px_ = 0.0;
|
||||
bool y_gutter_reserved_ = false;
|
||||
std::array<Row, static_cast<std::size_t>(kMaxRows)> storage_{};
|
||||
std::array<bool, static_cast<std::size_t>(kMaxRows)> row_dirty_{};
|
||||
};
|
||||
|
||||
} // namespace udpscope
|
||||
Reference in new issue
Block a user