From 942f88c568e0ae7f6a0f518ea8719b30450f6db1 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 28 Sep 2026 15:24:22 +0200 Subject: [PATCH] feat(examples): a three-row synced Figure demo, on a GLFW host that draws one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The multi-plot example the phase exists for: three plots stacked in one Figure, sharing one x link with following armed, each row carrying its own named signals and its own legend, driven by six StreamSeries at 500 kS/s. Two cursors and a region with an exact statistics readout reach the direct manipulation of spec §5 in the same frame, and PLOT_DEMO_SYNC=1 is a self-checking mode that exits non-zero unless all three rows hold one range and the shared right edge keeps tracking the newest sample through a zoom, a pan on another row and more arriving data. The GLFW host gains the figure mode the demo needs: set_figure() makes the run loop draw the Figure instead of its own plot and route each pointer, wheel and key event to the row it lands in, through one Controller per row (row_controller()). All of it is opt-in; the single-plot path is untouched. --- adapters/glfw/glfw_host.cpp | 86 +++- adapters/glfw/glfw_host.cpp:386-395 | 13 + adapters/glfw/glfw_host.hpp | 45 ++ examples/CMakeLists.txt | 10 + examples/glfw_demo.cpp | 78 ++-- examples/gtk_demo.cpp | 3 +- examples/imgui_demo.cpp | 3 +- examples/multiplot_demo.cpp | 672 ++++++++++++++++++++++++++++ examples/qt_demo.cpp | 46 +- 9 files changed, 917 insertions(+), 39 deletions(-) create mode 100644 adapters/glfw/glfw_host.cpp:386-395 create mode 100644 examples/multiplot_demo.cpp diff --git a/adapters/glfw/glfw_host.cpp b/adapters/glfw/glfw_host.cpp index 12a7633..20b4b73 100644 --- a/adapters/glfw/glfw_host.cpp +++ b/adapters/glfw/glfw_host.cpp @@ -102,6 +102,10 @@ void Host::close() noexcept { } glfwMakeContextCurrent(nullptr); glfwTerminate(); + // The figure and its row controllers are deliberately *not* dropped here: they are the + // caller's state, created by set_figure(), and a second run() reuses them (the run loop + // hands each row its rect every frame). Dropping them would silently stop routing + // gestures to the rows on the second run. have_pointer_ = false; // A closed host reports no size: a second run() must not see the previous // window's framebuffer size before its first refresh_sizes(). @@ -197,17 +201,39 @@ bool Host::run(const RunConfig& cfg, const FrameFn& frame, Status* st) { // and `begin_frame` must agree on: one rect, in framebuffer pixels, set // before the frame callback runs so it sees the layout it draws into. const Rect viewport{0.0f, 0.0f, static_cast(fb_width_), static_cast(fb_height_)}; - controller_.set_viewport(viewport); // also Plot::set_viewport, then layout() + if (figure_ != nullptr) { + // Figure mode: the rows tile the whole framebuffer, and it is `Figure::layout()` + // that hands each row its rect (and so its own `Plot` its viewport). The single + // controller is untouched -- each row has its own, see controller_at(). + figure_->set_viewport(viewport); + figure_->layout(); + // Every row's controller gets that row's rect, so a gesture -- routed by + // controller_at() or injected by a scripted run -- is hit-tested against the + // geometry the last frame drew, and the two readers of a row's geometry cannot + // disagree about it. + for (u32 i = 0; i < row_controllers_.size(); ++i) { + row_controllers_[i]->set_viewport(figure_->layout().rows[i]); + } + } else { + controller_.set_viewport(viewport); // also Plot::set_viewport, then layout() + } const f64 now = glfwGetTime(); const f64 dt = now - previous; previous = now; - if (frame) frame(plot_, dt); + // A figure's rows take the pointer, but the frame callback still has to be given a + // `Plot&`: row 0 is the one it gets, so the callback argument and `figure()->plot(0)` + // are the same plot. + if (frame) frame(figure_ != nullptr ? figure_->plot(0u) : plot_, dt); const bool exiting = exit_after_s > 0.0 && (now - start) >= exit_after_s; renderer_->begin_frame(fb_width_, fb_height_, viewport); - renderer_->draw(plot_); + if (figure_ != nullptr) { + renderer_->draw(*figure_); + } else { + renderer_->draw(plot_); + } if (exiting && screenshot != nullptr && screenshot[0] != '\0') { Status shot; const bool dumped = renderer_->dump_frame_ppm(screenshot, &shot); @@ -303,7 +329,7 @@ void Host::on_cursor_pos(f64 wx, f64 wy) noexcept { have_pointer_ = true; e.mods = mods_now(window_); e.down = any_button_down(window_); - controller_.on_pointer(e); + controller_at(e.x, e.y).on_pointer(e); } void Host::on_mouse_button(int button, int action, int mods) noexcept { @@ -330,7 +356,7 @@ void Host::on_mouse_button(int button, int action, int mods) noexcept { e.pressed = action == GLFW_PRESS; e.released = action == GLFW_RELEASE; e.down = any_button_down(window_); - controller_.on_pointer(e); + controller_at(e.x, e.y).on_pointer(e); } void Host::on_scroll(f64 dx, f64 dy) noexcept { @@ -343,7 +369,7 @@ void Host::on_scroll(f64 dx, f64 dy) noexcept { e.dx = static_cast(dx); e.dy = static_cast(dy); // GLFW's positive y (scroll up) is the zoom-in sign e.mods = mods_now(window_); - controller_.on_wheel(e); + controller_at(e.x, e.y).on_wheel(e); } void Host::on_key(int key, int action, int mods) noexcept { @@ -367,7 +393,53 @@ void Host::on_key(int key, int action, int mods) noexcept { // GLFW reports auto-repeat as its own action; a held key keeps acting, exactly // as holding the key on a keyboard does. e.down = action != GLFW_RELEASE; - controller_.on_key(e); + // A key has no position of its own: it goes to the row the pointer was last over, + // which is the row the user is looking at. + input::Controller& controller = + (figure_ == nullptr || last_row_ >= row_controllers_.size()) ? controller_ + : *row_controllers_[last_row_]; + controller.on_key(e); +} + +void Host::set_figure(Figure* figure) noexcept { + figure_ = figure; + row_controllers_.clear(); + last_row_ = 0u; + if (figure_ == nullptr) return; + const u32 rows = figure_->row_count(); + row_controllers_.reserve(rows); + for (u32 i = 0; i < rows; ++i) { + row_controllers_.push_back(std::make_unique(figure_->plot(i))); + } +} + +input::Controller* Host::row_controller(u32 row) noexcept { + return row < row_controllers_.size() ? row_controllers_[row].get() : nullptr; +} + +input::Controller& Host::controller_at(f32 x, f32 y) noexcept { + if (figure_ == nullptr || row_controllers_.empty()) return controller_; + const FigureLayout& layout = figure_->layout(); + const std::vector& rows = layout.rows; + if (rows.empty() || rows.size() != row_controllers_.size()) return controller_; + u32 row = 0u; + if (y >= rows.back().y + rows.back().h) { + row = static_cast(rows.size() - 1u); // below every row: the nearest one is the last + } else { + for (u32 i = 0; i < rows.size(); ++i) { + if (y >= rows[i].y && y < rows[i].y + rows[i].h) { + row = i; + break; + } + } + } + (void)x; // the rows tile the figure vertically, so y alone picks one + last_row_ = row; + input::Controller& controller = *row_controllers_[row]; + // The row's own viewport, so the geometry this controller hit-tests is the geometry + // the last frame drew: the same rect `Figure::layout()` gave the row's plot. + controller.set_viewport(rows[row]); + return controller; } } // namespace plot::glfw diff --git a/adapters/glfw/glfw_host.cpp:386-395 b/adapters/glfw/glfw_host.cpp:386-395 new file mode 100644 index 0000000..36dec9a --- /dev/null +++ b/adapters/glfw/glfw_host.cpp:386-395 @@ -0,0 +1,13 @@ + e.mods = static_cast(mods); + // GLFW reports auto-repeat as its own action; a held key keeps acting, exactly + // as holding the key on a keyboard does. + e.down = action != GLFW_RELEASE; + // A key has no position of its own: it goes to the row the pointer was last over, + // which is the row the user is looking at. + input::Controller& controller = + (figure_ == nullptr || last_row_ >= row_controllers_.size()) ? controller_ + : *row_controllers_[last_row_]; + controller.on_key(e); +} + +} // namespace plot::glfw diff --git a/adapters/glfw/glfw_host.hpp b/adapters/glfw/glfw_host.hpp index 4e1915a..0738611 100644 --- a/adapters/glfw/glfw_host.hpp +++ b/adapters/glfw/glfw_host.hpp @@ -1,12 +1,15 @@ #pragma once #include +#include #include +#include #include #include #include #include +#include #include #include @@ -96,6 +99,25 @@ class Host { /// The plot the frame callback populates. Valid for the host's whole life. Plot& plot() noexcept { return plot_; } + /// Draw a whole `Figure` instead of the host's own plot: the multi-plot shape, + /// several rows sharing one x axis, which one `Plot` cannot express. While a + /// figure is set the run loop lays it out over the framebuffer, routes each + /// pointer, wheel and key event to the row the event lands in (one `Controller` + /// per row, created here, each bound to that row's own `Plot`), and draws it with + /// `Renderer::draw(Figure&)`. The frame callback is handed the figure's **row 0** + /// as its `Plot&`, so `plot()` and the frame argument agree. + /// + /// The figure is borrowed and must outlive the run. Passing null restores the + /// single-plot loop, which is what every other caller uses; the host's own + /// `plot_` is then untouched by any of this. + void set_figure(Figure* figure) noexcept; + Figure* figure() noexcept { return figure_; } + /// One figure row's controller -- the one a gesture on that row reaches -- or + /// null while no figure is set or the row does not exist. A host reads a drag + /// mode or a hover out of it (`controller()` is the single-plot loop's, and is + /// not the one figure gestures go to). + input::Controller* row_controller(u32 row) noexcept; + /// The gesture controller the host feeds. Valid for the host's whole life. input::Controller& controller() noexcept { return controller_; } @@ -133,10 +155,33 @@ class Host { void on_scroll(f64 dx, f64 dy) noexcept; void on_key(int key, int action, int mods) noexcept; + /// The controller an event at framebuffer pixel (x, y) belongs to: `controller_` + /// -- the single-plot loop -- while no figure is set, and the controller of the + /// figure row that pixel lands in otherwise. The row rects come from + /// `Figure::layout()`, the same ones the renderer draws, so a gesture lands on the + /// row it was aimed at; a pixel outside every row (the row gap, or a chrome strip + /// below the last row) is clamped to the nearest row rather than dropped, so a + /// drag that leaves the data area still ends where it should. + /// + /// It also hands that row its own viewport, which is what keeps the controller's + /// hit-test geometry in step with the layout between two frames. + input::Controller& controller_at(f32 x, f32 y) noexcept; + Plot plot_; input::Controller controller_; ///< forwards its viewport to `plot_` std::optional renderer_; GLFWwindow* window_ = nullptr; + /// The figure the host draws instead of `plot_`, and one controller per row for + /// it -- one per `Plot`, because a `Controller` is bound to a plot at construction + /// and this is what makes a gesture on any row reach that row. Both are empty + /// until `set_figure()` is called, and the run loop's single-plot path reads + /// neither. + Figure* figure_ = nullptr; + std::vector> row_controllers_; + /// The row the last event landed in, which is where a key event goes: a key has + /// no position of its own, and the row the pointer is over is the one the user is + /// looking at. + u32 last_row_ = 0u; u32 fb_width_ = 0; ///< framebuffer size of the last refresh_sizes() u32 fb_height_ = 0; diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 99929ae..6ebb85f 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -9,6 +9,16 @@ if(TARGET plot::glfw) plot_apply_warnings(plot_glfw_demo) endif() +if(TARGET plot::glfw) + # The multi-plot example: a three-row `Figure` sharing one x axis. It needs the GLFW + # host's figure mode (the host draws the figure and routes each row's gestures), so it + # is guarded by the same target as the single-plot demo above; its producer thread is + # the only extra dependency. + add_executable(plot_multiplot_demo multiplot_demo.cpp) + target_link_libraries(plot_multiplot_demo PRIVATE plot::glfw Threads::Threads) + plot_apply_warnings(plot_multiplot_demo) +endif() + if(TARGET plot::imgui) # The demo owns the window, the ImGui context and the frame loop itself, so it # links the toolkit and its backends directly; the adapter is only the widget. diff --git a/examples/glfw_demo.cpp b/examples/glfw_demo.cpp index 3f28d33..1a2879d 100644 --- a/examples/glfw_demo.cpp +++ b/examples/glfw_demo.cpp @@ -366,6 +366,10 @@ struct RegionScript { /// Set once the third measurement has been taken: a run that ended before it has not /// passed, it just has not finished. bool completed = false; + /// Set in the first step, where the script places the region's bounds itself: from then + /// on the frame callback must leave the region alone, or the per-frame placement of the + /// demo's overlay would undo the very gesture this script measures. + bool owns_geometry = false; plot::u32 step = 0; plot::Rect before{}; plot::Rect after{}; @@ -393,6 +397,7 @@ struct RegionScript { p.set_x_range(axis, {live.max - kRegionWindowS, live.max}); const plot::Range w = p.x_range(axis); p.set_region(region, w.min + 0.30 * w.span(), w.min + 0.55 * w.span()); + owns_geometry = true; p.layout(); const plot::RegionLayout* rl = region_layout(p, region); if (rl == nullptr) { @@ -502,7 +507,10 @@ int main() { {"time", {-0.5, 0.5}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time}); const u32 axis_y = p.add_y_axis( {"signal", {-4.0, 4.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si}); - const u32 trace = p.add_trace({&stream, kTraceColor, 1.5f, axis_x, axis_y}); + // The trace is named, so the plot's legend -- on by default, per `Legend` -- lists it. + // An unnamed trace contributes no entry and an all-unnamed plot draws no panel, which is + // what keeps the legend free for a caller who never names one. + const u32 trace = p.add_trace({&stream, kTraceColor, 1.5f, axis_x, axis_y, "signal"}); // Snapped cursors sit exactly on a sample, so the readout measures samples // rather than a rounded position between two of them. @@ -521,22 +529,25 @@ int main() { /// as the follow script: a driven run whose verdict is carried out in its `failed`. RegionScript region_script = RegionScript::from_env(); - // The region that script drags, and the element the pixel counts are of. It exists only - // while the script is armed: the default run is the frame the four adapters copy, and an - // element nothing drives would be a change to it rather than a demonstration. The script - // places its bounds once the view it is measured against has been frozen. + // The region the frame carries: a direct-manipulation primitive with the statistics + // readout that is the point of one, so the demo reaches it without a script as well as + // through one. Its bounds ride the live window while the user has not taken the overlay + // over (see `overlay_follows`), and `PLOT_DEMO_REGIONS` freezes the view and drives it + // instead. plot::Region region_desc; region_desc.snap = plot::RegionSnap::Free; region_desc.fill = kRegionFill; region_desc.edge = kRegionEdge; region_desc.label = "roi"; - const u32 region = region_script.enabled ? p.add_region(region_desc) : 0u; - // The cursors ride the live window until the user takes over: the first - // ctrl+drag on a cursor, or a pan/zoom that clears the x axis' auto scale, - // stops them where the user put them. Under `Live` the follow path owns the x - // range whatever auto scale says, so a zoom is not the user taking the x view - // over -- only a cursor drag, or following being switched off, is. - bool cursors_follow = true; + const u32 region = p.add_region(region_desc); + // The cursors and the region ride the live window until the user takes the overlay over: + // the first drag on either, or a pan or zoom that clears the x axis' auto scale, stops + // them where the user put them. Under `Live` the follow path owns the x range whatever + // auto scale says, so a zoom is not the user taking the x view over -- only a handle + // drag, or following being switched off, is. While `PLOT_DEMO_REGIONS` is running its + // script owns the region's geometry instead (it measures one gesture against the next + // frame's), so the overlay is left alone for the length of that run. + bool overlay_follows = true; const plot::glfw::FrameFn frame = [&](plot::Plot& graph, plot::f64 dt) { stream.acquire(); // once per frame, on the render thread @@ -554,16 +565,22 @@ int main() { } if (graph.y_axis(axis_y).auto_scale) graph.auto_fit_y(axis_y); - if (cursors_follow) { + if (overlay_follows && !(region_script.enabled && region_script.owns_geometry)) { + const plot::Range xr = graph.x_range(axis_x); const bool x_taken_over = graph.x_follow(axis_x) == plot::XFollow::Live ? false : !graph.x_axis(axis_x).auto_scale; - if (host.controller().drag_mode() == plot::input::DragMode::MoveCursor || x_taken_over) { - cursors_follow = false; + const plot::input::DragMode mode = host.controller().drag_mode(); + const bool dragging = mode == plot::input::DragMode::MoveCursor || + mode == plot::input::DragMode::MoveRegion || + mode == plot::input::DragMode::ResizeRegionLeft || + mode == plot::input::DragMode::ResizeRegionRight; + if (dragging || x_taken_over) { + overlay_follows = false; } else { - const plot::Range xr = graph.x_range(axis_x); graph.set_cursor_x(cursor_a, xr.min + 0.35 * xr.span()); graph.set_cursor_x(cursor_b, xr.min + 0.65 * xr.span()); + graph.set_region(region, xr.min + 0.40 * xr.span(), xr.min + 0.60 * xr.span()); } } @@ -690,22 +707,31 @@ int main() { stats.draw_calls, stats.lod_traces, stats.raw_traces); std::printf("[glfw_demo] measurement %s dx %g s dy %g rms %g mean %g\n", m.valid ? "valid:" : "INVALID", m.dx, m.dy, m.rms, m.mean); + // The region's statistics, in every run: this is the direct-manipulation primitive's + // readout, and the numbers the pixel evidence is read against. Exact -- the interval + // queries go through `Series::reduce/sum/sumsq`, never the pyramid's bucket forms. + if (const plot::RegionStats& rs = p.region_stats(region, trace); rs.valid) { + std::printf("[glfw_demo] region %u: x [%g, %g] s, n %llu, mean %g, rms %g, min %g, max" + " %g, integral %g\n", + region, rs.x0, rs.x1, static_cast(rs.count), rs.mean, rs.rms, + rs.min, rs.max, rs.integral); + } + if (const plot::RegionLayout* rl = region_layout(p, region); rl != nullptr) { + // The last frame's geometry, as the layout measured it: the rect the fill, the bound + // strokes and the pixel counts all have to agree with. + std::printf("[glfw_demo] region fill x %.1f y %.1f w %.1f h %.1f, bounds at %.1f and" + " %.1f, tab w %.1f, label w %.1f\n", + 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)), + rl->left_handle.w, rl->label.w); + } if (region_script.enabled) { std::printf("[glfw_demo] region %u: x [%g, %g] s, snap %s, %s; checks %s\n", region, p.region(region).x0, p.region(region).x1, p.region(region).snap == plot::RegionSnap::Free ? "free" : "nearest sample", region_script.hover ? "bound hovered at exit" : "pointer off the data area at exit", region_script.failed ? "FAILED" : "passed"); - if (const plot::RegionLayout* rl = region_layout(p, region); rl != nullptr) { - // The last frame's geometry, as the layout measured it: the rect the fill, the bound - // strokes and the pixel counts all have to agree with. - std::printf("[glfw_demo] region fill x %.1f y %.1f w %.1f h %.1f, bounds at %.1f and" - " %.1f, tab w %.1f, label w %.1f\n", - 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)), - rl->left_handle.w, rl->label.w); - } } std::printf("[glfw_demo] upload path %s, gl error on the last frame %d\n", r != nullptr && r->using_persistent_upload() ? "persistent-mapped ring" : "orphan", diff --git a/examples/gtk_demo.cpp b/examples/gtk_demo.cpp index b32926c..4b53094 100644 --- a/examples/gtk_demo.cpp +++ b/examples/gtk_demo.cpp @@ -623,7 +623,8 @@ int main(int argc, char** argv) { {"time", {-0.5, 0.5}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time}); const u32 axis_y = graph.add_y_axis( {"signal", {-4.0, 4.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si}); - const u32 trace = graph.add_trace({&stream, kTraceColor, 1.5f, axis_x, axis_y}); + // Named, so the plot's legend -- on by default -- lists the signal. + const u32 trace = graph.add_trace({&stream, kTraceColor, 1.5f, axis_x, axis_y, "signal"}); // Snapped cursors sit exactly on a sample, so the readout measures samples and // not a rounded position between two of them. diff --git a/examples/imgui_demo.cpp b/examples/imgui_demo.cpp index 4e911e7..0a4c5fd 100644 --- a/examples/imgui_demo.cpp +++ b/examples/imgui_demo.cpp @@ -350,7 +350,8 @@ int main() { {"time", {-0.5, 0.5}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time}); const u32 axis_y = graph.add_y_axis( {"signal", {-4.0, 4.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si}); - graph.add_trace({&stream, kTraceColor, 1.5f, axis_x, axis_y}); + // Named, so the plot's legend -- on by default -- lists the signal. + graph.add_trace({&stream, kTraceColor, 1.5f, axis_x, axis_y, "signal"}); plot::imgui::PlotWidget widget; diff --git a/examples/multiplot_demo.cpp b/examples/multiplot_demo.cpp new file mode 100644 index 0000000..d71ec3b --- /dev/null +++ b/examples/multiplot_demo.cpp @@ -0,0 +1,672 @@ +// The multi-plot application: three plots stacked in one `Figure`, sharing one x +// axis, each carrying its own signals and its own legend. +// +// The shape the library did not have before Task 6, and the one a real instrument +// wants: a main plot, a secondary plot at a different scale, and a sparse event +// plot, all framed by one time window that pans and zooms as one. What it +// demonstrates: +// +// - a `Figure` of three rows, every row its own `Plot` with its own y axis and its +// own legend -- row 0 three named signals on one shared y axis (a sine, a square +// and a noisy ramp), row 1 two signals at a different scale (a slow sine at +-100 +// and a decaying burst at +20), row 2 one sparse spike train; +// - **one x link with following armed**: the three rows are in one link group +// (`Figure::add_x_link`/`link_x`/`set_x_link_follow`), so the window is derived +// once per frame from the newest sample any member holds and pushed to all three. +// A wheel zoom or a drag on *any* row therefore moves every row together, and new +// data keeps arriving at the right edge -- the window slides instead of the trace +// running off it; +// - the direct manipulation of spec §5, reachable in this demo: two cursors on row 0 +// with a measurement band between them, and one `Region` whose exact statistics are +// printed with every metered report. Both ride the live window until the user +// grabs one; +// - six `StreamSeries` at 500 kS/s pushed from one producer thread, acquired once per +// frame, so the shared window is exercised while data slides and the traces are drawn +// from the pyramid (620 samples per pixel, well past the dense threshold); +// - the headless hooks the GLFW adapter implements once +// (PLOT_DEMO_EXIT_AFTER_MS / PLOT_DEMO_SCREENSHOT), for a CI run under `xvfb-run`; +// - `PLOT_DEMO_SYNC=1`: the same run with the scripted synced-follow verification. It +// measures, per frame, that all three rows hold *one* range and that the range's +// right edge keeps tracking the newest sample -- at the live edge to begin with, +// after a zoom over the shared axis strip, after a pan on a *different* row and +// again after more data has arrived. Like the GLFW demo's follow script it is a +// check and not a readout: the run exits non-zero if a property fails, or if the +// sequence never got far enough to measure all four. +// +// The producer is joined before the streams it writes into are destroyed, and before +// the host closes the GL context (main's declaration order does that: the series are +// declared first, the host last). + +#include + +#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); + 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 +} diff --git a/examples/qt_demo.cpp b/examples/qt_demo.cpp index 363efaa..0fac443 100644 --- a/examples/qt_demo.cpp +++ b/examples/qt_demo.cpp @@ -86,6 +86,10 @@ constexpr plot::Color kExtraCursorColors[] = {plot::Color::hex(0xFF6B6B), plot::Color::hex(0xB08CFF), plot::Color::hex(0x6BE3FF), plot::Color::hex(0xE8E86B)}; +/// The region's fill and bound stroke, in colours nothing else here draws, so a pixel +/// count of either is about the region. +constexpr plot::Color kRegionFill = plot::Color::hex(0x33FF00, 0.35f); +constexpr plot::Color kRegionEdge = plot::Color::hex(0xFF00FF); /// Logical pixels. The widget's framebuffer is `devicePixelRatioF()` times this. constexpr int kWindowWidth = 1200; @@ -267,7 +271,8 @@ int main(int argc, char** argv) { {"time", {-0.5, 0.5}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time}); const u32 axis_y = graph.add_y_axis( {"signal", {-4.0, 4.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si}); - const u32 trace = graph.add_trace({&stream, kTraceColor, 1.5f, axis_x, axis_y}); + // Named, so the plot's legend -- on by default -- lists the signal. + const u32 trace = graph.add_trace({&stream, kTraceColor, 1.5f, axis_x, axis_y, "signal"}); // Snapped cursors sit exactly on a sample, so the readout measures samples and // not a rounded position between two of them. The pair and its measurement are @@ -278,6 +283,16 @@ int main(int argc, char** argv) { graph.set_cursor_snap(cursor_b, plot::SnapMode::NearestSample); const u32 measure = graph.add_measure(cursor_a, cursor_b, trace); + // One region, so the direct manipulation of spec §5 is reachable in this demo too: a + // shaded interval with the statistics readout in the exit frame. Its bounds ride the + // live window until the user drags a handle, exactly as the cursors do. + plot::Region region_desc; + region_desc.snap = plot::RegionSnap::Free; + region_desc.fill = kRegionFill; + region_desc.edge = kRegionEdge; + region_desc.label = "roi"; + const u32 region = graph.add_region(region_desc); + // --- the window, its toolbar and the widget ---------------------------- QMainWindow window; window.setWindowTitle("plot qt demo"); @@ -303,7 +318,7 @@ int main(int argc, char** argv) { Meter meter; // The cursors ride the live window until the user takes over: the first // ctrl+drag on a cursor, or a pan/zoom that clears the x axis' auto scale, - // stops them where the user put them. + // stops them where the user put them. The region's bounds ride the same window. bool cursors_follow = true; plot::usize extra_cursor = 0; @@ -317,13 +332,18 @@ int main(int argc, char** argv) { if (p.y_axis(axis_y).auto_scale) p.auto_fit_y(axis_y); if (cursors_follow) { - if (widget->controller().drag_mode() == plot::input::DragMode::MoveCursor || - !p.x_axis(axis_x).auto_scale) { + const plot::input::DragMode mode = widget->controller().drag_mode(); + const bool dragging = mode == plot::input::DragMode::MoveCursor || + mode == plot::input::DragMode::MoveRegion || + mode == plot::input::DragMode::ResizeRegionLeft || + mode == plot::input::DragMode::ResizeRegionRight; + if (dragging || !p.x_axis(axis_x).auto_scale) { cursors_follow = false; } else { const plot::Range xr = p.x_range(axis_x); p.set_cursor_x(cursor_a, xr.min + 0.35 * xr.span()); p.set_cursor_x(cursor_b, xr.min + 0.65 * xr.span()); + p.set_region(region, xr.min + 0.40 * xr.span(), xr.min + 0.60 * xr.span()); } } @@ -420,6 +440,24 @@ int main(int argc, char** argv) { static_cast(after.axes.w), static_cast(after.axes.h), static_cast(after.axes.x), static_cast(after.axes.y)); + // The region's last-frame geometry and its exact statistics, so the screenshot's + // pixels can be read against the rects the layout measured (`Series::reduce/sum/sumsq` + // over the interval, never the pyramid's bucket forms). + for (const plot::RegionLayout& rl : after.regions) { + if (rl.region != region) continue; + const plot::RegionStats& rs = graph.region_stats(region, trace); + std::printf("[qt_demo] 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))); + if (rs.valid) { + std::printf("[qt_demo] region %u: x [%g, %g] s, n %llu, mean %g, rms %g, min %g, max" + " %g, integral %g\n", + region, rs.x0, rs.x1, static_cast(rs.count), rs.mean, + rs.rms, rs.min, rs.max, rs.integral); + } + } if (screenshot != nullptr && screenshot[0] != '\0') { const bool written = dump_widget_ppm(image, screenshot); std::printf("[qt_demo] screenshot %s: %s\n", screenshot, written ? "written" : "FAILED");