Files
plotter/examples/multiplot_demo.cpp
Martino Ferrari ed3a8ce645 licence: MIT LICENSE and SPDX identifiers in every first-party file
The repository had no licence at all, so every source was all-rights-reserved
by default. Add the verbatim MIT text at LICENSE (Copyright (c) 2026 Martino
Ferrari) and a two-line SPDX header to each of the 113 first-party sources --
48 include/plot, 26 src, 10 adapters, 5 examples, 21 tests, 3 tools -- so the
grant travels with each file rather than relying on the root document alone.

Scripts keep their shebang on line 1; the SPDX lines follow it. Genuinely
vendored code is untouched: third_party/stb_truetype.h carries no header of
ours. src/render/embedded_mono_font.cpp is generated, so tools/fonts/gen_atlas.py
emit() now writes the two lines into its header comment and the file was
regenerated -- gen_atlas.py --check passes and the regeneration adds only those
comment lines to the previously committed file.

README's provenance section now states MIT and links LICENSE.

No code, test or public API change.
2026-09-28 22:13:40 +02:00

753 lines
38 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
// 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.
///
/// The zoom above rolls over the axis strip, outside `axes`, where the anchor is the live
/// edge rather than the coordinate under the pointer. That is the one thing a *link group*
/// used to lose: a member's axis is `XFollow::Off` by construction (the group owns the
/// follow state, and `link_x()` refuses a `Live` member), so `Controller::x_zoom_anchor()`
/// could not see that the window was live and fell back to the extent the pointer's x
/// extrapolates to -- the middle of the axes -- which walked the window back off the edge
/// by ~19 % of its width. A following group now publishes its own edge to every member
/// (`Plot::set_x_live_owner`), the controller reads it, and `behind` after the zoom must
/// still be ~0 -- which the check below asserts, so the fix cannot silently regress.
///
/// 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]{};
/// The live edge the group had published to its members when the zoom was made: the
/// coordinate the window's right edge must still be at afterwards, to the bit. Reading
/// it here rather than comparing against the newest sample is what makes the check
/// exact -- the edge moves while the frame runs, so "the window ends at the newest
/// sample" is true only to within the one frame the measurement takes.
f64 anchor_edge = 0.0;
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: {
// The edge the zoom must anchor on. A following group publishes it to every member
// (`Plot::set_x_live_owner`), which is what `Controller::x_zoom_anchor()` now reads
// for a gesture outside `axes` -- without it the group's live window is invisible to
// the input layer and the anchor is the extent the pointer's x extrapolates to.
f64 edge = 0.0;
if (!figure.plot(kRows - 1u).x_live_owner(0u, edge)) {
std::fprintf(stderr, "[multiplot] sync: no live edge was published to the rows\n");
failed = true;
break;
}
anchor_edge = edge;
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 -- and, because
// the anchor outside `axes` is the live edge, left the window *ending on that edge*,
// to the bit. `zoom_on_edge` is the same statement with the slack a frame of arriving
// data costs: the edge moves on while the frame runs, so the window is within 5 % of
// its own width of the newest sample -- where anchoring on the extrapolated coordinate
// left it 30 % behind.
const bool zoomed = span[1] < span[0] * 0.99;
const bool anchor_kept = near(right[1], anchor_edge, 1e-9 * std::fabs(anchor_edge) + 1e-12);
const bool zoom_on_edge = behind[1] <= 0.05 * span[1];
// (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 && anchor_kept && zoom_on_edge &&
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 zoom kept the window ending on the"
" live edge %s (to the bit %s, behind %.6f 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", zoom_on_edge ? "yes" : "NO", anchor_kept ? "yes" : "NO", behind[1],
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;
/// Optional scripted crosshair (spec §17): `PLOT_DEMO_CROSSHAIR=<row>` (default row 1)
/// places the figure crosshair every frame with the pointer in that row and that row's own
/// y -- the shared x going to all three rows, the horizontal line only to the hovered one.
/// Off unless the environment asks for it, like the sync and region scripts, so a headless
/// screenshot can show the one horizontal line the hovered row draws and none in the others.
u32 crosshair_row = kRows;
if (const char* v = std::getenv("PLOT_DEMO_CROSSHAIR");
v != nullptr && v[0] != '\0' && v[0] != '0') {
crosshair_row = static_cast<u32>(std::strtoul(v, nullptr, 10));
if (crosshair_row >= kRows) crosshair_row = 1u; // a row that does not exist means row 1
}
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());
}
if (crosshair_row < kRows) {
// The pointer at a fixed screen position in the hovered row, read back to *its* data
// coordinates: one x, spread to every row by the figure, and the hovered row's own y.
const plot::Plot& hovered = std::as_const(figure.plot(crosshair_row));
const plot::Rect a = hovered.layout().axes;
figure.set_crosshair(
hovered.x_transform(0u).to_data_x(static_cast<plot::f64>(a.x) + 0.60 * static_cast<plot::f64>(a.w)),
crosshair_row,
hovered.y_transform(0u).to_data_y(static_cast<plot::f64>(a.y) + 0.45 * static_cast<plot::f64>(a.h)),
true);
}
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);
// `x_link_count()` counts link *groups* (excluding the unlinked group 0), so it is
// labelled as one and the members are listed separately: the readout a reader checks the
// link with must not say "rows" about a number of groups.
u32 members = 0;
for (u32 row = 0; row < kRows; ++row) members += figure.x_link(row) == link ? 1u : 0u;
std::printf("[multiplot] x link group %u: range [%.6f, %.6f] s (width %.6f), follow %s,"
" %u member row%s of %u link group%s\n",
link, shared.min, shared.max, shared.span(),
figure.x_link_follow(link) == plot::XFollow::Live ? "Live" : "Off", members,
members == 1u ? "" : "s", figure.x_link_count(),
figure.x_link_count() == 1u ? "" : "s");
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, in link group %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()),
figure.x_link(row));
}
// The crosshair's geometry per row (spec §17), which is what the headless screenshot's ink is
// read against: with the scripted crosshair on row `PLOT_DEMO_CROSSHAIR`, that row has a
// horizontal line and the others do not, while every row has the shared vertical one.
for (u32 row = 0; row < kRows; ++row) {
const plot::CrosshairLayout& c = std::as_const(figure.plot(row)).layout().crosshair;
std::printf("[multiplot] crosshair row %u: active %d, vertical box w %.2f at x %.1f,"
" horizontal box h %.2f at y %.1f\n",
row, c.active ? 1 : 0, static_cast<plot::f64>(c.vertical.w),
static_cast<plot::f64>(c.vertical.x), static_cast<plot::f64>(c.horizontal.h),
static_cast<plot::f64>(c.horizontal.y));
}
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
}