Compare commits
2
Commits
73f610b136
...
2b4b316ad8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b4b316ad8 | ||
|
|
25c3381551 |
No files matched your search
@@ -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<Tick> ticks;
|
||||
|
||||
@@ -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<u32> row_first_;
|
||||
std::vector<std::vector<f32>> cell_weights_;
|
||||
bool share_row_y_ = false;
|
||||
std::vector<RowY> 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
|
||||
|
||||
@@ -40,6 +40,13 @@ namespace plot {
|
||||
/// are for a host that wants one without the other.
|
||||
enum class XStrip { Both, LabelsOnly, TitleOnly, None };
|
||||
|
||||
/// Which parts of the left y chrome `Plot::layout()` reserves. `Both` is a plot drawn on
|
||||
/// its own. `None` reserves no label width, no title column and ignores the figure's common
|
||||
/// gutter: it is the mode of every cell of a split row but the first when the figure shares
|
||||
/// the row's y axis (`Figure::set_share_row_y`), where the first cell draws the one axis.
|
||||
/// The cell's ticks still refresh and its grid still builds.
|
||||
enum class YStrip { Both, None };
|
||||
|
||||
/// Whether a strip mode reserves the label strip. One rule, read by `Plot::layout()`
|
||||
/// when it sizes the strip and by the renderer when it decides whether to stage the
|
||||
/// labels -- a row that reserves none would otherwise stage them over its own data.
|
||||
@@ -238,6 +245,12 @@ class Plot {
|
||||
void set_x_strip_mode(XStrip mode) noexcept { x_strip_ = mode; }
|
||||
XStrip x_strip_mode() const noexcept { return x_strip_; }
|
||||
|
||||
/// Which parts of the left y chrome `layout()` reserves; `YStrip::Both` by default. With
|
||||
/// `YStrip::None` the y label width, the y title column and the figure's common gutter are
|
||||
/// all zero, and the renderer stages no y labels or title (`y_label_area.w == 0`).
|
||||
void set_y_strip_mode(YStrip mode) noexcept { y_strip_ = mode; }
|
||||
YStrip y_strip_mode() const noexcept { return y_strip_; }
|
||||
|
||||
/// The font layout() measures tick labels with. Borrowed, not owned, and may be
|
||||
/// null -- then the label areas fall back to PlotLayout's defaults and no string
|
||||
/// is measured: a plot with no renderer lays out with whatever the host sets
|
||||
@@ -435,6 +448,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
|
||||
@@ -1060,6 +1087,8 @@ class Plot {
|
||||
std::string x_note_;
|
||||
/// Which parts of the bottom x chrome are reserved at all; see set_x_strip_mode().
|
||||
XStrip x_strip_ = XStrip::Both;
|
||||
/// Which parts of the left y chrome are reserved at all; see set_y_strip_mode().
|
||||
YStrip y_strip_ = YStrip::Both;
|
||||
/// The floor a figure imposes on the left gutter; see set_min_y_gutter(). 0.0 means
|
||||
/// none, which is what a plot standing on its own keeps.
|
||||
f32 min_y_gutter_ = 0.0f;
|
||||
|
||||
+105
-1
@@ -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;
|
||||
@@ -567,10 +641,14 @@ const FigureLayout& Figure::layout() {
|
||||
// everywhere, and then there is no shared axis to speak of: each plot has its own. A
|
||||
// split bottom row labels each of its cells, each against its own x axis.
|
||||
const XStrip strip = labels_bottom_only_ && row != bottom_row ? XStrip::None : XStrip::Both;
|
||||
// A split row whose cells share one y range also shares one y axis, drawn by its first
|
||||
// cell: every other cell reserves no y chrome at all (see `YStrip::None`).
|
||||
const bool shared_y = share_row_y_ && cells_per_row_[row] > 1u;
|
||||
for (u32 i = row_first_[row]; i < row_first_[row + 1u]; ++i) {
|
||||
Plot& p = plots_[i];
|
||||
p.set_viewport(layout_.cells[i]);
|
||||
p.set_x_strip_mode(strip);
|
||||
p.set_y_strip_mode(shared_y && i != row_first_[row] ? YStrip::None : YStrip::Both);
|
||||
// Before either pass below, and the same value for every plot: nothing is measured on
|
||||
// the right, so unlike the left gutter this needs no measurement pass -- the plots
|
||||
// end on a common column because they are all told the one width.
|
||||
@@ -581,6 +659,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&)`
|
||||
@@ -611,16 +691,40 @@ const FigureLayout& Figure::layout() {
|
||||
// Over the flattened plots: every cell of every row takes part, so the y axes of a split
|
||||
// row line up with each other and with the rows above and below.
|
||||
f32 gutter = min_y_gutter_;
|
||||
//
|
||||
// A `YStrip::None` cell draws no y axis, so it has no width to contribute and takes no part
|
||||
// in the maximum: the gutter is the widest of the cells that do draw one.
|
||||
for (Plot& p : plots_) {
|
||||
p.set_min_y_gutter(0.0f);
|
||||
const PlotLayout& row_layout = p.layout();
|
||||
if (p.y_strip_mode() == YStrip::None) continue;
|
||||
gutter = std::max(gutter, row_layout.y_axis_width + row_layout.y_title_width);
|
||||
}
|
||||
// Rows that share one y axis divide their *plot* widths, not their cell widths: the first
|
||||
// cell's y strip would otherwise make its plot narrower than its neighbours'. The gutter is
|
||||
// taken off the row first, the remainder is split by `place_cells` (so the weights, the gap
|
||||
// and the floor all apply to plot widths), and the first cell then grows left by the gutter
|
||||
// to hold the axis. The gutter is capped at half the row, so a row too narrow for it
|
||||
// degrades to ordinary cells instead of producing a negative rect. The cells still tile the
|
||||
// row: the first starts on its left edge and the last still ends on its right edge.
|
||||
for (u32 row = 0; row < rows; ++row) {
|
||||
const u32 n = cells_per_row_[row];
|
||||
if (!share_row_y_ || n <= 1u) continue;
|
||||
const Rect& r = layout_.rows[row];
|
||||
const f32 g_eff = std::max(0.0f, std::min(gutter, r.w / 2.0f));
|
||||
Rect* cells = &layout_.cells[row_first_[row]];
|
||||
const f32 used = place_cells(Rect{r.x + g_eff, r.y, r.w - g_eff, r.h}, n, cell_weights_[row],
|
||||
col_gap_, floor_scale, cells);
|
||||
cells[0].x -= g_eff;
|
||||
cells[0].w += g_eff;
|
||||
layout_.col_gap = used < col_gap_ ? 0.0f : layout_.col_gap;
|
||||
for (u32 i = row_first_[row]; i < row_first_[row + 1u]; ++i) plots_[i].set_viewport(layout_.cells[i]);
|
||||
}
|
||||
// A one-row figure imposes exactly what that row measured, so it lays out as the plot
|
||||
// would on its own; a figure whose rows all need the same strip imposes that, which is
|
||||
// also what each would have done alone.
|
||||
for (Plot& p : plots_) {
|
||||
p.set_min_y_gutter(gutter);
|
||||
p.set_min_y_gutter(p.y_strip_mode() == YStrip::None ? 0.0f : gutter);
|
||||
p.layout();
|
||||
}
|
||||
|
||||
|
||||
+24
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1305,6 +1319,11 @@ const PlotLayout& Plot::layout() {
|
||||
// the strip wants and this overrides the want, not the measurement.
|
||||
const bool x_labels = x_strip_has_labels(x_strip_);
|
||||
if (!x_labels) x_height = 0.0f;
|
||||
// The y strip likewise: a cell that gives its y axis to a neighbour (`YStrip::None`) reserves
|
||||
// no label width, no title column and no share of the figure's gutter, so every y-strip
|
||||
// quantity below collapses to zero and `axes` starts at the viewport's left edge.
|
||||
const bool y_none = y_strip_ == YStrip::None;
|
||||
if (y_none) y_width = 0.0f;
|
||||
// The title strips, though, are needed before the loop and never change, unlike
|
||||
// the label strips: a strip holds exactly one text line per titled axis and no
|
||||
// tick label can change that. Nothing at all is reserved without a text backend
|
||||
@@ -1318,7 +1337,7 @@ const PlotLayout& Plot::layout() {
|
||||
// no title reserves no row for a title either, and one that reserves the title and no
|
||||
// labels still gets its row -- below where the labels would have been.
|
||||
const u32 x_titles = x_strip_has_title(x_strip_) ? titled_x_axis_count() : 0u;
|
||||
const f32 y_title_w = text_ != nullptr ? y_title_strip_width() : 0.0f;
|
||||
const f32 y_title_w = text_ != nullptr && !y_none ? y_title_strip_width() : 0.0f;
|
||||
const f32 x_title_h = text_ != nullptr && x_titles != 0u
|
||||
? static_cast<f32>(x_titles) * text_->height() + 2.0f * pad
|
||||
: 0.0f;
|
||||
@@ -1339,7 +1358,7 @@ const PlotLayout& Plot::layout() {
|
||||
// -- and it is applied before the title column below, so the pair still fits the cap
|
||||
// (the floor is clamped to the same budget) and the invariant `axes.w >= share` holds.
|
||||
// `min_y_gutter_` is 0.0 unless a figure set it, so this is a no-op for a lone plot.
|
||||
const f32 y_floor = std::min(min_y_gutter_, y_budget);
|
||||
const f32 y_floor = y_none ? 0.0f : std::min(min_y_gutter_, y_budget);
|
||||
y_width = std::min(std::max(y_width, y_floor - y_title_w), y_budget);
|
||||
x_height = std::min(x_height, x_budget);
|
||||
const f32 y_title_width = std::min(y_title_w, y_budget - y_width);
|
||||
@@ -1372,7 +1391,8 @@ const PlotLayout& Plot::layout() {
|
||||
if (text_ == nullptr || pass + 1 >= kMaxLayoutPasses) break;
|
||||
// The same caps as above, so this compares like with like and the loop still
|
||||
// reaches its fixed point on the second pass.
|
||||
const f32 need_y = std::min(std::clamp(y_label_width(), 0.0f, vw), y_budget);
|
||||
const f32 need_y =
|
||||
y_none ? 0.0f : std::min(std::clamp(y_label_width(), 0.0f, vw), y_budget);
|
||||
// Overridden, not merely unmeasured: the strip is not reserved, so the fixed
|
||||
// point has to be zero for a row that gave its strip away.
|
||||
const f32 need_x =
|
||||
|
||||
@@ -902,9 +902,14 @@ void Renderer::draw_tick_labels(const Plot& plot, const PlotLayout& layout) {
|
||||
// Y labels: right-aligned in their strip, centred on the tick and pulled back
|
||||
// inside the strip at the two extremes (a label centred on the top tick would
|
||||
// otherwise hang above the plot area).
|
||||
//
|
||||
// Unless the plot gave its y strip away (`YStrip::None`, a later cell of a split row that
|
||||
// shares one y axis): its label area is then zero wide, and labels right-aligned against it
|
||||
// would be drawn in the neighbouring cell. Keyed on the area rather than the mode so a strip
|
||||
// the layout clamped to nothing stages nothing either.
|
||||
const f32 top_limit =
|
||||
std::max(layout.y_label_area.y, layout.y_label_area.bottom() - line_h);
|
||||
for (u32 i = 0; i < plot.y_axis_count(); ++i) {
|
||||
for (u32 i = 0; layout.y_label_area.w > 0.0f && i < plot.y_axis_count(); ++i) {
|
||||
const Transform transform = plot.y_transform(i);
|
||||
for (const Tick& tick : plot.y_axis(i).ticks) {
|
||||
if (!tick.major || tick.label.empty()) continue;
|
||||
|
||||
@@ -1275,3 +1275,136 @@ 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<plot::f32>{0.0f, 10.0f, 5.0f}, 0.0, 1.0);
|
||||
plot::data::ArraySeries b(std::vector<plot::f32>{-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<plot::f32>{0.0f, 10.0f}, 0.0, 1.0);
|
||||
plot::data::ArraySeries b(std::vector<plot::f32>{-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());
|
||||
}
|
||||
|
||||
PLOT_TEST(figure_row_y_only_the_first_cell_has_a_gutter) {
|
||||
plot::Figure figure = split_figure({1, 3}, false);
|
||||
for (plot::u32 i = 0; i < figure.plot_count(); ++i) give_magnitude_row(figure.plot(i), 0u);
|
||||
figure.set_share_row_y(true);
|
||||
figure.layout();
|
||||
PLOT_CHECK(figure.plot(0u).layout().y_axis_width > 0.0f); // unsplit row keeps its axis
|
||||
PLOT_CHECK(figure.plot(1u).layout().y_axis_width > 0.0f); // first cell of the split row
|
||||
for (plot::u32 i = 2; i < 4u; ++i) {
|
||||
const plot::PlotLayout& l = figure.plot(i).layout();
|
||||
PLOT_CHECK(l.y_axis_width == 0.0f && l.y_title_width == 0.0f && l.y_label_area.w == 0.0f);
|
||||
PLOT_CHECK(l.axes.w > 0.0f);
|
||||
}
|
||||
// The gutter is one figure-wide value: the unsplit row and the split row's first cell agree.
|
||||
PLOT_CHECK_NEAR(figure.plot(0u).layout().axes.x - figure.layout().cells[0].x,
|
||||
figure.plot(1u).layout().axes.x - figure.layout().cells[1].x, 0.01);
|
||||
}
|
||||
|
||||
PLOT_TEST(figure_row_y_equal_weights_give_equal_plot_widths_and_cells_tile) {
|
||||
plot::Figure figure = split_figure({3}, false);
|
||||
for (plot::u32 i = 0; i < 3u; ++i) give_magnitude_row(figure.plot(i), 0u);
|
||||
figure.set_col_gap(8.0f);
|
||||
figure.set_share_row_y(true);
|
||||
const plot::FigureLayout& fl = figure.layout();
|
||||
const double w0 = figure.plot(0u).layout().axes.w;
|
||||
PLOT_CHECK_NEAR(figure.plot(1u).layout().axes.w, w0, 0.6);
|
||||
PLOT_CHECK_NEAR(figure.plot(2u).layout().axes.w, w0, 0.6);
|
||||
PLOT_CHECK_NEAR(fl.cells[0].x, fl.rows[0].x, 0.001);
|
||||
PLOT_CHECK_NEAR(fl.cells[2].right(), fl.rows[0].right(), 0.001);
|
||||
PLOT_CHECK_NEAR(fl.cells[1].x - fl.cells[0].right(), 8.0, 0.01);
|
||||
PLOT_CHECK_NEAR(fl.cells[2].x - fl.cells[1].right(), 8.0, 0.01);
|
||||
}
|
||||
|
||||
PLOT_TEST(figure_row_y_weights_apply_to_plot_widths) {
|
||||
plot::Figure figure = split_figure({2}, false);
|
||||
for (plot::u32 i = 0; i < 2u; ++i) give_magnitude_row(figure.plot(i), 0u);
|
||||
figure.set_col_gap(0.0f);
|
||||
figure.set_share_row_y(true);
|
||||
const std::vector<plot::f32> w{3.0f, 1.0f};
|
||||
PLOT_CHECK(figure.set_cell_weights(0u, w));
|
||||
figure.layout();
|
||||
const double a0 = figure.plot(0u).layout().axes.w;
|
||||
const double a1 = figure.plot(1u).layout().axes.w;
|
||||
PLOT_CHECK_NEAR(a0 - 64.0, (a1 - 64.0) * 3.0, 1.0);
|
||||
}
|
||||
|
||||
PLOT_TEST(figure_row_y_narrow_row_degrades_without_negative_rects) {
|
||||
plot::Figure figure = split_figure({4}, false);
|
||||
figure.set_viewport(plot::Rect{0.0f, 0.0f, 30.0f, 100.0f});
|
||||
for (plot::u32 i = 0; i < 4u; ++i) give_magnitude_row(figure.plot(i), 0u);
|
||||
figure.set_col_gap(10.0f);
|
||||
figure.set_share_row_y(true);
|
||||
double sum = 0.0;
|
||||
for (const plot::Rect& c : figure.layout().cells) {
|
||||
PLOT_CHECK(c.w >= 0.0f && c.h >= 0.0f);
|
||||
sum += c.w;
|
||||
}
|
||||
PLOT_CHECK_NEAR(sum, 30.0, 0.05);
|
||||
}
|
||||
|
||||
PLOT_TEST(figure_row_y_sharing_off_still_gives_every_cell_a_gutter) {
|
||||
plot::Figure figure = split_figure({2}, false);
|
||||
give_magnitude_row(figure.plot(0u), 0u);
|
||||
give_magnitude_row(figure.plot(1u), 1u);
|
||||
figure.layout();
|
||||
PLOT_CHECK(figure.plot(1u).layout().y_axis_width > 0.0f);
|
||||
}
|
||||
@@ -5059,6 +5059,52 @@ PLOT_TEST(figure_x_title_is_drawn_on_the_bottom_row_only) {
|
||||
|
||||
ctx.destroy();
|
||||
}
|
||||
/// A split row that shares one y axis draws it once, on its first cell: every later cell is
|
||||
/// `YStrip::None`, reserves no y strip, and the renderer must stage no y tick labels for it.
|
||||
/// Staged text is not observable through `FrameStats`, so this reads the pixels. The column gap
|
||||
/// is wide and empty chrome, and a label staged for the second cell would be right-aligned
|
||||
/// against that cell's (zero-width) label area, i.e. land in the gap: the gap holds no ink.
|
||||
PLOT_TEST(figure_shared_y_second_cell_stages_no_y_labels) {
|
||||
plottest::HeadlessContext ctx(kW, kH);
|
||||
plot::Status st;
|
||||
PLOT_CHECK(ctx.create(&st));
|
||||
PLOT_CHECK(plot::gl::load(nullptr));
|
||||
plot::Renderer r;
|
||||
PLOT_CHECK(r.init(&st));
|
||||
|
||||
const std::vector<plot::u32> cells{2u};
|
||||
plot::Figure figure{std::span<const plot::u32>(cells)};
|
||||
figure.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
for (plot::u32 i = 0; i < 2u; ++i) {
|
||||
plot::Plot& p = figure.plot(i);
|
||||
p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
|
||||
p.add_y_axis({"", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||
}
|
||||
figure.set_col_gap(80.0f);
|
||||
figure.set_share_row_y(true);
|
||||
|
||||
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
r.draw(figure);
|
||||
r.end_frame();
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
const plot::FigureLayout layout = figure.layout();
|
||||
const plot::Color ink = r.style().axis_text;
|
||||
|
||||
const plot::PlotLayout& first = figure.plot(0u).layout();
|
||||
const plot::PlotLayout& second = figure.plot(1u).layout();
|
||||
PLOT_CHECK(second.y_label_area.w == 0.0f);
|
||||
PLOT_CHECK(layout.cells[1].x - layout.cells[0].right() > 70.0f);
|
||||
// The first cell still draws the row's one axis ...
|
||||
PLOT_CHECK(scan_rect_ink(ctx, first.y_label_area, ink).pixels > 0u);
|
||||
// ... and nothing is staged in the gap on the second cell's left.
|
||||
// Inset a few pixels each side: the cells' own frame lines sit on the gap's two edges.
|
||||
const plot::Rect gap{layout.cells[0].right() + 4.0f, first.axes.y,
|
||||
layout.cells[1].x - layout.cells[0].right() - 8.0f, first.axes.h};
|
||||
PLOT_CHECK(scan_rect_ink(ctx, gap, ink).pixels == 0u);
|
||||
|
||||
ctx.destroy();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------- the axes clip (spec §13)
|
||||
|
||||
// The reported defect this section is about: a trace is staged in *data* space and
|
||||
|
||||
Reference in new issue
Block a user