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");