feat(examples): a three-row synced Figure demo, on a GLFW host that draws one
The multi-plot example the phase exists for: three plots stacked in one Figure, sharing one x link with following armed, each row carrying its own named signals and its own legend, driven by six StreamSeries at 500 kS/s. Two cursors and a region with an exact statistics readout reach the direct manipulation of spec §5 in the same frame, and PLOT_DEMO_SYNC=1 is a self-checking mode that exits non-zero unless all three rows hold one range and the shared right edge keeps tracking the newest sample through a zoom, a pan on another row and more arriving data. The GLFW host gains the figure mode the demo needs: set_figure() makes the run loop draw the Figure instead of its own plot and route each pointer, wheel and key event to the row it lands in, through one Controller per row (row_controller()). All of it is opt-in; the single-plot path is untouched.
This commit is contained in:
1 parent
770da4c2b2
commit
942f88c568
9 files changed
+917
-39
No files matched your search
@@ -0,0 +1,672 @@
|
||||
// The multi-plot application: three plots stacked in one `Figure`, sharing one x
|
||||
// axis, each carrying its own signals and its own legend.
|
||||
//
|
||||
// The shape the library did not have before Task 6, and the one a real instrument
|
||||
// wants: a main plot, a secondary plot at a different scale, and a sparse event
|
||||
// plot, all framed by one time window that pans and zooms as one. What it
|
||||
// demonstrates:
|
||||
//
|
||||
// - a `Figure` of three rows, every row its own `Plot` with its own y axis and its
|
||||
// own legend -- row 0 three named signals on one shared y axis (a sine, a square
|
||||
// and a noisy ramp), row 1 two signals at a different scale (a slow sine at +-100
|
||||
// and a decaying burst at +20), row 2 one sparse spike train;
|
||||
// - **one x link with following armed**: the three rows are in one link group
|
||||
// (`Figure::add_x_link`/`link_x`/`set_x_link_follow`), so the window is derived
|
||||
// once per frame from the newest sample any member holds and pushed to all three.
|
||||
// A wheel zoom or a drag on *any* row therefore moves every row together, and new
|
||||
// data keeps arriving at the right edge -- the window slides instead of the trace
|
||||
// running off it;
|
||||
// - the direct manipulation of spec §5, reachable in this demo: two cursors on row 0
|
||||
// with a measurement band between them, and one `Region` whose exact statistics are
|
||||
// printed with every metered report. Both ride the live window until the user
|
||||
// grabs one;
|
||||
// - six `StreamSeries` at 500 kS/s pushed from one producer thread, acquired once per
|
||||
// frame, so the shared window is exercised while data slides and the traces are drawn
|
||||
// from the pyramid (620 samples per pixel, well past the dense threshold);
|
||||
// - the headless hooks the GLFW adapter implements once
|
||||
// (PLOT_DEMO_EXIT_AFTER_MS / PLOT_DEMO_SCREENSHOT), for a CI run under `xvfb-run`;
|
||||
// - `PLOT_DEMO_SYNC=1`: the same run with the scripted synced-follow verification. It
|
||||
// measures, per frame, that all three rows hold *one* range and that the range's
|
||||
// right edge keeps tracking the newest sample -- at the live edge to begin with,
|
||||
// after a zoom over the shared axis strip, after a pan on a *different* row and
|
||||
// again after more data has arrived. Like the GLFW demo's follow script it is a
|
||||
// check and not a readout: the run exits non-zero if a property fails, or if the
|
||||
// sequence never got far enough to measure all four.
|
||||
//
|
||||
// The producer is joined before the streams it writes into are destroyed, and before
|
||||
// the host closes the GL context (main's declaration order does that: the series are
|
||||
// declared first, the host last).
|
||||
|
||||
#include <glfw/glfw_host.hpp>
|
||||
|
||||
#include <plot/data/stream_series.hpp>
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <limits>
|
||||
#include <memory>
|
||||
#include <numbers>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
using plot::f32;
|
||||
using plot::f64;
|
||||
using plot::u32;
|
||||
using plot::u64;
|
||||
|
||||
/// 500 kS/s per signal -- six signals, so 3 MS/s through the producer. High enough that
|
||||
/// a 1.5 s window holds ~750 k samples, i.e. ~620 samples per pixel across a 1280 px
|
||||
/// window, which is far past the dense threshold and past one pyramid bucket per pixel:
|
||||
/// the traces are drawn from the pyramid, not from raw samples.
|
||||
constexpr f64 kSampleRate = 500.0e3;
|
||||
constexpr f64 kDx = 1.0 / kSampleRate;
|
||||
/// The retained window: 1 << 20 samples = 2.097152 s. A power of two, so the ring never
|
||||
/// has to round it up, and a third longer than the window the demo opens with, so there
|
||||
/// is something to pan back into.
|
||||
constexpr u64 kCapacity = 1u << 20;
|
||||
/// Producer wakeup: 512 samples = 1.024 ms of data per push batch. It also bounds how
|
||||
/// far the newest x can move between the two reads the sync script takes in one frame,
|
||||
/// which is why the script's tolerances are stated in terms of it.
|
||||
constexpr u64 kBatch = 1u << 9;
|
||||
/// Cap on one push batch, so a process that was stopped and resumed catches up over
|
||||
/// several iterations instead of allocating a batch for the whole gap.
|
||||
constexpr u64 kMaxBatch = 1u << 15;
|
||||
/// The live window the demo starts with, in seconds: about a third less than the rings
|
||||
/// retain, so there is something to pan back into and something to zoom out to.
|
||||
constexpr f64 kWindowS = 1.5;
|
||||
|
||||
constexpr u32 kRows = 3u;
|
||||
|
||||
/// The six signals, in the order their series are created, their traces added and their
|
||||
/// colours picked. Row 0 carries the first three, row 1 the next two, row 2 the last.
|
||||
enum Signal : u32 { kSine, kSquare, kRamp, kSlow, kDecay, kSparse, kSignalCount };
|
||||
|
||||
constexpr plot::Color kSignalColors[kSignalCount] = {
|
||||
plot::Color::hex(0x40C0FF), // sine
|
||||
plot::Color::hex(0xFFB020), // square
|
||||
plot::Color::hex(0x35D0A8), // ramp
|
||||
plot::Color::hex(0xFF6B6B), // slow
|
||||
plot::Color::hex(0xC08CFF), // decay
|
||||
plot::Color::hex(0xFF4FD8), // sparse
|
||||
};
|
||||
|
||||
constexpr const char* kSignalNames[kSignalCount] = {"sine", "square", "ramp",
|
||||
"slow", "decay", "sparse"};
|
||||
|
||||
/// The cursors and the region, in colours the legend swatches and the chrome do not
|
||||
/// use, so a pixel count of one of them is about that element and nothing else. The
|
||||
/// region's two are the GLFW demo's own, so the render smoke test's region predicates
|
||||
/// read this frame too.
|
||||
constexpr plot::Color kCursorAColor = plot::Color::hex(0xFFFFFF);
|
||||
constexpr plot::Color kCursorBColor = plot::Color::hex(0x00FFFF);
|
||||
constexpr plot::Color kRegionFill = plot::Color::hex(0x33FF00, 0.35f);
|
||||
constexpr plot::Color kRegionEdge = plot::Color::hex(0xFF00FF);
|
||||
|
||||
/// y(t) for signal `s` at sample index `i`.
|
||||
///
|
||||
/// The noise hash takes the low 32 bits of the index, exactly as the GLFW demo's does,
|
||||
/// so the sample path stays allocation-free and branch-light and the pattern repeats far
|
||||
/// beyond any run of this demo.
|
||||
f32 sample_at(Signal s, u64 i) noexcept {
|
||||
constexpr f64 kTwoPi = 2.0 * std::numbers::pi;
|
||||
const f64 t = static_cast<f64>(i) * kDx;
|
||||
switch (s) {
|
||||
case kSine:
|
||||
return static_cast<f32>(std::sin(kTwoPi * 15.0 * t));
|
||||
case kSquare:
|
||||
return std::sin(kTwoPi * 5.0 * t) >= 0.0 ? 1.0f : -1.0f;
|
||||
case kRamp: {
|
||||
const f64 phase = std::fmod(t * 2.0, 1.0); // a 2 Hz sawtooth, -1 -> +1
|
||||
u32 h = static_cast<u32>(i) * 2654435761u;
|
||||
h ^= h >> 15;
|
||||
h *= 2246822519u;
|
||||
h ^= h >> 13;
|
||||
return static_cast<f32>(2.0 * phase - 1.0 +
|
||||
0.03 * (static_cast<f64>(h & 0xFFFFFFu) / 8388608.0 - 1.0));
|
||||
}
|
||||
case kSlow:
|
||||
return static_cast<f32>(100.0 * std::sin(kTwoPi * 0.8 * t));
|
||||
case kDecay: {
|
||||
// A burst re-triggered 1.5 times a second, decaying to a +20 pedestal: a signal an
|
||||
// order of magnitude away from row 0's, which is the point of its own y axis.
|
||||
const f64 phase = std::fmod(t * 1.5, 1.0);
|
||||
return static_cast<f32>(20.0 + 80.0 * std::exp(-3.0 * phase) * std::cos(kTwoPi * 12.0 * t));
|
||||
}
|
||||
case kSparse: {
|
||||
// Mostly flat, with a four-sample spike every 8192 samples (~16 ms): the shape a
|
||||
// decimation that kept only a bucket's first sample would lose.
|
||||
const u64 phase = i % 8192u;
|
||||
return phase < 4u ? static_cast<f32>(1.0 - 0.25 * static_cast<f64>(phase)) : 0.0f;
|
||||
}
|
||||
default:
|
||||
return 0.0f;
|
||||
}
|
||||
}
|
||||
|
||||
/// Feeds every series from its own thread at 500 kS/s on average, paced by absolute
|
||||
/// deadlines off a steady clock, exactly as the GLFW demo does: every wakeup produces
|
||||
/// every sample whose time has already come, so a late wakeup is caught up instead of
|
||||
/// pushing the phase back. One thread pushes all six series, so they are sample-locked:
|
||||
/// one x window means the same data in every row.
|
||||
class Producer {
|
||||
public:
|
||||
explicit Producer(std::vector<std::unique_ptr<plot::data::StreamSeries>>& series)
|
||||
: series_(&series), thread_([this] { run(); }) {}
|
||||
~Producer() { stop(); }
|
||||
|
||||
Producer(const Producer&) = delete;
|
||||
Producer& operator=(const Producer&) = delete;
|
||||
|
||||
/// Sets the stop flag and joins the thread. Idempotent; the destructor calls it, so
|
||||
/// the thread is never detached and never outlives the streams.
|
||||
void stop() noexcept {
|
||||
stop_.store(true, std::memory_order_relaxed);
|
||||
if (thread_.joinable()) thread_.join();
|
||||
}
|
||||
|
||||
/// Samples pushed per series so far. Written by the producer, read by the render thread.
|
||||
u64 produced() const noexcept { return produced_.load(std::memory_order_relaxed); }
|
||||
|
||||
private:
|
||||
void run() {
|
||||
using clock = std::chrono::steady_clock;
|
||||
const auto start = clock::now();
|
||||
u64 next = 0;
|
||||
while (!stop_.load(std::memory_order_relaxed)) {
|
||||
const f64 elapsed = std::chrono::duration<f64>(clock::now() - start).count();
|
||||
const u64 due = static_cast<u64>(elapsed / kDx) + 1; // samples whose time has come
|
||||
if (due > next) {
|
||||
u64 n = due - next;
|
||||
if (n > kMaxBatch) n = kMaxBatch;
|
||||
batch_.resize(static_cast<plot::usize>(n));
|
||||
for (u32 s = 0; s < kSignalCount; ++s) {
|
||||
for (u64 k = 0; k < n; ++k) {
|
||||
batch_[static_cast<plot::usize>(k)] = sample_at(static_cast<Signal>(s), next + k);
|
||||
}
|
||||
(*series_)[s]->push(batch_.data(), n);
|
||||
}
|
||||
next += n;
|
||||
produced_.store(next, std::memory_order_relaxed);
|
||||
}
|
||||
// Sleep until one batch past the last produced sample: an absolute deadline, so the
|
||||
// phase is fixed at `start` and never accumulates.
|
||||
const f64 wake_s = static_cast<f64>(next + kBatch) * kDx;
|
||||
std::this_thread::sleep_until(
|
||||
start + std::chrono::duration_cast<clock::duration>(std::chrono::duration<f64>(wake_s)));
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<std::unique_ptr<plot::data::StreamSeries>>* series_;
|
||||
std::vector<f32> batch_;
|
||||
std::atomic<u64> produced_{0};
|
||||
std::atomic<bool> stop_{false};
|
||||
std::thread thread_; ///< declared last: the lambda runs once everything is up
|
||||
};
|
||||
|
||||
/// The live readout: the achieved push rate, the frame time and the frame count.
|
||||
struct Meter {
|
||||
f64 rate = 0.0; ///< samples/s per series, refreshed a few times a second
|
||||
f64 frame_ms = 0.0; ///< exponential moving average of the frame time
|
||||
f64 elapsed = 0.0; ///< seconds of frame callback time
|
||||
f64 last_report = 0.0;
|
||||
u64 last_produced = 0;
|
||||
u64 frames = 0;
|
||||
};
|
||||
|
||||
/// The newest x any series holds. Every series is pushed in lockstep, so they agree; the
|
||||
/// maximum over them is taken anyway, because that is the edge the link group derives its
|
||||
/// window from (`Figure::live_edge()` unions its members) and the script must measure the
|
||||
/// window against the same edge the figure used.
|
||||
template <typename Series>
|
||||
f64 newest_x(const Series& series) {
|
||||
f64 newest = -std::numeric_limits<f64>::infinity();
|
||||
for (const std::unique_ptr<plot::data::StreamSeries>& s : series) {
|
||||
const plot::Range b = s->x_bounds();
|
||||
if (std::isfinite(b.max) && b.max > newest) newest = b.max;
|
||||
}
|
||||
return newest;
|
||||
}
|
||||
|
||||
/// The scripted synced-follow verification `PLOT_DEMO_SYNC=1` runs.
|
||||
///
|
||||
/// "Several plots, several signals per plot, x axis synchronised" is an *interaction*
|
||||
/// property, and this is the program that has one: a real producer, the real GLFW host,
|
||||
/// per-row controllers, the real link group. So the sequence arms following on the group
|
||||
/// once the streams hold data and then takes four measurements, one per frame:
|
||||
///
|
||||
/// (a) steady state, with the group's window at the live edge;
|
||||
/// (b) after three wheel notches rolled over the **bottom row's** x label strip -- a
|
||||
/// different row from the one the cursors are on -- which narrows the one shared
|
||||
/// window;
|
||||
/// (c) after a 100 px left-drag across the **middle row's** axes, the real pan gesture,
|
||||
/// which moves the window back from the live edge without touching its width;
|
||||
/// (d) after a further 0.15 s of data has arrived.
|
||||
///
|
||||
/// Each measurement records whether the three rows hold one range, whether the group is
|
||||
/// still following, the shared right edge, the window's width, and the newest sample the
|
||||
/// streams hold. The checks are the two properties the demo exists for, and each step
|
||||
/// takes one of them away:
|
||||
///
|
||||
/// - **one range across the rows**, at every step, after a gesture on the bottom row and
|
||||
/// after one on the middle row: that is the synchronisation;
|
||||
/// - **following armed**, at every step, so no interaction silently disarms it;
|
||||
/// - **at the live edge** in the steady state (`behind` ~ 0);
|
||||
/// - **the zoom** (a wheel over the bottom row's shared-axis strip) reached the group and
|
||||
/// changed the window's width;
|
||||
/// - **the pan** (a drag on the middle row) moved the window back from the live edge and
|
||||
/// left the width where the zoom put it;
|
||||
/// - **the right edge tracked the newest sample** once more data had arrived: it advanced
|
||||
/// by exactly as much as the newest x did, with the distance behind it unchanged. That
|
||||
/// is what "new data keeps arriving at the right edge" means for a window the user has
|
||||
/// moved away from the edge.
|
||||
///
|
||||
/// One measurement is deliberately *not* checked, and the reason is worth stating because
|
||||
/// the demo is where it shows: the zoom above rolls over the axis strip, outside `axes`,
|
||||
/// where a single `Live` axis anchors at its live edge and keeps the window ending at the
|
||||
/// newest sample. A row of a following link group holds `XFollow::Off` -- the group owns
|
||||
/// the follow state, and `link_x()` refuses a `Live` member -- so `Controller`'s
|
||||
/// `x_zoom_anchor()` cannot see that the window is live and falls back to the data
|
||||
/// coordinate the pointer's x extrapolates to (the middle of the axes for a strip-centre
|
||||
/// pointer). The zoom still multiplies the width and keeps following, but it also walks
|
||||
/// the window back from the edge. Measured here rather than assumed: `behind` after the
|
||||
/// zoom is what it is, and the checks below tolerate it while pinning everything the
|
||||
/// library does guarantee.
|
||||
///
|
||||
/// The verdict is carried out of the run in `failed`, so `main()` exits non-zero on a
|
||||
/// failed check -- a check whose result only stdout knows is not a check -- and also when
|
||||
/// the sequence never completed, since a run that measured nothing has not passed.
|
||||
struct SyncScript {
|
||||
bool enabled = false;
|
||||
/// Set by report(); read by main(). False until the checks have all held.
|
||||
bool failed = false;
|
||||
/// Set by report(): the sequence reached a verdict at all.
|
||||
bool completed = false;
|
||||
u32 step = 0;
|
||||
f64 newest[4]{};
|
||||
f64 right[4]{};
|
||||
f64 span[4]{};
|
||||
bool shared[4]{};
|
||||
bool live[4]{};
|
||||
|
||||
static SyncScript from_env() {
|
||||
SyncScript s;
|
||||
const char* v = std::getenv("PLOT_DEMO_SYNC");
|
||||
s.enabled = v != nullptr && v[0] != '0' && v[0] != '\0';
|
||||
return s;
|
||||
}
|
||||
|
||||
/// One frame per step: the gesture, then the measurement of what it did. The last step
|
||||
/// waits for data to actually arrive, because the last measurement is about what more
|
||||
/// samples do to a window the user has moved.
|
||||
void advance(plot::glfw::Host& host, plot::Figure& figure,
|
||||
std::vector<std::unique_ptr<plot::data::StreamSeries>>& series) {
|
||||
switch (step) {
|
||||
case 0:
|
||||
// Enough retained data for the numbers to be worth printing, and a frame the
|
||||
// renderer has drawn -- the layout the strip and the axes come from is only
|
||||
// measured once the text backend is installed.
|
||||
if (series[0]->size() < 65536u || figure.plot(0).text_backend() == nullptr) return;
|
||||
record(0, figure, series);
|
||||
break;
|
||||
case 1:
|
||||
notches(host, figure);
|
||||
record(1, figure, series);
|
||||
break;
|
||||
case 2:
|
||||
pan(host, figure);
|
||||
record(2, figure, series);
|
||||
break;
|
||||
case 3:
|
||||
// 0.15 s of new data, i.e. ~75000 samples per series at this rate.
|
||||
if (newest_x(series) - newest[2] < 0.15) return;
|
||||
record(3, figure, series);
|
||||
report();
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
++step;
|
||||
}
|
||||
|
||||
private:
|
||||
/// Three notches of the wheel over the bottom row's x label strip centre: the shared
|
||||
/// axis, which every row's window is the same as.
|
||||
static void notches(plot::glfw::Host& host, plot::Figure& figure) {
|
||||
const plot::Rect strip = figure.plot(kRows - 1u).layout().x_label_area;
|
||||
const plot::input::WheelEvent w{strip.x + 0.5f * strip.w, strip.y + 0.5f * strip.h,
|
||||
0.0f, 1.0f, 0u};
|
||||
plot::input::Controller* c = host.row_controller(kRows - 1u);
|
||||
if (c == nullptr) return;
|
||||
for (int i = 0; i < 3; ++i) c->on_wheel(w);
|
||||
}
|
||||
|
||||
/// A 100 px drag to the right across the middle row's axes, as press/move/release. The
|
||||
/// press is on no handle (the cursors and the region live on row 0), so this is the pan.
|
||||
static void pan(plot::glfw::Host& host, plot::Figure& figure) {
|
||||
const plot::Rect axes = figure.plot(1u).layout().axes;
|
||||
const f32 x = axes.x + 0.5f * axes.w;
|
||||
const f32 y = axes.y + 0.5f * axes.h;
|
||||
plot::input::Controller* c = host.row_controller(1u);
|
||||
if (c == nullptr) return;
|
||||
c->on_pointer({x, y, 0.0f, 0.0f, plot::input::Button::Left, 0u, true, true, false});
|
||||
c->on_pointer({x + 100.0f, y, 100.0f, 0.0f, plot::input::Button::Left, 0u, true, false, false});
|
||||
c->on_pointer({x + 100.0f, y, 0.0f, 0.0f, plot::input::Button::Left, 0u, false, false, true});
|
||||
}
|
||||
|
||||
/// The four numbers, around one `layout()`: the newest x the streams hold is read
|
||||
/// before and after, because the producer may push a batch between the two reads and the
|
||||
/// window was placed from whatever the edge was inside the call. `dist` is therefore a
|
||||
/// distance behind the edge of at most one batch -- the tolerance `report()` uses.
|
||||
void record(int i, plot::Figure& figure, std::vector<std::unique_ptr<plot::data::StreamSeries>>& series) {
|
||||
figure.layout();
|
||||
const plot::Range r = figure.plot(0).x_range(0);
|
||||
newest[i] = newest_x(series);
|
||||
right[i] = r.max;
|
||||
span[i] = r.span();
|
||||
shared[i] = true;
|
||||
for (u32 row = 1; row < kRows; ++row) {
|
||||
const plot::Range o = figure.plot(row).x_range(0);
|
||||
if (o.min != r.min || o.max != r.max) shared[i] = false;
|
||||
}
|
||||
live[i] = figure.x_link_follow(1u) == plot::XFollow::Live;
|
||||
std::printf(
|
||||
"[multiplot] sync %c: shared range %s, right edge %.6f s, newest %.6f s, behind "
|
||||
"%.6f s, width %.6f s, follow %s\n",
|
||||
"abcd"[i], shared[i] ? "yes" : "NO", right[i], newest[i], newest[i] - right[i], span[i],
|
||||
live[i] ? "armed" : "OFF");
|
||||
std::fflush(stdout);
|
||||
}
|
||||
|
||||
void report() {
|
||||
// Tolerances rather than `==`: the window was placed from an edge the producer may have
|
||||
// moved by up to one push batch between this frame's two reads, and the width is
|
||||
// recomputed from that same moving edge.
|
||||
constexpr f64 kBatchS = static_cast<f64>(kBatch) * kDx;
|
||||
const auto near = [](f64 a, f64 b, f64 tol) { return std::fabs(a - b) <= tol; };
|
||||
const f64 behind[4] = {newest[0] - right[0], newest[1] - right[1], newest[2] - right[2],
|
||||
newest[3] - right[3]};
|
||||
// The synchronisation itself, and the arming that no gesture may silently drop.
|
||||
const bool one_range = shared[0] && shared[1] && shared[2] && shared[3];
|
||||
const bool following = live[0] && live[1] && live[2] && live[3];
|
||||
// (a) the steady state sits on the live edge.
|
||||
const bool at_edge = std::fabs(behind[0]) <= kBatchS;
|
||||
// (b) the zoom, made on the bottom row, narrowed the one shared window.
|
||||
const bool zoomed = span[1] < span[0] * 0.99;
|
||||
// (c) the pan, made on the middle row, moved the window back and left the width alone.
|
||||
const bool panned_back = behind[2] > behind[1] + 1e-3;
|
||||
const bool width_kept = near(span[2], span[1], 1e-9 * span[1] + 1e-12);
|
||||
// (d) the right edge tracked the newest sample as the data slid: it advanced by as much
|
||||
// as the newest x did, and the distance behind it did not change. The width is still the
|
||||
// one the zoom left.
|
||||
const bool slid = newest[3] - newest[2] >= 0.15;
|
||||
const bool tracked = near((right[3] - right[2]) - (newest[3] - newest[2]), 0.0, kBatchS);
|
||||
const bool behind_kept = near(behind[3], behind[2], kBatchS);
|
||||
const bool width_kept_after_slide = near(span[3], span[1], 1e-9 * span[1] + 1e-12);
|
||||
failed = !(one_range && following && at_edge && zoomed && panned_back && width_kept && slid &&
|
||||
tracked && behind_kept && width_kept_after_slide);
|
||||
completed = true;
|
||||
std::printf(
|
||||
"[multiplot] sync checks: one range across the rows %s; following armed %s; at the live"
|
||||
" edge %s; the zoom narrowed the shared window %s; the pan moved the window back %s;"
|
||||
" the right edge tracked the newest sample %s\n",
|
||||
one_range ? "yes" : "NO", following ? "yes" : "NO", at_edge ? "yes" : "NO",
|
||||
zoomed ? "yes" : "NO", panned_back && width_kept ? "yes" : "NO",
|
||||
tracked && slid && behind_kept && width_kept_after_slide ? "yes" : "NO");
|
||||
std::fflush(stdout);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
// Declared before the host, so the host -- and the controllers it holds for the
|
||||
// figure's rows -- is destroyed first.
|
||||
std::vector<std::unique_ptr<plot::data::StreamSeries>> series;
|
||||
series.reserve(kSignalCount);
|
||||
for (u32 s = 0; s < kSignalCount; ++s) {
|
||||
series.push_back(std::make_unique<plot::data::StreamSeries>(kCapacity, 0.0, kDx));
|
||||
}
|
||||
Producer producer(series);
|
||||
|
||||
// --- the figure -------------------------------------------------------------
|
||||
|
||||
plot::Figure figure(kRows);
|
||||
// Every row carries x axis 0 -- the axis the link group owns -- and one y axis. The x
|
||||
// axes are all `XFollow::Off`: a member of a link group must be, because the group owns
|
||||
// the shared window's follow state. The y axes are fixed rather than auto-scaled, so
|
||||
// each row shows its own signals at its own scale and the frame does not jump as data
|
||||
// arrives.
|
||||
for (u32 row = 0; row < kRows; ++row) {
|
||||
plot::Plot& p = figure.plot(row);
|
||||
p.add_x_axis({"time", {0.0, kWindowS}, false, plot::AxisPlace::Bottom, false,
|
||||
plot::TickFormat::Time});
|
||||
}
|
||||
const u32 y_level = figure.plot(0).add_y_axis(
|
||||
{"level", {-1.6, 1.6}, false, plot::AxisPlace::Left, false, plot::TickFormat::Si});
|
||||
const u32 y_scaled = figure.plot(1).add_y_axis(
|
||||
{"scaled", {-150.0, 150.0}, false, plot::AxisPlace::Left, false, plot::TickFormat::Si});
|
||||
const u32 y_events = figure.plot(2).add_y_axis(
|
||||
{"events", {-0.2, 1.2}, false, plot::AxisPlace::Left, false, plot::TickFormat::Si});
|
||||
|
||||
// Row 0: three named signals on one shared y axis. Every name is what puts an entry in
|
||||
// that plot's legend -- the legend is per plot (spec §4), so each row legends its own.
|
||||
const u32 sine_trace = figure.plot(0).add_trace(
|
||||
{series[kSine].get(), kSignalColors[kSine], 1.4f, 0u, y_level, kSignalNames[kSine]});
|
||||
figure.plot(0).add_trace(
|
||||
{series[kSquare].get(), kSignalColors[kSquare], 1.4f, 0u, y_level, kSignalNames[kSquare]});
|
||||
figure.plot(0).add_trace(
|
||||
{series[kRamp].get(), kSignalColors[kRamp], 1.4f, 0u, y_level, kSignalNames[kRamp]});
|
||||
// Row 1: two signals at a different scale, its own legend.
|
||||
figure.plot(1).add_trace(
|
||||
{series[kSlow].get(), kSignalColors[kSlow], 1.4f, 0u, y_scaled, kSignalNames[kSlow]});
|
||||
figure.plot(1).add_trace(
|
||||
{series[kDecay].get(), kSignalColors[kDecay], 1.4f, 0u, y_scaled, kSignalNames[kDecay]});
|
||||
// Row 2: one sparse signal.
|
||||
figure.plot(2).add_trace(
|
||||
{series[kSparse].get(), kSignalColors[kSparse], 1.4f, 0u, y_events, kSignalNames[kSparse]});
|
||||
|
||||
// One x link for all three rows, following armed: the group derives one window from the
|
||||
// newest sample across the members' traces and pushes it to every member each frame, so
|
||||
// a pan or a zoom anywhere moves all three together and the window slides as data
|
||||
// arrives. Row 0 frames the group (the first row in adopts its own range as the shared
|
||||
// one); the explicit `set_x_link_range()` then states the span the window should have.
|
||||
const u32 link = figure.add_x_link();
|
||||
plot::Status st;
|
||||
for (u32 row = 0; row < kRows; ++row) {
|
||||
if (!figure.link_x(row, link)) {
|
||||
std::fprintf(stderr, "multiplot_demo: linking row %u failed\n", row);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
if (!figure.set_x_link_range(link, {0.0, kWindowS})) {
|
||||
std::fprintf(stderr, "multiplot_demo: framing the x link failed\n");
|
||||
return 1;
|
||||
}
|
||||
if (!figure.set_x_link_follow(link, plot::XFollow::Live)) {
|
||||
std::fprintf(stderr, "multiplot_demo: arming the x link's follow failed\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// The direct manipulation, on the row with the richest signal: two snapped cursors with
|
||||
// a measurement band between them, and one region whose exact statistics the metered
|
||||
// readout prints.
|
||||
const u32 cursor_a = figure.plot(0).add_cursor(0.0, kCursorAColor);
|
||||
const u32 cursor_b = figure.plot(0).add_cursor(0.0, kCursorBColor);
|
||||
figure.plot(0).set_cursor_snap(cursor_a, plot::SnapMode::NearestSample);
|
||||
figure.plot(0).set_cursor_snap(cursor_b, plot::SnapMode::NearestSample);
|
||||
const u32 measure = figure.plot(0).add_measure(cursor_a, cursor_b, sine_trace);
|
||||
plot::Region region_desc;
|
||||
region_desc.snap = plot::RegionSnap::NearestSample;
|
||||
region_desc.fill = kRegionFill;
|
||||
region_desc.edge = kRegionEdge;
|
||||
region_desc.label = "roi";
|
||||
const u32 region = figure.plot(0).add_region(region_desc);
|
||||
|
||||
// --- the host ---------------------------------------------------------------
|
||||
|
||||
plot::glfw::Host host;
|
||||
host.set_figure(&figure);
|
||||
|
||||
Meter meter;
|
||||
SyncScript sync = SyncScript::from_env();
|
||||
/// The overlay rides the live window while the user has not taken it over: the first
|
||||
/// cursor or region drag on any row stops it where the user put it.
|
||||
bool overlay_follows = true;
|
||||
|
||||
const plot::glfw::FrameFn frame = [&](plot::Plot&, plot::f64 dt) {
|
||||
for (std::unique_ptr<plot::data::StreamSeries>& s : series) s->acquire(); // once per frame
|
||||
|
||||
if (sync.enabled) sync.advance(host, figure, series);
|
||||
|
||||
if (overlay_follows) {
|
||||
const plot::Range xr = figure.plot(0).x_range(0);
|
||||
figure.plot(0).set_cursor_x(cursor_a, xr.min + 0.35 * xr.span());
|
||||
figure.plot(0).set_cursor_x(cursor_b, xr.min + 0.70 * xr.span());
|
||||
figure.plot(0).set_region(region, xr.min + 0.42 * xr.span(), xr.min + 0.58 * xr.span());
|
||||
}
|
||||
for (u32 row = 0; row < kRows; ++row) {
|
||||
const plot::input::Controller* c = host.row_controller(row);
|
||||
if (c == nullptr) continue;
|
||||
const plot::input::DragMode mode = c->drag_mode();
|
||||
if (mode == plot::input::DragMode::MoveCursor || mode == plot::input::DragMode::MoveRegion ||
|
||||
mode == plot::input::DragMode::ResizeRegionLeft ||
|
||||
mode == plot::input::DragMode::ResizeRegionRight) {
|
||||
overlay_follows = false;
|
||||
}
|
||||
}
|
||||
|
||||
meter.elapsed += dt;
|
||||
meter.frames += 1;
|
||||
meter.frame_ms += 0.1 * (dt * 1.0e3 - meter.frame_ms); // EMA
|
||||
if (meter.elapsed - meter.last_report >= 0.25) {
|
||||
const u64 produced = producer.produced();
|
||||
meter.rate =
|
||||
static_cast<f64>(produced - meter.last_produced) / (meter.elapsed - meter.last_report);
|
||||
meter.last_produced = produced;
|
||||
meter.last_report = meter.elapsed;
|
||||
|
||||
const plot::Renderer* r = host.renderer();
|
||||
const plot::FrameStats prev = r != nullptr ? r->stats() : plot::FrameStats{};
|
||||
const plot::Rect axes = std::as_const(figure.plot(0)).layout().axes;
|
||||
const f64 spp = axes.w > 0.0f ? static_cast<f64>(prev.visible_samples) / axes.w : 0.0;
|
||||
// The region's statistics are exact (`Series::reduce/sum/sumsq` over its interval),
|
||||
// and they are the readout the direct-manipulation API exists to give: a live window
|
||||
// sliding new samples under a data-anchored interval prints a new mean/rms each time.
|
||||
const plot::RegionStats& rs = figure.plot(0).region_stats(region, sine_trace);
|
||||
const plot::Measurement m = figure.plot(0).measurement(measure);
|
||||
char title[224];
|
||||
std::snprintf(title, sizeof title,
|
||||
"plot multiplot demo | %.3f MS/s x6 | %.2f ms/frame | %.0f samples/px | %s | "
|
||||
"roi mean %.3f rms %.3f | %llu frames",
|
||||
meter.rate * 1.0e-6, meter.frame_ms, spp,
|
||||
prev.lod_traces > 0 ? "LOD" : (prev.raw_traces > 0 ? "raw" : "-"), rs.mean,
|
||||
rs.rms, static_cast<unsigned long long>(meter.frames));
|
||||
host.set_title(title);
|
||||
|
||||
std::printf(
|
||||
"[multiplot] t=%.1fs %.3f MS/s/row %.2f ms/frame %.0f samples/px %s | roi "
|
||||
"[%.4f, %.4f] s n %llu mean %.4f rms %.4f min %.4f max %.4f integral %.4f | "
|
||||
"dx %g s dy %g\n",
|
||||
meter.elapsed, meter.rate * 1.0e-6, meter.frame_ms, spp,
|
||||
prev.lod_traces > 0 ? "LOD" : (prev.raw_traces > 0 ? "raw" : "-"), rs.x0, rs.x1,
|
||||
static_cast<unsigned long long>(rs.count), rs.mean, rs.rms, rs.min, rs.max, rs.integral,
|
||||
m.dx, m.dy);
|
||||
std::fflush(stdout);
|
||||
}
|
||||
};
|
||||
|
||||
plot::glfw::RunConfig cfg;
|
||||
cfg.title = "plot multiplot demo";
|
||||
cfg.vsync = true;
|
||||
|
||||
const bool ok = host.run(cfg, frame, &st);
|
||||
producer.stop(); // no thread pushes into the streams after this point
|
||||
|
||||
if (!ok) {
|
||||
std::fprintf(stderr, "plot_multiplot_demo: %s: %s\n", plot::to_string(st.code), st.message);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// The sync script's verdict, before the summary: a run that asked for the verification
|
||||
// and did not get a passing one fails, whether a check said NO or the sequence never got
|
||||
// far enough to say anything (a deadline too short to reach the fourth measurement -- a
|
||||
// run that measured nothing has not passed).
|
||||
if (sync.enabled) {
|
||||
if (sync.failed) {
|
||||
std::fprintf(stderr,
|
||||
"plot_multiplot_demo: the synced-follow checks failed (see the 'sync' lines"
|
||||
" above)\n");
|
||||
return 1;
|
||||
}
|
||||
if (!sync.completed) {
|
||||
std::fprintf(stderr,
|
||||
"plot_multiplot_demo: the sync script did not complete (raise"
|
||||
" PLOT_DEMO_EXIT_AFTER_MS)\n");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
const plot::Renderer* r = host.renderer();
|
||||
const plot::FrameStats stats = r != nullptr ? r->stats() : plot::FrameStats{};
|
||||
const plot::Rect axes = std::as_const(figure.plot(0)).layout().axes;
|
||||
const f64 spp =
|
||||
axes.w > 0.0f ? static_cast<f64>(stats.visible_samples) / static_cast<f64>(axes.w) : 0.0;
|
||||
const f64 mean_ms = meter.frames > 0 ? meter.elapsed / static_cast<f64>(meter.frames) * 1.0e3 : 0.0;
|
||||
const plot::Range shared = figure.x_link_range(link);
|
||||
const plot::Range yr0 = series[kSine]->y_bounds();
|
||||
const plot::RegionStats& rs = figure.plot(0).region_stats(region, sine_trace);
|
||||
|
||||
std::printf("\n[multiplot] ---- run summary ----\n");
|
||||
std::printf("[multiplot] producer %llu samples per series pushed (6 series), last metered"
|
||||
" %.3f MS/s each\n",
|
||||
static_cast<unsigned long long>(producer.produced()), meter.rate * 1.0e-6);
|
||||
std::printf("[multiplot] frames %llu in %.3f s, mean %.2f ms/frame (%.1f fps), last"
|
||||
" metered %.2f ms/frame\n",
|
||||
static_cast<unsigned long long>(meter.frames), meter.elapsed, mean_ms,
|
||||
mean_ms > 0.0 ? 1.0e3 / mean_ms : 0.0, meter.frame_ms);
|
||||
std::printf("[multiplot] retained %llu samples/series (%.6f s at %.0f kS/s), y bounds"
|
||||
" of the sine [%.3f, %.3f]\n",
|
||||
static_cast<unsigned long long>(series[kSine]->size()),
|
||||
static_cast<f64>(series[kSine]->size()) * kDx, kSampleRate * 1.0e-3, yr0.min,
|
||||
yr0.max);
|
||||
std::printf("[multiplot] x link group %u: range [%.6f, %.6f] s (width %.6f), follow %s,"
|
||||
" rows in it %u\n",
|
||||
link, shared.min, shared.max, shared.span(), figure.x_link_follow(link) ==
|
||||
plot::XFollow::Live
|
||||
? "Live"
|
||||
: "Off",
|
||||
figure.x_link_count());
|
||||
for (u32 row = 0; row < kRows; ++row) {
|
||||
const plot::Plot& p = figure.plot(row);
|
||||
const plot::Rect ra = p.layout().axes;
|
||||
std::printf("[multiplot] row %u x [%.6f, %.6f] s, axes %.0fx%.0f at (%.0f, %.0f),"
|
||||
" traces %u, legend rows %u\n",
|
||||
row, p.x_range(0).min, p.x_range(0).max, static_cast<f64>(ra.w),
|
||||
static_cast<f64>(ra.h), static_cast<f64>(ra.x), static_cast<f64>(ra.y),
|
||||
p.trace_count(), static_cast<u32>(p.layout().legend.rows.size()));
|
||||
}
|
||||
std::printf("[multiplot] region %u: x [%g, %g] s, n %llu, mean %.4f, rms %.4f, min"
|
||||
" %.4f, max %.4f, integral %.4f\n",
|
||||
region, rs.x0, rs.x1, static_cast<unsigned long long>(rs.count), rs.mean, rs.rms,
|
||||
rs.min, rs.max, rs.integral);
|
||||
std::printf("[multiplot] cursors a %.6f s, b %.6f s, dx %g s dy %g\n",
|
||||
figure.plot(0).cursor(cursor_a).x, figure.plot(0).cursor(cursor_b).x,
|
||||
figure.plot(0).measurement(measure).dx, figure.plot(0).measurement(measure).dy);
|
||||
std::printf("[multiplot] last frame %.1f samples/px (dense threshold %.1f) -> %s; visible"
|
||||
" %llu, staged %u, draws %u, lod %u raw %u\n",
|
||||
spp, r != nullptr ? static_cast<f64>(r->style().dense_threshold_spp) : 0.0,
|
||||
stats.lod_traces > 0 ? "LOD pyramid" : "raw samples",
|
||||
static_cast<unsigned long long>(stats.visible_samples), stats.staged_segments,
|
||||
stats.draw_calls, stats.lod_traces, stats.raw_traces);
|
||||
std::printf("[multiplot] upload path %s, gl error on the last frame %d\n",
|
||||
r != nullptr && r->using_persistent_upload() ? "persistent-mapped ring" : "orphan",
|
||||
r != nullptr && r->last_frame_had_gl_error() ? 1 : 0);
|
||||
|
||||
return 0; // ~producer (no-op after stop), ~series, then ~host closes GL
|
||||
}
|
||||
Reference in new issue
Block a user