Files
plotter/examples/multiplot_demo.cpp
T
Martino Ferrari a70c381a1e fix(examples): keep the glfw demo's region off the pan path, and print the multiplot's region geometry
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
2026-09-28 15:36:25 +02:00

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
}