fix(plot): a figure gives every row the same y gutter so the axes line up
This commit is contained in:
1 parent
663b57c679
commit
cae672bc99
6 files changed
+348
-5
No files matched your search
@@ -136,6 +136,14 @@ class Figure {
|
|||||||
/// This is also where a row's `PlotLayout` becomes well-defined: a row's chrome is
|
/// This is also where a row's `PlotLayout` becomes well-defined: a row's chrome is
|
||||||
/// laid out against `rows[i]` by its own `Plot::layout()`, which is why the figure
|
/// laid out against `rows[i]` by its own `Plot::layout()`, which is why the figure
|
||||||
/// computes rects and not axes.
|
/// computes rects and not axes.
|
||||||
|
///
|
||||||
|
/// It is also the one place the rows' left chrome can be made to agree (spec §16):
|
||||||
|
/// each row is laid out once to learn the gutter its own labels and title column
|
||||||
|
/// need, the figure takes the maximum and imposes it on every row through
|
||||||
|
/// `Plot::set_min_y_gutter()` before laying the rows out once more -- so every row's
|
||||||
|
/// `axes.x` and `axes.w` are equal and a vertical feature lands in the same pixel
|
||||||
|
/// column in each. Two passes per row per call, not a loop: the gutter is a maximum
|
||||||
|
/// over rows and a row's own strip width does not depend on it.
|
||||||
const FigureLayout& layout();
|
const FigureLayout& layout();
|
||||||
const FigureLayout& layout() const noexcept { return layout_; }
|
const FigureLayout& layout() const noexcept { return layout_; }
|
||||||
|
|
||||||
|
|||||||
@@ -524,6 +524,24 @@ class Plot {
|
|||||||
/// a lookup on a const Plot cannot move the axes out from under the caller.
|
/// a lookup on a const Plot cannot move the axes out from under the caller.
|
||||||
const PlotLayout& layout() const noexcept { return layout_; }
|
const PlotLayout& layout() const noexcept { return layout_; }
|
||||||
|
|
||||||
|
/// A floor on the left gutter `layout()` reserves, in device pixels: the label
|
||||||
|
/// strip and the title column together are never narrower than this, so `axes`
|
||||||
|
/// never starts left of `viewport().x + min_y_gutter()`. 0.0 -- the default --
|
||||||
|
/// imposes nothing and the plot sizes its strip from its own widest tick label,
|
||||||
|
/// which is what a plot standing on its own has always done.
|
||||||
|
///
|
||||||
|
/// It exists for `Figure`, which is the only thing that can know the answer: a row
|
||||||
|
/// cannot see its siblings' tick labels, so the figure lays each row out once to
|
||||||
|
/// learn the gutter it needs, takes the maximum and sets it here on every row --
|
||||||
|
/// after which all the rows' `axes.x` and `axes.w` agree and a vertical feature
|
||||||
|
/// lands in the same pixel column in each. The floor is applied inside the same cap
|
||||||
|
/// as the measured strip, so a figure's gutter can never squeeze `axes` below the
|
||||||
|
/// half-viewport floor either. Negative values are clamped to 0.
|
||||||
|
void set_min_y_gutter(f32 device_px) noexcept {
|
||||||
|
min_y_gutter_ = device_px > 0.0f ? device_px : 0.0f;
|
||||||
|
}
|
||||||
|
f32 min_y_gutter() const noexcept { return min_y_gutter_; }
|
||||||
|
|
||||||
/// Grid segments for the primary axis pair (x axis 0, y axis 0), in data space.
|
/// Grid segments for the primary axis pair (x axis 0, y axis 0), in data space.
|
||||||
const Grid& grid() const noexcept { return grid_; }
|
const Grid& grid() const noexcept { return grid_; }
|
||||||
|
|
||||||
@@ -713,6 +731,9 @@ class Plot {
|
|||||||
Legend legend_{};
|
Legend legend_{};
|
||||||
/// Which parts of the bottom x chrome are reserved at all; see set_x_strip_mode().
|
/// Which parts of the bottom x chrome are reserved at all; see set_x_strip_mode().
|
||||||
XStrip x_strip_ = XStrip::Both;
|
XStrip x_strip_ = XStrip::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;
|
||||||
/// Which handles a pointer is on, published by the input layer (see HotHandles).
|
/// Which handles a pointer is on, published by the input layer (see HotHandles).
|
||||||
HotHandles hot_{};
|
HotHandles hot_{};
|
||||||
|
|
||||||
|
|||||||
+31
-2
@@ -393,12 +393,41 @@ const FigureLayout& Figure::layout() {
|
|||||||
// its transforms describe this frame's rects; and before the rows lay themselves
|
// 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.
|
// out below, so a following group's window is the range they lay out with.
|
||||||
sync_x_links();
|
sync_x_links();
|
||||||
// Every row is laid out against its own rect here, so after this call each row's
|
// 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
|
// 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&)`
|
// than what the row last held. That is the contract `Renderer::draw(Figure&)`
|
||||||
// relies on -- it draws each row with the layout this produced, and `draw(Plot&)`
|
// relies on -- it draws each row with the layout this produced, and `draw(Plot&)`
|
||||||
// is the one that computes its own.
|
// is the one that computes its own.
|
||||||
for (u32 row = 0; row < rows; ++row) plots_[row].layout();
|
//
|
||||||
|
// The rows' axes have to line up (spec §16), so this is where the figure's **common
|
||||||
|
// left gutter** is decided -- a row cannot see its siblings' tick labels, so the one
|
||||||
|
// width they can agree on has to be handed to them from here.
|
||||||
|
//
|
||||||
|
// Two passes, and no more. The first lays every row out with no floor, so each
|
||||||
|
// reports the gutter its own labels and title column need; the figure keeps the
|
||||||
|
// maximum. The second re-lays every row with that maximum imposed on its
|
||||||
|
// `Plot::set_min_y_gutter()`, which widens a row's strip to fill it, so all the rows'
|
||||||
|
// `axes` start and end on the same columns. A fixed-point loop -- re-measuring until
|
||||||
|
// the rows stop moving -- is the tempting wrong answer and is not needed: the gutter
|
||||||
|
// is a *maximum* over rows, and a row's own strip width does not depend on any other
|
||||||
|
// row's or on the width imposed on it (y tick labels are chosen from the row's own
|
||||||
|
// range and height, and a title column is one text line per titled axis), so one read
|
||||||
|
// per row settles the answer exactly. Measuring with a floor from the previous frame
|
||||||
|
// would be the only reason to iterate, and clearing it before the measurement pass is
|
||||||
|
// what removes it.
|
||||||
|
f32 gutter = 0.0f;
|
||||||
|
for (u32 row = 0; row < rows; ++row) {
|
||||||
|
plots_[row].set_min_y_gutter(0.0f);
|
||||||
|
const PlotLayout& row_layout = plots_[row].layout();
|
||||||
|
gutter = std::max(gutter, row_layout.y_axis_width + row_layout.y_title_width);
|
||||||
|
}
|
||||||
|
// 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 (u32 row = 0; row < rows; ++row) {
|
||||||
|
plots_[row].set_min_y_gutter(gutter);
|
||||||
|
plots_[row].layout();
|
||||||
|
}
|
||||||
|
|
||||||
// The figure's one x axis is the bottom row's, so the strip it is drawn in is that
|
// The figure's one x axis is the bottom row's, so the strip it is drawn in is that
|
||||||
// row's own label area -- the rect the controller hit-tests and the renderer stages
|
// row's own label area -- the rect the controller hit-tests and the renderer stages
|
||||||
|
|||||||
+16
-3
@@ -836,7 +836,14 @@ const PlotLayout& Plot::layout() {
|
|||||||
x_height = std::clamp(x_height, 0.0f, viewport_.h);
|
x_height = std::clamp(x_height, 0.0f, viewport_.h);
|
||||||
const f32 y_budget = (1.0f - kMinAxesShare) * viewport_.w;
|
const f32 y_budget = (1.0f - kMinAxesShare) * viewport_.w;
|
||||||
const f32 x_budget = (1.0f - kMinAxesShare) * viewport_.h;
|
const f32 x_budget = (1.0f - kMinAxesShare) * viewport_.h;
|
||||||
y_width = std::min(y_width, y_budget);
|
// The figure's common gutter, when one was imposed: a floor on `y_width + y_title_w`
|
||||||
|
// so every row's axes starts at the same x. It is served by the *label* strip -- the
|
||||||
|
// title column is one text line per titled axis and cannot be widened to fill a gutter
|
||||||
|
// -- 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);
|
||||||
|
y_width = std::min(std::max(y_width, y_floor - y_title_w), y_budget);
|
||||||
x_height = std::min(x_height, x_budget);
|
x_height = std::min(x_height, x_budget);
|
||||||
const f32 y_title_width = std::min(y_title_w, y_budget - y_width);
|
const f32 y_title_width = std::min(y_title_w, y_budget - y_width);
|
||||||
const f32 x_title_height = std::min(x_title_h, x_budget - x_height);
|
const f32 x_title_height = std::min(x_title_h, x_budget - x_height);
|
||||||
@@ -865,8 +872,14 @@ const PlotLayout& Plot::layout() {
|
|||||||
const f32 need_x =
|
const f32 need_x =
|
||||||
x_labels ? std::min(std::clamp(text_->height() + 2.0f * pad, 0.0f, viewport_.h), x_budget)
|
x_labels ? std::min(std::clamp(text_->height() + 2.0f * pad, 0.0f, viewport_.h), x_budget)
|
||||||
: 0.0f;
|
: 0.0f;
|
||||||
if (need_y == y_width && need_x == x_height) break; // strips are a fixed point
|
// What the strip *wants* is the label's own width or the figure's floor, whichever is
|
||||||
y_width = need_y;
|
// greater -- the floor is a maximum over sources, settled by one `max` and not by
|
||||||
|
// asking the loop to grow the strip towards it pass by pass. Comparing the measured
|
||||||
|
// need alone would never agree once a floor sits above it, and the loop would run its
|
||||||
|
// cap out; comparing the want is what keeps it a fixed point here too.
|
||||||
|
const f32 want_y = std::min(std::max(need_y, y_floor - y_title_w), y_budget);
|
||||||
|
if (want_y == y_width && need_x == x_height) break; // strips are a fixed point
|
||||||
|
y_width = want_y;
|
||||||
x_height = need_x;
|
x_height = need_x;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,42 @@ std::vector<plot::Rect> rows_of(plot::Figure& figure) {
|
|||||||
return figure.layout().rows;
|
return figure.layout().rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Gives one plot the axes of the misalignment cases: a fixed x range and a fixed y
|
||||||
|
/// range from `give_magnitude_row()`'s table. Fixed (`auto_scale` off), so the labels a row
|
||||||
|
/// measures are the ones its magnitude produces whatever order the rows are laid out
|
||||||
|
/// in -- which is what makes two rows' strips comparable.
|
||||||
|
void give_magnitude_row(plot::Plot& p, plot::u32 which) {
|
||||||
|
static plot::render::EmbeddedMonoFont font;
|
||||||
|
const plot::Range y_ranges[3] = {{0.0, 671.0e6}, {0.0, 90.0}, {0.0, 2.51}};
|
||||||
|
p.set_text_backend(&font);
|
||||||
|
p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
|
||||||
|
p.add_y_axis({"v", y_ranges[which % 3u], false, plot::AxisPlace::Left, false});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A figure whose rows carry deliberately different magnitudes: the "671m"-scale
|
||||||
|
/// row's tick labels are four characters wide, the "90" row's two and the "2.51"
|
||||||
|
/// row's three. That is the shape the reported misalignment had -- each row sized its
|
||||||
|
/// own y strip from its own widest label, so the rows' axes started at three different
|
||||||
|
/// x.
|
||||||
|
plot::Figure different_magnitude_figure(plot::u32 rows = 3u) {
|
||||||
|
plot::Figure figure(rows);
|
||||||
|
figure.set_viewport(whole_figure());
|
||||||
|
for (plot::u32 row = 0; row < rows; ++row) give_magnitude_row(figure.plot(row), row);
|
||||||
|
return figure;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The left gutter `which`'s row would reserve for itself if it were a plot *standing
|
||||||
|
/// alone* in `viewport` -- the label strip plus the title column, which is the width a
|
||||||
|
/// figure's common gutter is a maximum over. The same axes, text backend and metrics,
|
||||||
|
/// laid out with no figure floor, so reading it leaves the figure's rows untouched.
|
||||||
|
plot::f32 natural_gutter_width(plot::u32 which, plot::Rect viewport) {
|
||||||
|
plot::Plot solo;
|
||||||
|
give_magnitude_row(solo, which);
|
||||||
|
solo.set_viewport(viewport);
|
||||||
|
solo.layout();
|
||||||
|
return solo.layout().y_axis_width + solo.layout().y_title_width;
|
||||||
|
}
|
||||||
|
|
||||||
bool rows_overlap(const plot::Rect& a, const plot::Rect& b) {
|
bool rows_overlap(const plot::Rect& a, const plot::Rect& b) {
|
||||||
return a.x < b.right() && b.x < a.right() && a.y < b.bottom() && b.y < a.bottom();
|
return a.x < b.right() && b.x < a.right() && a.y < b.bottom() && b.y < a.bottom();
|
||||||
}
|
}
|
||||||
@@ -255,6 +291,135 @@ PLOT_TEST(every_row_keeps_a_positive_height) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------- y gutter alignment
|
||||||
|
|
||||||
|
/// Spec §16, the invariant: a figure gives its rows one common left gutter, so every
|
||||||
|
/// row's `axes` starts and ends on the same pixel columns. This is the mechanism; the
|
||||||
|
/// case below is the drawn consequence a user sees as "the y axes line up".
|
||||||
|
PLOT_TEST(figure_rows_share_one_y_gutter) {
|
||||||
|
plot::Figure figure = different_magnitude_figure();
|
||||||
|
const plot::FigureLayout layout = figure.layout();
|
||||||
|
const plot::Rect first = figure.plot(0u).layout().axes;
|
||||||
|
for (plot::u32 row = 0; row < 3u; ++row) {
|
||||||
|
const plot::Rect& axes = figure.plot(row).layout().axes;
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(axes.x), static_cast<plot::f64>(first.x), 1e-4);
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(axes.w), static_cast<plot::f64>(first.w), 1e-4);
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(axes.right()), static_cast<plot::f64>(first.right()),
|
||||||
|
1e-4);
|
||||||
|
// The rows are still their own plots vertically: the equality is the *left gutter*
|
||||||
|
// and not three rows accidentally given one rect.
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(axes.y), static_cast<plot::f64>(layout.rows[row].y),
|
||||||
|
1e-4);
|
||||||
|
}
|
||||||
|
// The rows really do disagree about the strip they need, which is what makes the
|
||||||
|
// gutter a decision the figure has to make: each row's own labels, measured with no
|
||||||
|
// floor in the row's own viewport, ask for a different width -- and every row's
|
||||||
|
// applied width is at least what it asked for, so the figure widened the narrow ones
|
||||||
|
// rather than inventing a width they did not need.
|
||||||
|
plot::f32 natural[3] = {};
|
||||||
|
for (plot::u32 row = 0; row < 3u; ++row) {
|
||||||
|
natural[row] = natural_gutter_width(row, layout.rows[row]);
|
||||||
|
const plot::PlotLayout& rl = figure.plot(row).layout();
|
||||||
|
PLOT_CHECK(rl.y_axis_width + rl.y_title_width >= natural[row] - 1e-4f);
|
||||||
|
}
|
||||||
|
PLOT_CHECK(!(natural[0] == natural[1] && natural[1] == natural[2]));
|
||||||
|
// The shared x axis is still the *bottom row's own* label area -- aligning the rows'
|
||||||
|
// left chrome must not move where a press on the shared axis lands.
|
||||||
|
const plot::PlotLayout& bottom = figure.plot(2u).layout();
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout.x_axis_strip.x),
|
||||||
|
static_cast<plot::f64>(bottom.x_label_area.x), 1e-4);
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout.x_axis_strip.w),
|
||||||
|
static_cast<plot::f64>(bottom.x_label_area.w), 1e-4);
|
||||||
|
PLOT_CHECK(layout.x_axis_strip.h > 0.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spec §16, the drawn consequence: with one gutter, a given data x maps to **one**
|
||||||
|
/// pixel column of the figure, because every row's x transform is built from the same
|
||||||
|
/// axes rect. That is the property a vertical feature -- a cursor, a stacked sample --
|
||||||
|
/// relies on, and it is what "the y axis are not vertically aligned" complained about.
|
||||||
|
PLOT_TEST(figure_rows_map_a_data_x_to_one_column) {
|
||||||
|
plot::Figure figure = different_magnitude_figure();
|
||||||
|
figure.layout();
|
||||||
|
const plot::f32 column = static_cast<plot::f32>(figure.plot(0u).x_transform(0u).to_screen_x(37.5));
|
||||||
|
for (plot::u32 row = 0; row < 3u; ++row) {
|
||||||
|
const plot::Plot& p = figure.plot(row);
|
||||||
|
const plot::f32 x = static_cast<plot::f32>(p.x_transform(0u).to_screen_x(37.5));
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(x), static_cast<plot::f64>(column), 1e-3);
|
||||||
|
// ... and the leftmost data column lands on the axes' left edge in every row, so
|
||||||
|
// the alignment is the full width and not a common offset into three different ones.
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(p.x_transform(0u).to_screen_x(0.0)),
|
||||||
|
static_cast<plot::f64>(p.layout().axes.x), 1e-3);
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(p.x_transform(0u).to_screen_x(100.0)),
|
||||||
|
static_cast<plot::f64>(p.layout().axes.right()), 1e-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The neighbour: a `Plot` standing alone is not a figure and keeps sizing its strip
|
||||||
|
/// from its own data. The floor is the figure's imposition, unset by default, and the
|
||||||
|
/// only thing that widens a lone plot's *gutter* is an explicit one.
|
||||||
|
PLOT_TEST(a_plot_on_its_own_keeps_its_own_y_gutter) {
|
||||||
|
plot::Plot p;
|
||||||
|
give_magnitude_row(p, 1u); // the "90" row: the narrowest labels
|
||||||
|
p.set_viewport(whole_figure());
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(p.min_y_gutter()), 0.0, 1e-12);
|
||||||
|
const plot::PlotLayout& natural_layout = p.layout();
|
||||||
|
const plot::f32 natural = natural_layout.y_axis_width + natural_layout.y_title_width;
|
||||||
|
PLOT_CHECK(natural > 0.0f);
|
||||||
|
|
||||||
|
// An explicit floor widens the gutter, which is the same lever `Figure` pulls -- and
|
||||||
|
// it is a floor on the *gutter*, so the label strip and the title column together
|
||||||
|
// reach it rather than the label strip alone.
|
||||||
|
p.set_min_y_gutter(natural + 20.0f);
|
||||||
|
const plot::PlotLayout& wide = p.layout();
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(wide.y_axis_width + wide.y_title_width),
|
||||||
|
static_cast<plot::f64>(natural + 20.0f), 1e-3);
|
||||||
|
// Negative values mean "no floor", like `set_row_gap()`, and the gutter goes back to
|
||||||
|
// what the labels and titles ask for.
|
||||||
|
p.set_min_y_gutter(-5.0f);
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(p.min_y_gutter()), 0.0, 1e-12);
|
||||||
|
const plot::PlotLayout& back = p.layout();
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(back.y_axis_width + back.y_title_width),
|
||||||
|
static_cast<plot::f64>(natural), 1e-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The figure's gutter is clamped by the same cap as the measured strip, so a figure
|
||||||
|
/// whose rows are much wider than its viewport still cannot let the gutter squeeze the
|
||||||
|
/// axes below the half-viewport floor `Plot::layout()` guarantees.
|
||||||
|
PLOT_TEST(figure_gutter_never_squeezes_the_axes_below_the_floor) {
|
||||||
|
plot::Figure figure = different_magnitude_figure();
|
||||||
|
const plot::f32 w = 40.0f;
|
||||||
|
figure.set_viewport({0.0f, 0.0f, w, 60.0f});
|
||||||
|
figure.layout();
|
||||||
|
const plot::Rect first = figure.plot(0u).layout().axes;
|
||||||
|
for (plot::u32 row = 0; row < 3u; ++row) {
|
||||||
|
const plot::Rect& axes = figure.plot(row).layout().axes;
|
||||||
|
PLOT_CHECK(axes.w >= 0.5f * w - 1e-3f);
|
||||||
|
PLOT_CHECK(axes.w > 0.0f);
|
||||||
|
// ... and the rows still agree, which is the point: the cap is the figure's width,
|
||||||
|
// and every row shares it.
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(axes.x), static_cast<plot::f64>(first.x), 1e-4);
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(axes.w), static_cast<plot::f64>(first.w), 1e-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The degenerate neighbour: a figure of one row imposes exactly what that row
|
||||||
|
/// measured, so it lays out as the plot would on its own -- the gutter cannot move a
|
||||||
|
/// row that has no sibling to line up with.
|
||||||
|
PLOT_TEST(a_one_row_figure_lays_out_as_the_plot_alone_does) {
|
||||||
|
plot::Figure figure = different_magnitude_figure(1u);
|
||||||
|
const plot::FigureLayout layout = figure.layout();
|
||||||
|
plot::Plot solo;
|
||||||
|
give_magnitude_row(solo, 0u);
|
||||||
|
solo.set_viewport(layout.rows[0]);
|
||||||
|
solo.layout();
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(figure.plot(0u).layout().axes.x),
|
||||||
|
static_cast<plot::f64>(solo.layout().axes.x), 1e-4);
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(figure.plot(0u).layout().axes.w),
|
||||||
|
static_cast<plot::f64>(solo.layout().axes.w), 1e-4);
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(figure.plot(0u).layout().y_axis_width),
|
||||||
|
static_cast<plot::f64>(solo.layout().y_axis_width), 1e-4);
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------- the x link
|
// --------------------------------------------------------------- the x link
|
||||||
|
|
||||||
/// Linking a row to a group that already has a range puts it on that range at once,
|
/// Linking a row to a group that already has a range puts it on that range at once,
|
||||||
|
|||||||
@@ -3677,6 +3677,113 @@ PLOT_TEST(shared_x_axis_is_drawn_once) {
|
|||||||
ctx.destroy();
|
ctx.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spec §16, end to end: a figure whose rows carry different magnitudes ("671m",
|
||||||
|
/// "90", "2.51" scale tick labels) draws a vertical feature -- each row's own cursor
|
||||||
|
/// at the same data x -- in the **same pixel column** in every row. That is what a
|
||||||
|
/// user sees when the y axes line up, and it is what the rows' `axes` rects being
|
||||||
|
/// equal is for: the equality below is read from the drawn frame, not from the layout
|
||||||
|
/// alone, so a renderer that derived a row's geometry of its own would fail here.
|
||||||
|
PLOT_TEST(figure_rows_line_up_a_vertical_cursor) {
|
||||||
|
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::f32> samples = wave(101u, 0.0f);
|
||||||
|
plot::data::ArraySeries series(samples, 0.0, 1.0);
|
||||||
|
|
||||||
|
// The cursor's colour, and the only strongly red thing in these frames: the trace is
|
||||||
|
// `kRowBlue` and every piece of chrome is grey (all three channels equal), so a
|
||||||
|
// red-dominant pixel inside a row's `axes` can only be that row's cursor. The row
|
||||||
|
// ranges are the three magnitudes whose tick labels are four, two and three
|
||||||
|
// characters wide -- the shape that gave each row its own strip before the gutter.
|
||||||
|
const plot::Color cursor_red = plot::Color::hex(0xFF2020);
|
||||||
|
const plot::f32 y_scales[3] = {671.0e6f, 90.0f, 2.51f};
|
||||||
|
|
||||||
|
plot::Figure figure(3u);
|
||||||
|
figure.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||||
|
for (plot::u32 row = 0; row < 3u; ++row) {
|
||||||
|
plot::Plot& p = figure.plot(row);
|
||||||
|
const plot::u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
|
||||||
|
const plot::u32 ay =
|
||||||
|
p.add_y_axis({"v", {0.0, y_scales[row]}, false, plot::AxisPlace::Left, false});
|
||||||
|
p.add_trace({&series, kRowBlue, 1.5f, ax, ay});
|
||||||
|
p.add_cursor(50.0, cursor_red);
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
dump_if_requested(r, "figure_gutter_cursor.ppm");
|
||||||
|
|
||||||
|
const std::vector<plot::u8> frame = read_frame(ctx);
|
||||||
|
const plot::FigureLayout layout = figure.layout();
|
||||||
|
PLOT_CHECK(layout.rows.size() == 3u);
|
||||||
|
// The mechanism, straight from the drawn frame's own layouts: every row's axes starts
|
||||||
|
// and ends on the same pixel columns, and that is true at the top of the figure too --
|
||||||
|
// these rows are separate plots vertically and one column horizontally.
|
||||||
|
const plot::Rect first_axes = figure.plot(0u).layout().axes;
|
||||||
|
for (plot::u32 row = 1; row < 3u; ++row) {
|
||||||
|
const plot::Rect& axes = figure.plot(row).layout().axes;
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(axes.x), static_cast<plot::f64>(first_axes.x), 1e-4);
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(axes.w), static_cast<plot::f64>(first_axes.w), 1e-4);
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(axes.right()),
|
||||||
|
static_cast<plot::f64>(first_axes.right()), 1e-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every column of `area` that holds a *fully covered* cursor pixel, left to right.
|
||||||
|
// The predicate demands the cursor red at full coverage (0xFF2020), which is what
|
||||||
|
// makes the comparison across rows meaningful: a partially covered column along the
|
||||||
|
// stroke's edge blends with whatever the row has under it -- its trace at a different
|
||||||
|
// y -- so its colour is a property of that row's data, not of the geometry this case
|
||||||
|
// is about. The core of the stroke has no such dependence.
|
||||||
|
const auto cursor_columns = [&](const plot::Rect& area) {
|
||||||
|
std::vector<plot::u32> columns;
|
||||||
|
for (plot::u32 x = static_cast<plot::u32>(area.x);
|
||||||
|
x < static_cast<plot::u32>(area.right()); ++x) {
|
||||||
|
for (plot::u32 y = static_cast<plot::u32>(area.y);
|
||||||
|
y < static_cast<plot::u32>(area.bottom()); ++y) {
|
||||||
|
const plot::u8* px = &frame[(plot::usize(y) * ctx.width + x) * 4u];
|
||||||
|
if (px[0] >= 200 && px[1] <= 80 && px[2] <= 80) {
|
||||||
|
columns.push_back(x);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return columns;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::vector<plot::u32> first_columns;
|
||||||
|
for (plot::u32 row = 0; row < 3u; ++row) {
|
||||||
|
const plot::PlotLayout& row_layout =
|
||||||
|
static_cast<const plot::Plot&>(figure.plot(row)).layout();
|
||||||
|
const std::vector<plot::u32> columns = cursor_columns(row_layout.axes);
|
||||||
|
PLOT_CHECK(!columns.empty()); // the row's cursor really is in the frame
|
||||||
|
// One connected run: the stroke is one vertical line, not a doubled or smeared one.
|
||||||
|
PLOT_CHECK(columns.back() - columns.front() + 1u == columns.size());
|
||||||
|
if (row == 0) {
|
||||||
|
first_columns = columns;
|
||||||
|
} else {
|
||||||
|
// The same pixel column in this row as in the first -- the drawn consequence of
|
||||||
|
// the shared gutter, which a frame that sized each row's strip from its own
|
||||||
|
// labels cannot satisfy.
|
||||||
|
PLOT_CHECK(columns == first_columns);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ... and that column is the one the shared transform predicts, so the equality is
|
||||||
|
// the geometry's and not three cursors that happened to land together. The tolerance
|
||||||
|
// is the stroke's own pixel: the transform gives the stroke's centre, which lands
|
||||||
|
// inside the band it inked.
|
||||||
|
const plot::u32 expected = static_cast<plot::u32>(
|
||||||
|
static_cast<const plot::Plot&>(figure.plot(0u)).x_transform(0u).to_screen_x(50.0));
|
||||||
|
PLOT_CHECK(first_columns.front() <= expected + 1u && expected <= first_columns.back() + 1u);
|
||||||
|
|
||||||
|
ctx.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
/// A resize recomputes the layout: the rows are re-derived from the new viewport --
|
/// A resize recomputes the layout: the rows are re-derived from the new viewport --
|
||||||
/// their rects, the shared strip, the axes and the transforms they pin -- rather than
|
/// their rects, the shared strip, the axes and the transforms they pin -- rather than
|
||||||
/// rescaled from, or left at, the rects of the frame before. The second frame here is
|
/// rescaled from, or left at, the rects of the frame before. The second frame here is
|
||||||
|
|||||||
Reference in new issue
Block a user