From 25c3381551d90de39807261d90655766ba84d5a9 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Wed, 4 Nov 2026 00:10:47 +0100 Subject: [PATCH] figure: cells of a row can share one y range --- include/plot/plot/axis.hpp | 16 +++++--- include/plot/plot/figure.hpp | 33 ++++++++++++++++ include/plot/plot/plot.hpp | 14 +++++++ src/plot/figure.cpp | 76 ++++++++++++++++++++++++++++++++++++ src/plot/plot.cpp | 16 +++++++- tests/test_figure.cpp | 66 +++++++++++++++++++++++++++++++ 6 files changed, 214 insertions(+), 7 deletions(-) diff --git a/include/plot/plot/axis.hpp b/include/plot/plot/axis.hpp index 24c5971..dccab82 100644 --- a/include/plot/plot/axis.hpp +++ b/include/plot/plot/axis.hpp @@ -102,12 +102,16 @@ struct Axis { /// group stops following or the row leaves it. bool live_owner = false; f64 live_owner_newest = 0.0; - /// Set while this plot is a member of a `Figure` x link group (`Figure::link_x()` - /// writes it, `unlink_row()` clears it). The group owns the shared x window *and* - /// its follow state -- `Figure::link_x()` refuses a `Live` member joiner -- so - /// `Plot::set_x_follow(Live)` on this axis is refused after linking too, rather - /// than letting a later call put the member's axis back into a state the group - /// cannot drive (see `Plot::set_x_follow()`). + /// Set while this axis is a member of a `Figure` link group. On an x axis that is + /// an x link group (`Figure::link_x()` writes it, `unlink_row()` clears it): the + /// group owns the shared x window *and* its follow state -- `Figure::link_x()` + /// refuses a `Live` member joiner -- so `Plot::set_x_follow(Live)` on this axis is + /// refused after linking too, rather than letting a later call put the member's + /// axis back into a state the group cannot drive (see `Plot::set_x_follow()`). + /// On a y axis it is the row's shared-y group (`Figure::set_share_row_y()`): the + /// group, not the plot, owns the range *and* the auto scale state, so + /// `Plot::autoscale_axes()` skips the axis and the figure fits the union of the + /// members' traces instead. bool link_member = false; /// Ticks for the last `refresh()`, ascending, majors and (when asked) minors. std::vector ticks; diff --git a/include/plot/plot/figure.hpp b/include/plot/plot/figure.hpp index 6d746cd..6d0097a 100644 --- a/include/plot/plot/figure.hpp +++ b/include/plot/plot/figure.hpp @@ -314,6 +314,22 @@ class Figure { /// A group's follow state. `Off` for group 0 and for a group that does not exist. XFollow x_link_follow(u32 group) const noexcept; + /// Makes the cells of every split row (two or more) share their y axis 0: one range + /// and one auto scale state per row, default off. A range set on any member -- a + /// wheel, a drag, `Plot::set_y_range()` -- becomes the row's and is pushed to all of + /// its cells at the next `layout()`; the row is auto or manual as one, and while it is + /// auto it fits the union of every cell's visible traces (a cell with none is ignored; + /// padding and log handling are the plot's own, via `Plot::fit_y_bounds()`). The group, + /// not the plot, owns auto scale: members' own autoscale is suppressed, because each + /// would fit only its own traces and the cells would fight over the range. Turning it + /// on or off forgets the groups' state; a row of one cell is never a group. + void set_share_row_y(bool on); + bool share_row_y() const noexcept { return share_row_y_; } + /// Whether figure row `row` is auto scaled as a group. True for a row that is not a + /// group -- fewer than two cells, sharing off, or a row that does not exist -- so + /// "auto" is also the answer when there is no shared state to be manual. + bool row_y_auto(u32 row) const noexcept; + private: /// One link group: the range its members share, the scroll state behind that /// range while it follows, the rows in it (ascending) and, in step with them, the @@ -370,6 +386,21 @@ class Figure { /// a following group's window, push both to all members. void sync_x_links(); + /// A split row's shared y axis: the range last pushed to its cells (the value the + /// next frame compares each cell against, so a gesture is told from the group's own + /// write -- the same device as `XLink::pushed`), whether the group has been + /// initialised from its first cell, and whether it is auto scaled. + struct RowY { + Range pushed{}; + bool have = false; + bool auto_scale = true; + }; + /// Advances every split row's y group for this frame: adopt what a cell was changed + /// to (a range or an auto flag), fit the union of the cells' traces while the group + /// is auto, push the result to all cells. Runs before any `Plot::layout()`, which is + /// where a plot would otherwise autoscale on its own. + void sync_row_y(); + /// Fills the row bookkeeping (`cells_per_row_`, `row_first_`, `cell_weights_`) from a /// per-row cell count, clamping each to at least one. Does not touch `plots_`, which the /// constructors size from `total_cells()`. @@ -381,6 +412,8 @@ class Figure { /// index of row r's first cell and the last entry the plot count. std::vector row_first_; std::vector> cell_weights_; + bool share_row_y_ = false; + std::vector row_y_; f32 col_gap_ = 0.0f; /// What `plot()` yields for a row that does not exist, so a stale row can be used /// without reading out of bounds. Owned rather than a static, so one figure's diff --git a/include/plot/plot/plot.hpp b/include/plot/plot/plot.hpp index 07c5971..4a3f6cd 100644 --- a/include/plot/plot/plot.hpp +++ b/include/plot/plot/plot.hpp @@ -435,6 +435,20 @@ class Plot { /// does not exist. Not for callers outside the figure: a `Plot` that is not in a /// figure never has a linked axis. void set_x_link_member(u32 axis, bool linked) noexcept; + /// The y-side twin of `set_x_link_member()`: publishes that this y axis is a member + /// of its row's shared-y group (`Figure::set_share_row_y()`). A linked y axis is + /// never fitted by `layout()` -- the group owns its range and auto scale, and fits + /// the union of every member's traces -- but a gesture or `set_y_range()` still + /// writes it, which is how the group learns of a change. A no-op for an axis that + /// does not exist. Not for callers outside the figure. + void set_y_link_member(u32 axis, bool linked) noexcept; + /// Union of the bounds of the visible traces bound to y axis `axis`: what a shared-y + /// group collects from each member. False -- and `out` untouched -- when none. + bool y_data_bounds(u32 axis, Range& out) const; + /// Fits y axis `axis` to `bounds` the way `auto_fit_y()` fits it to its own traces + /// (same padding, same log handling), leaving `auto_scale` alone. False -- range + /// untouched -- when the axis does not exist or cannot be fitted to `bounds`. + bool fit_y_bounds(u32 axis, Range bounds); /// Data -> pixels for one axis, built from the axis' range, log flag and the /// current layout. Always invertible: a collapsed range or a collapsed axes diff --git a/src/plot/figure.cpp b/src/plot/figure.cpp index cf10a0c..56da84f 100644 --- a/src/plot/figure.cpp +++ b/src/plot/figure.cpp @@ -434,6 +434,80 @@ bool Figure::adopt_member_changes(XLink& group) { return false; } +void Figure::set_share_row_y(bool on) { + share_row_y_ = on; + row_y_.clear(); + for (u32 row = 0; row < cells_per_row_.size(); ++row) { + if (cells_per_row_[row] < 2u) continue; + for (u32 i = row_first_[row]; i < row_first_[row] + cells_per_row_[row]; ++i) { + plots_[i].set_y_link_member(0u, on); + } + } +} + +bool Figure::row_y_auto(u32 row) const noexcept { + return row < row_y_.size() && row_y_[row].have ? row_y_[row].auto_scale : true; +} + +void Figure::sync_row_y() { + if (!share_row_y_) return; + row_y_.resize(cells_per_row_.size()); + for (u32 row = 0; row < cells_per_row_.size(); ++row) { + const u32 n = cells_per_row_[row]; + if (n < 2u) continue; + RowY& g = row_y_[row]; + const u32 first = row_first_[row]; + // Axes added after set_share_row_y() are covered here. + for (u32 i = first; i < first + n; ++i) plots_[i].set_y_link_member(0u, true); + if (!g.have) { + g.pushed = plots_[first].y_axis(0u).range; + g.auto_scale = plots_[first].y_axis(0u).auto_scale; + } + // What happened since the last push. A member's auto flag that differs from the group's + // is a request (set_y_range clears it, auto_fit_y / set_y_auto_scale(true) sets it); + // otherwise a member whose range differs from the pushed one was moved by a gesture. + bool want_auto = g.auto_scale; + Range target = g.pushed; + bool flag_request = false; + for (u32 i = first; i < first + n && !flag_request; ++i) { + const Axis& a = plots_[i].y_axis(0u); + if (a.auto_scale != g.auto_scale) { + want_auto = a.auto_scale; + if (!want_auto) target = a.range; // auto -> manual: the gesture's range + flag_request = true; + } + } + if (!flag_request && !want_auto) { + for (u32 i = first; i < first + n; ++i) { + const Range r = plots_[i].y_axis(0u).range; + if (r.min != g.pushed.min || r.max != g.pushed.max) { + target = r; + break; + } + } + } + if (want_auto) { + Range bounds{}; + bool any = false; + for (u32 i = first; i < first + n; ++i) { + Range b{}; + if (!plots_[i].y_data_bounds(0u, b)) continue; + bounds = any ? Range{std::min(bounds.min, b.min), std::max(bounds.max, b.max)} : b; + any = true; + } + if (any && plots_[first].fit_y_bounds(0u, bounds)) target = plots_[first].y_axis(0u).range; + // else: nothing to fit -- keep the range the group had + } + for (u32 i = first; i < first + n; ++i) { + plots_[i].set_y_range(0u, target); // clears the flag ... + plots_[i].set_y_auto_scale(0u, want_auto); // ... so restate it: every member mirrors the group + } + g.pushed = target; + g.auto_scale = want_auto; + g.have = true; + } +} + void Figure::sync_x_links() { for (XLink& group : links_) { if (group.rows.empty()) continue; @@ -581,6 +655,8 @@ const FigureLayout& Figure::layout() { // its transforms describe this frame's rects; and before the rows lay themselves // out below, so a following group's window is the range they lay out with. sync_x_links(); + // Before any `Plot::layout()`, where a plot would autoscale its own y axis. + sync_row_y(); // Every row is laid out against its own rect, so after this call each row's // chrome is current: `plot(i).layout()` reads back what the figure computed rather // than what the row last held. That is the contract `Renderer::draw(Figure&)` diff --git a/src/plot/plot.cpp b/src/plot/plot.cpp index e569a49..ce601e3 100644 --- a/src/plot/plot.cpp +++ b/src/plot/plot.cpp @@ -819,6 +819,19 @@ void Plot::set_x_link_member(u32 axis, bool linked) noexcept { x_axes_[axis].link_member = linked; } +void Plot::set_y_link_member(u32 axis, bool linked) noexcept { + if (axis >= y_axes_.size()) return; + y_axes_[axis].link_member = linked; +} + +bool Plot::y_data_bounds(u32 axis, Range& out) const { + return trace_bounds_union(/*x_side=*/false, axis, out); +} + +bool Plot::fit_y_bounds(u32 axis, Range bounds) { + return axis < y_axes_.size() && fit_axis_range(y_axes_[axis], bounds); +} + XFollow Plot::x_follow(u32 axis) const { return axis < x_axes_.size() ? x_axes_[axis].follow : XFollow::Off; } @@ -1001,7 +1014,8 @@ void Plot::autoscale_axes() { if (x_axes_[i].auto_scale && !follows_live(i)) fit_x_axis(i); } for (u32 i = 0; i < y_axes_.size(); ++i) { - if (y_axes_[i].auto_scale) fit_y_axis(i); + // A shared-y member is fitted by its figure, against every cell's traces. + if (y_axes_[i].auto_scale && !y_axes_[i].link_member) fit_y_axis(i); } } diff --git a/tests/test_figure.cpp b/tests/test_figure.cpp index a69c50e..3e8812a 100644 --- a/tests/test_figure.cpp +++ b/tests/test_figure.cpp @@ -1275,3 +1275,69 @@ PLOT_TEST(figure_cells_common_y_gutter) { PLOT_CHECK(g0 > 0.0); PLOT_CHECK_NEAR(g0, g1, 0.01); } + +PLOT_TEST(figure_row_y_cells_share_a_range_after_a_gesture) { + plot::Figure figure = split_figure({2, 1}); + figure.set_share_row_y(true); + figure.layout(); + figure.plot(1u).set_y_range(0u, {-3.0, 7.0}); // what a wheel / drag / pan ends in + figure.layout(); + PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).min, -3.0, 1e-9); + PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).max, 7.0, 1e-9); + PLOT_CHECK_NEAR(figure.plot(1u).y_range(0u).min, -3.0, 1e-9); + PLOT_CHECK_NEAR(figure.plot(1u).y_range(0u).max, 7.0, 1e-9); + // The unsplit row is its own thing. + PLOT_CHECK_NEAR(figure.plot(2u).y_range(0u).min, -1.0, 1e-9); + PLOT_CHECK(!figure.row_y_auto(0u)); +} + +PLOT_TEST(figure_row_y_auto_fits_the_union_and_ignores_empty_cells) { + plot::Figure figure = split_figure({3}); + for (plot::u32 i = 0; i < 3u; ++i) figure.plot(i).set_y_auto_scale(0u, true); + plot::data::ArraySeries a(std::vector{0.0f, 10.0f, 5.0f}, 0.0, 1.0); + plot::data::ArraySeries b(std::vector{-20.0f, 5.0f, 0.0f}, 0.0, 1.0); + figure.plot(0u).add_trace({&a, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u}); + figure.plot(1u).add_trace({&b, plot::Color::hex(0xFF8080), 1.5f, 0u, 0u}); + figure.set_share_row_y(true); // cell 2 has no trace + figure.layout(); + PLOT_CHECK(figure.row_y_auto(0u)); + const plot::Range r0 = figure.plot(0u).y_range(0u); + PLOT_CHECK(r0.min <= -20.0 && r0.max >= 10.0); + PLOT_CHECK(r0.max - r0.min < 40.0); // padded, not blown up + for (plot::u32 i = 1; i < 3u; ++i) { + PLOT_CHECK_NEAR(figure.plot(i).y_range(0u).min, r0.min, 1e-9); + PLOT_CHECK_NEAR(figure.plot(i).y_range(0u).max, r0.max, 1e-9); + } +} + +PLOT_TEST(figure_row_y_manual_then_fit_returns_to_auto) { + plot::Figure figure = split_figure({2}); + for (plot::u32 i = 0; i < 2u; ++i) figure.plot(i).set_y_auto_scale(0u, true); + plot::data::ArraySeries a(std::vector{0.0f, 10.0f}, 0.0, 1.0); + plot::data::ArraySeries b(std::vector{-4.0f, 4.0f}, 0.0, 1.0); + figure.plot(0u).add_trace({&a, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u}); + figure.plot(1u).add_trace({&b, plot::Color::hex(0xFF8080), 1.5f, 0u, 0u}); + figure.set_share_row_y(true); + figure.layout(); + figure.plot(1u).set_y_range(0u, {1.0, 2.0}); + figure.layout(); + PLOT_CHECK(!figure.row_y_auto(0u)); + PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).min, 1.0, 1e-9); + PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).max, 2.0, 1e-9); + figure.layout(); // manual holds: no fight with the plots' own autoscale + PLOT_CHECK_NEAR(figure.plot(1u).y_range(0u).max, 2.0, 1e-9); + figure.plot(0u).auto_fit_y(0u); // what fit-all / double-click does to one cell + figure.layout(); + PLOT_CHECK(figure.row_y_auto(0u)); + PLOT_CHECK(figure.plot(1u).y_range(0u).min <= -4.0 && figure.plot(1u).y_range(0u).max >= 10.0); + PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).min, figure.plot(1u).y_range(0u).min, 1e-9); +} + +PLOT_TEST(figure_row_y_sharing_off_keeps_cells_independent) { + plot::Figure figure = split_figure({2}); + figure.layout(); + figure.plot(1u).set_y_range(0u, {-3.0, 7.0}); + figure.layout(); + PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).max, 1.0, 1e-9); + PLOT_CHECK(!figure.share_row_y()); +}