// 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 #include #include #include #include #include #include #include #include #include #include #include #include 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(i) * kDx; switch (s) { case kSine: return static_cast(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(i) * 2654435761u; h ^= h >> 15; h *= 2246822519u; h ^= h >> 13; return static_cast(2.0 * phase - 1.0 + 0.03 * (static_cast(h & 0xFFFFFFu) / 8388608.0 - 1.0)); } case kSlow: return static_cast(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(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(1.0 - 0.25 * static_cast(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>& 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(clock::now() - start).count(); const u64 due = static_cast(elapsed / kDx) + 1; // samples whose time has come if (due > next) { u64 n = due - next; if (n > kMaxBatch) n = kMaxBatch; batch_.resize(static_cast(n)); for (u32 s = 0; s < kSignalCount; ++s) { for (u64 k = 0; k < n; ++k) { batch_[static_cast(k)] = sample_at(static_cast(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(next + kBatch) * kDx; std::this_thread::sleep_until( start + std::chrono::duration_cast(std::chrono::duration(wake_s))); } } std::vector>* series_; std::vector batch_; std::atomic produced_{0}; std::atomic 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 f64 newest_x(const Series& series) { f64 newest = -std::numeric_limits::infinity(); for (const std::unique_ptr& 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>& 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>& 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(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> series; series.reserve(kSignalCount); for (u32 s = 0; s < kSignalCount; ++s) { series.push_back(std::make_unique(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& 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(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(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(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(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(stats.visible_samples) / static_cast(axes.w) : 0.0; const f64 mean_ms = meter.frames > 0 ? meter.elapsed / static_cast(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(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(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(series[kSine]->size()), static_cast(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(ra.w), static_cast(ra.h), static_cast(ra.x), static_cast(ra.y), p.trace_count(), static_cast(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(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::handle_column(rl.left_handle)), static_cast(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(r->style().dense_threshold_spp) : 0.0, stats.lod_traces > 0 ? "LOD pyramid" : "raw samples", static_cast(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 }