Adding a grabbable region across the middle of the glfw demo turned the pan gesture PLOT_DEMO_FOLLOW makes -- and a user's own centre drag -- into a region drag, which that mode's self-check reported as 'distance behind only by the pan: NO' and exit 1. The region now rides the left sixth of the live window, so a press in the middle pans exactly as it always did, and the reason is written where the bounds are placed. The multiplot demo also prints its region's last-frame fill rect and bound columns, so the exit frame's region pixels can be read against the geometry Plot::layout() measured. Report: .superpowers/sdd/p2-task-7-report.md
684 lines
34 KiB
C++
684 lines
34 KiB
C++
// 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);
|
|
// The region's last-frame geometry on row 0, as the layout measured it: the rect the
|
|
// fill and the bound strokes are drawn in, which is also what the screenshot's region
|
|
// pixels are read against.
|
|
for (const plot::RegionLayout& rl : std::as_const(figure.plot(0)).layout().regions) {
|
|
if (rl.region != region) continue;
|
|
std::printf("[multiplot] region %u: fill x %.1f y %.1f w %.1f h %.1f, bounds at %.1f and"
|
|
" %.1f\n",
|
|
region, rl.fill.x, rl.fill.y, rl.fill.w, rl.fill.h,
|
|
static_cast<plot::f64>(plot::handle_column(rl.left_handle)),
|
|
static_cast<plot::f64>(plot::handle_column(rl.right_handle)));
|
|
}
|
|
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
|
|
}
|