feat(plot): vertical y-axis label via a rotated text run

This commit is contained in:
Martino Ferrari committed 2026-09-28 11:14:36 +02:00
1 parent 1cb5970757
commit daeb31b1ea
6 files changed
+453 -80

No files matched your search

+13 -7
View File
@@ -27,7 +27,12 @@ struct PlotLayout {
Rect axes; Rect axes;
Rect y_label_area; Rect y_label_area;
Rect x_label_area; Rect x_label_area;
/// One text line each, just outside the matching label area. Zero-sized (and /// The y title's strip is a *column*: one text line wide, the axes' height
/// tall, just outside `y_label_area`. The y title is drawn rotated to read
/// bottom-to-top, so what it needs is a line's height rather than the run's own
/// width -- a horizontal run's width is what this strip had to reserve before,
/// and a long title then ate the plot. The x title stays horizontal and gets a
/// row of the same height below `x_label_area`. Both are zero-sized (and
/// unoccupied) when no axis of that direction has a title. /// unoccupied) when no axis of that direction has a title.
Rect y_title_area; Rect y_title_area;
Rect x_title_area; Rect x_title_area;
@@ -39,10 +44,10 @@ struct PlotLayout {
/// is reserved. Every titled axis of a direction gets its own text line inside /// is reserved. Every titled axis of a direction gets its own text line inside
/// its strip -- which is what keeps a Left/Right (or Bottom/Top) pair from /// its strip -- which is what keeps a Left/Right (or Bottom/Top) pair from
/// drawing two titles over each other -- so `x_title_height` is one line per /// drawing two titles over each other -- so `x_title_height` is one line per
/// titled x axis; `y_title_width` is the widest y title plus the padding, since /// titled x axis and `y_title_width` is one line per titled y axis too: a
/// y titles are horizontal runs that stack down the axes-tall strip and need /// rotated title occupies length along the axes' height, so a second titled y
/// width, not another row of it. With no text backend there is no title to /// axis takes a *column* beside the first rather than another row of width. With
/// measure and both stay 0.0f. /// no text backend there is no title to lay out at all and both stay 0.0f.
f32 y_title_width = 0.0f; f32 y_title_width = 0.0f;
f32 x_title_height = 0.0f; f32 x_title_height = 0.0f;
}; };
@@ -254,8 +259,9 @@ class Plot {
/// Widest y tick label plus the label padding on both sides: the left strip the /// Widest y tick label plus the label padding on both sides: the left strip the
/// text backend asks for. Zero with no backend or no y axis. /// text backend asks for. Zero with no backend or no y axis.
f32 y_label_width() const; f32 y_label_width() const;
/// Widest y axis title plus the label padding on both sides: the vertical strip /// One text line of width per titled y axis, plus the label padding on both
/// a horizontal y title fits in. Zero with no backend or no titled y axis. /// sides: the column the rotated y title is drawn in, one column per titled
/// axis. Zero with no backend or no titled y axis.
f32 y_title_strip_width() const; f32 y_title_strip_width() const;
Rect viewport_{}; Rect viewport_{};
+16 -9
View File
@@ -261,15 +261,22 @@ class Renderer {
/// Lays one run of text out at a framebuffer position and stages it, shifting /// Lays one run of text out at a framebuffer position and stages it, shifting
/// it into viewport space. Returns the pen past the run. /// it into viewport space. Returns the pen past the run.
Vec2 stage_text(Vec2 pos_px, const char* utf8); Vec2 stage_text(Vec2 pos_px, const char* utf8);
/// Width of the longest prefix of `utf8` that fits `budget_px`, at the size the /// Width of the longest prefix of `utf8` that fits `budget_px` along `axis`, at
/// text pipeline draws with: exactly what `stage_text_clipped()` stages. /// the size the text pipeline draws with: exactly what `stage_text_clipped()`
f32 clipped_text_width(const char* utf8, f32 budget_px) const; /// stages.
/// Stages as much of `utf8` as fits `budget_px` at `pos_px` and returns the f32 clipped_text_width(const char* utf8, f32 budget_px, Vec2 axis) const;
/// width it staged. Titles are the runs that need this: a title longer than the /// Stages as much of `utf8` as fits `budget_px` along `axis` at `pos_px` and
/// strip the layout reserved for it is cut there, rather than being allowed to /// returns the length it staged. Titles are the runs that need this: a title
/// grow the strip (which would shrink the plot) or to spill over the tick labels /// longer than the strip the layout reserved for it is cut there, rather than
/// and the data (which is the defect the strips exist to prevent). /// being allowed to grow the strip (which would shrink the plot) or to spill
f32 stage_text_clipped(Vec2 pos_px, const char* utf8, f32 budget_px); /// over the tick labels and the data (which is the defect the strips exist to
/// prevent).
///
/// `axis` is the run's own direction and a unit vector: (1, 0) for the
/// horizontal x title, which spends the strip's width, and (0,-1) for the y
/// title, which reads bottom-to-top and spends the strip's height. `pos_px` is
/// the pen -- for the rotated run, the bottom-left corner of its first cell.
f32 stage_text_clipped(Vec2 pos_px, const char* utf8, f32 budget_px, Vec2 axis);
void draw_grid(const Plot& plot); void draw_grid(const Plot& plot);
void draw_axis_lines(const Plot& plot); void draw_axis_lines(const Plot& plot);
+12 -9
View File
@@ -336,9 +336,10 @@ const PlotLayout& Plot::layout() {
// tick label can change that. Nothing at all is reserved without a text backend // tick label can change that. Nothing at all is reserved without a text backend
// -- there is then no title to measure, which is what keeps the two defaults // -- there is then no title to measure, which is what keeps the two defaults
// above meaningful for the no-text-backend case. The x strip is one text line // above meaningful for the no-text-backend case. The x strip is one text line
// per titled x axis tall (they are stacked in it); the y strip is as wide as the // per titled x axis tall (they are stacked in it); the y strip is one text line
// widest y title, since a y title is a horizontal run that stacks down the strip // per titled y axis *wide*, because the y title is drawn rotated and so spends
// and so needs width rather than another row of it (see y_title_strip_width). // its length along the axes' height and takes a column, not a row, per titled
// axis (see y_title_strip_width).
const u32 x_titles = titled_x_axis_count(); const u32 x_titles = titled_x_axis_count();
const f32 y_title_w = text_ != nullptr ? y_title_strip_width() : 0.0f; const f32 y_title_w = text_ != nullptr ? y_title_strip_width() : 0.0f;
const f32 x_title_h = text_ != nullptr && x_titles != 0u const f32 x_title_h = text_ != nullptr && x_titles != 0u
@@ -437,13 +438,15 @@ u32 Plot::titled_y_axis_count() const noexcept {
} }
f32 Plot::y_title_strip_width() const { f32 Plot::y_title_strip_width() const {
// One text line per titled y axis, not the widest title's own width: the title
// is drawn rotated to read bottom-to-top, so it spends the axes' height and
// each titled axis needs a column of its own -- which is what keeps a Left/Right
// pair of titles from being drawn over each other now that they no longer stack
// down a taller strip.
if (text_ == nullptr) return 0.0f; if (text_ == nullptr) return 0.0f;
f32 widest = 0.0f; const u32 titles = titled_y_axis_count();
for (const Axis& a : y_axes_) { if (titles == 0u) return 0.0f;
if (a.label.empty()) continue; return static_cast<f32>(titles) * text_->height() + 2.0f * style_.grid_label_pad;
widest = std::max(widest, text_->measure(a.label.c_str()));
}
return widest > 0.0f ? widest + 2.0f * style_.grid_label_pad : 0.0f;
} }
f32 Plot::y_label_width() const { f32 Plot::y_label_width() const {
+62 -37
View File
@@ -51,19 +51,35 @@ u64 clamp_index(f64 v, u64 n) noexcept {
constexpr usize kNumberChars = 24; constexpr usize kNumberChars = 24;
constexpr usize kReadoutChars = 160; constexpr usize kReadoutChars = 160;
/// Walks the longest prefix of `utf8` that fits `budget_px`, calling `emit(pen, /// The run bases the axis titles are drawn with. A title reads left to right in
/// glyph)` for every glyph it keeps -- `pen` in viewport pixels, `glyph` the /// the x strip (`kTextRight`), and bottom-to-top in the y strip (`kTextUp`: a
/// backend's own entry for that codepoint -- and returning the width that prefix /// quarter turn anticlockwise on screen, where y grows downward). Both are unit
/// occupies. Both the measuring and the staging of a clipped run walk this, so the /// vectors, which is what `walk_text_clipped()` spends its budget along and what
/// cut lands on the same pixel either way; the codepoints, the advances and the /// `TextBatch` builds the glyph cell from (see `TextInstance`).
/// scale are the ones `TextBatch::add_text()` uses, so a clipped run still ends constexpr Vec2 kTextRight{1.0f, 0.0f};
/// where the plot layer measured its title to end. constexpr Vec2 kTextUp{0.0f, -1.0f};
/// Walks the longest prefix of `utf8` that fits `budget_px` along `axis`, calling
/// `emit(pen, glyph)` for every glyph it keeps -- `pen` in viewport pixels,
/// `glyph` the backend's own entry for that codepoint -- and returning the length
/// of that prefix in pixels. Both the measuring and the staging of a clipped run
/// walk this, so the cut lands on the same pixel either way; the codepoints, the
/// advances and the scale are the ones `TextBatch::add_text()` uses, so a clipped
/// run still ends where the plot layer measured its title to end.
/// ///
/// A codepoint the backend has no glyph for is skipped, exactly as `add_text` /// A codepoint the backend has no glyph for is skipped, exactly as `add_text`
/// skips it, so what is measured here is what is drawn. /// skips it, so what is measured here is what is drawn.
///
/// `axis` is the run's own direction in viewport pixels -- a unit vector, the same
/// basis the cells are staged with (see `TextInstance`): the pen advances along it
/// and the budget is spent along it too, so a run is clipped to the strip's extent
/// *in its own direction* rather than to the strip's width. A horizontal x title
/// asks for (1, 0) and spends the strip's width; the y title asks for (0,-1) and
/// spends the strip's height, which is the only reason a rotated run can be cut at
/// the strip's ends at all.
template <class Emit> template <class Emit>
f32 walk_text_clipped(Vec2 pen, const char* utf8, f32 budget_px, const render::TextBackend& font, f32 walk_text_clipped(Vec2 pen, const char* utf8, f32 budget_px, const render::TextBackend& font,
f32 scale, Emit emit) { f32 scale, Vec2 axis, Emit emit) {
if (utf8 == nullptr || !(budget_px > 0.0f)) { if (utf8 == nullptr || !(budget_px > 0.0f)) {
return 0.0f; // no room at all: a strip clipped away stages nothing return 0.0f; // no room at all: a strip clipped away stages nothing
} }
@@ -74,7 +90,7 @@ f32 walk_text_clipped(Vec2 pen, const char* utf8, f32 budget_px, const render::T
const f32 advance = glyph->advance * scale; const f32 advance = glyph->advance * scale;
if (used + advance > budget_px) break; // the next glyph does not fit: cut here if (used + advance > budget_px) break; // the next glyph does not fit: cut here
emit(pen, *glyph); emit(pen, *glyph);
pen.x += advance; pen = pen + axis * advance;
used += advance; used += advance;
} }
return used; return used;
@@ -468,16 +484,16 @@ Vec2 Renderer::stage_text(Vec2 pos_px, const char* utf8) {
return glyphs_.add_text(utf8, to_viewport(pos_px), font_backend_.scale(), default_font_); return glyphs_.add_text(utf8, to_viewport(pos_px), font_backend_.scale(), default_font_);
} }
f32 Renderer::clipped_text_width(const char* utf8, f32 budget_px) const { f32 Renderer::clipped_text_width(const char* utf8, f32 budget_px, Vec2 axis) const {
return walk_text_clipped(Vec2{}, utf8, budget_px, default_font_, font_backend_.scale(), return walk_text_clipped(Vec2{}, utf8, budget_px, default_font_, font_backend_.scale(), axis,
[](Vec2, const render::Glyph&) {}); [](Vec2, const render::Glyph&) {});
} }
f32 Renderer::stage_text_clipped(Vec2 pos_px, const char* utf8, f32 budget_px) { f32 Renderer::stage_text_clipped(Vec2 pos_px, const char* utf8, f32 budget_px, Vec2 axis) {
const f32 scale = font_backend_.scale(); const f32 scale = font_backend_.scale();
return walk_text_clipped(to_viewport(pos_px), utf8, budget_px, default_font_, scale, return walk_text_clipped(to_viewport(pos_px), utf8, budget_px, default_font_, scale, axis,
[this, scale](Vec2 pen, const render::Glyph& glyph) { [this, scale, axis](Vec2 pen, const render::Glyph& glyph) {
glyphs_.add_glyph(pen, glyph, scale); glyphs_.add_glyph(pen, glyph, scale, axis);
}); });
} }
@@ -563,46 +579,55 @@ void Renderer::draw_tick_labels(const Plot& plot, const PlotLayout& layout) {
void Renderer::draw_titles(const Plot& plot, const PlotLayout& layout) { void Renderer::draw_titles(const Plot& plot, const PlotLayout& layout) {
// Every title goes in the strip its direction reserved for it, and every titled // Every title goes in the strip its direction reserved for it, and every titled
// axis gets its own text line inside that strip: one row per axis is what keeps // axis gets a text line of its own inside that strip -- a row for an x axis, a
// a Left/Right (or Bottom/Top) pair of titles from being drawn over each other, // column for a y one -- which is what keeps a Left/Right (or Bottom/Top) pair of
// where the pass before this one put them all on one centred run. Each run is // titles from being drawn over each other, where the pass before that put them
// also cut to its strip, so a title longer than the strip -- which the layout // all on one centred run. Each run is also cut to its strip, so a title longer
// caps, because a title may shrink the plot but never erase it -- stops at the // than the strip -- which the layout caps, because a title may shrink the plot
// strip's padding instead of spilling over the labels or the data. // but never erase it -- stops at the strip's padding instead of spilling over the
// labels or the data.
const f32 pad = style_.grid_label_pad; const f32 pad = style_.grid_label_pad;
const f32 line_h = font_backend_.height(); const f32 line_h = font_backend_.height();
// X titles: the strip's rows top down, one per titled axis. // X titles: the strip's rows top down, one per titled axis, each read left to
// right on the row it owns.
for (u32 i = 0, row = 0; i < plot.x_axis_count(); ++i) { for (u32 i = 0, row = 0; i < plot.x_axis_count(); ++i) {
const Axis& axis = plot.x_axis(i); const Axis& axis = plot.x_axis(i);
if (axis.label.empty()) continue; if (axis.label.empty()) continue;
const Rect& strip = layout.x_title_area; const Rect& strip = layout.x_title_area;
const f32 budget = strip.w - 2.0f * pad; const f32 budget = strip.w - 2.0f * pad;
const f32 width = clipped_text_width(axis.label.c_str(), budget); const f32 width = clipped_text_width(axis.label.c_str(), budget, kTextRight);
// Centred on the strip: the tick labels have their own rows above it, so the // Centred on the strip: the tick labels have their own rows above it, so the
// title needs no alignment of its own. // title needs no alignment of its own.
stage_text_clipped({strip.x + 0.5f * (strip.w - width), stage_text_clipped({strip.x + 0.5f * (strip.w - width),
strip.y + pad + static_cast<f32>(row) * line_h}, strip.y + pad + static_cast<f32>(row) * line_h},
axis.label.c_str(), budget); axis.label.c_str(), budget, kTextRight);
++row; ++row;
} }
// Y titles: the same, stacked down the axes-tall strip and centred on it as a // Y titles: the same strips, cut the same way, but each run is rotated a quarter
// group, so the single titled axis an ordinary plot has keeps the row it always // turn so it reads bottom-to-top -- axis (0,-1), the direction the run advances
// had. A y title reads horizontally like the x ones, so its width is what the // in -- which is what lets the strip be one line *wide* instead of as wide as the
// strip had to reserve; the extra lines cost height, which the axes already // title: a rotation, not a reflection, so the text is not mirrored (the shader
// provides. // builds the cell's other axis as the basis' perpendicular).
const f32 stack = static_cast<f32>(plot.titled_y_axis_count()) * line_h; //
for (u32 i = 0, row = 0; i < plot.y_axis_count(); ++i) { // Each titled axis gets its own column, the mirror of the x rows above, so a
// Left/Right pair of titled y axes is drawn side by side rather than over each
// other. The pen starts at the column's bottom-left: with this basis the first
// cell occupies the pen's own corner and grows up and to the right, so the run
// rises out of the strip's bottom edge and is centred along the height -- and cut
// to it, which is the budget this direction needs. A title longer than the axes
// is therefore clipped at the strip's ends exactly like an over-long x title.
for (u32 i = 0, column = 0; i < plot.y_axis_count(); ++i) {
const Axis& axis = plot.y_axis(i); const Axis& axis = plot.y_axis(i);
if (axis.label.empty()) continue; if (axis.label.empty()) continue;
const Rect& strip = layout.y_title_area; const Rect& strip = layout.y_title_area;
const f32 budget = strip.w - 2.0f * pad; const f32 budget = strip.h - 2.0f * pad;
const f32 width = clipped_text_width(axis.label.c_str(), budget); const f32 length = clipped_text_width(axis.label.c_str(), budget, kTextUp);
const f32 top = strip.y + std::max(0.0f, 0.5f * (strip.h - stack)) + stage_text_clipped({strip.x + pad + static_cast<f32>(column) * line_h,
static_cast<f32>(row) * line_h; strip.bottom() - 0.5f * (strip.h - length)},
stage_text_clipped({strip.x + 0.5f * (strip.w - width), top}, axis.label.c_str(), budget); axis.label.c_str(), budget, kTextUp);
++row; ++column;
} }
} }
+71 -10
View File
@@ -82,10 +82,12 @@ PLOT_TEST(layout_derives_label_areas_from_font_metrics) {
const f32 pad = p.style().grid_label_pad; const f32 pad = p.style().grid_label_pad;
PLOT_CHECK_NEAR(l.y_axis_width, widest_major_label(p.y_axis(ay), font) + 2.0f * pad, 1e-4); PLOT_CHECK_NEAR(l.y_axis_width, widest_major_label(p.y_axis(ay), font) + 2.0f * pad, 1e-4);
PLOT_CHECK_NEAR(l.x_axis_height, font.height() + 2.0f * pad, 1e-4); PLOT_CHECK_NEAR(l.x_axis_height, font.height() + 2.0f * pad, 1e-4);
// The y axis carries the title "volts": it is drawn horizontally, so the strip // The y axis carries the title "volts", drawn rotated to read bottom-to-top, so
// left of the label strip is as wide as the measured title. There is no x axis, // the strip left of the label strip is one text line *wide* -- the column the
// so the x direction reserves nothing beyond its label strip. // run advances up -- and not as wide as the title is when horizontal. There is
PLOT_CHECK_NEAR(l.y_title_width, font.measure("volts") + 2.0f * pad, 1e-4); // no x axis, so the x direction reserves nothing beyond its label strip.
PLOT_CHECK_NEAR(l.y_title_width, font.height() + 2.0f * pad, 1e-4);
PLOT_CHECK(l.y_title_width < font.measure("volts") + 2.0f * pad);
PLOT_CHECK_NEAR(l.x_title_height, 0.0f, 1e-6); PLOT_CHECK_NEAR(l.x_title_height, 0.0f, 1e-6);
PLOT_CHECK_NEAR(l.axes.x, l.y_title_width + l.y_axis_width, 1e-4); PLOT_CHECK_NEAR(l.axes.x, l.y_title_width + l.y_axis_width, 1e-4);
PLOT_CHECK_NEAR(l.axes.w, 800.0f - (l.y_title_width + l.y_axis_width), 1e-4); PLOT_CHECK_NEAR(l.axes.w, 800.0f - (l.y_title_width + l.y_axis_width), 1e-4);
@@ -100,8 +102,9 @@ PLOT_TEST(layout_derives_label_areas_from_font_metrics) {
PLOT_CHECK_NEAR(l.x_label_area.y, l.axes.bottom(), 1e-4); PLOT_CHECK_NEAR(l.x_label_area.y, l.axes.bottom(), 1e-4);
} }
/// Every titled axis of a direction gets its own text line inside its strip; /// Every titled axis of a direction gets its own text line inside its strip; without
/// without that, the second titled axis of an edge is drawn on top of the first. /// that, the second titled axis of an edge is drawn on top of the first. An x line is
/// a row of the strip, a y one a column of it, because the y title is drawn rotated.
PLOT_TEST(layout_stacks_one_title_line_per_titled_axis) { PLOT_TEST(layout_stacks_one_title_line_per_titled_axis) {
plot::render::EmbeddedMonoFont font; plot::render::EmbeddedMonoFont font;
plot::Plot p; plot::Plot p;
@@ -117,10 +120,13 @@ PLOT_TEST(layout_stacks_one_title_line_per_titled_axis) {
PLOT_CHECK(p.titled_y_axis_count() == 2u); PLOT_CHECK(p.titled_y_axis_count() == 2u);
// The x titles stack in rows, so the strip grows by a line per titled axis... // The x titles stack in rows, so the strip grows by a line per titled axis...
PLOT_CHECK_NEAR(l.x_title_height, 2.0f * font.height() + 2.0f * pad, 1e-4); PLOT_CHECK_NEAR(l.x_title_height, 2.0f * font.height() + 2.0f * pad, 1e-4);
// ... while the y titles stack down the axes-tall strip, so the second one costs // ... and the y titles, rotated, take a *column* each: the second titled y axis
// height the axes already has rather than a second column of width. // costs another text line of width rather than a row of the strip's height, and
PLOT_CHECK_NEAR(l.y_title_width, // the pair still cannot be drawn over each other. The widest title's own width
std::max(font.measure("volts"), font.measure("amps")) + 2.0f * pad, 1e-4); // plays no part in it.
PLOT_CHECK_NEAR(l.y_title_width, 2.0f * font.height() + 2.0f * pad, 1e-4);
PLOT_CHECK(l.y_title_width <
std::max(font.measure("volts"), font.measure("amps")) + 2.0f * pad);
PLOT_CHECK_NEAR(l.axes.h, 400.0f - (l.x_axis_height + l.x_title_height), 1e-4); PLOT_CHECK_NEAR(l.axes.h, 400.0f - (l.x_axis_height + l.x_title_height), 1e-4);
} }
@@ -143,6 +149,61 @@ PLOT_TEST(layout_reserves_no_title_strip_without_a_title) {
PLOT_CHECK_NEAR(l.axes.h, 400.0f - l.x_axis_height, 1e-4); PLOT_CHECK_NEAR(l.axes.h, 400.0f - l.x_axis_height, 1e-4);
} }
/// The y title is rotated, so its strip is a column: one text line wide where a
/// horizontal run needed the title's own width, and as tall as the axes, which is
/// where the run spends its length. The title here is wide enough to make the two
/// models plainly different -- it measures more than ten of the lines it now costs.
PLOT_TEST(y_title_strip_is_narrow_when_a_y_title_is_set) {
plot::render::EmbeddedMonoFont font;
plot::Plot p;
p.set_text_backend(&font);
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
p.add_x_axis({"seconds", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"millivolts per division", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
const plot::PlotLayout& l = p.layout();
const plot::f32 pad = p.style().grid_label_pad;
// One text line wide -- a column, not the run's own width...
PLOT_CHECK_NEAR(l.y_title_area.w, l.y_title_width, 1e-6);
PLOT_CHECK_NEAR(l.y_title_width, font.height() + 2.0f * pad, 1e-4);
PLOT_CHECK(l.y_title_width < font.measure("millivolts per division") + 2.0f * pad);
// ... spanning the axes' whole vertical extent, so the run has the room to
// advance in and a title longer than the axes is cut where the axes ends.
PLOT_CHECK_NEAR(l.y_title_area.x, 0.0f, 1e-6);
PLOT_CHECK_NEAR(l.y_title_area.y, l.axes.y, 1e-6);
PLOT_CHECK_NEAR(l.y_title_area.h, l.axes.h, 1e-4);
PLOT_CHECK(l.y_title_area.h > l.y_title_area.w);
// The strips tile the viewport outside `axes`: title, then labels, then data.
PLOT_CHECK_NEAR(l.y_title_area.right(), l.y_label_area.x, 1e-4);
PLOT_CHECK_NEAR(l.y_label_area.right(), l.axes.x, 1e-4);
PLOT_CHECK_NEAR(l.axes.x, l.y_title_width + l.y_axis_width, 1e-4);
PLOT_CHECK_NEAR(l.axes.w, 800.0f - (l.y_title_width + l.y_axis_width), 1e-4);
}
/// A Left and a Right titled y axis both draw in the one strip the layout reserves,
/// so the strip has to hold two runs. They no longer stack down its height -- the
/// runs are vertical, they would overlap from top to bottom -- so each gets a
/// column, and the strip is two text lines wide. What the model must not do is go
/// back to being as wide as the widest title: the columns are lines, not titles.
PLOT_TEST(two_titled_y_axes_reserve_a_column_each) {
plot::render::EmbeddedMonoFont font;
plot::Plot p;
p.set_text_backend(&font);
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
p.add_y_axis({"volts", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
p.add_y_axis({"amps", {-1.0, 1.0}, false, plot::AxisPlace::Right, false});
const plot::PlotLayout& l = p.layout();
const plot::f32 pad = p.style().grid_label_pad;
PLOT_CHECK(p.titled_y_axis_count() == 2u);
PLOT_CHECK_NEAR(l.y_title_width, 2.0f * font.height() + 2.0f * pad, 1e-4);
// Two columns fit side by side inside the strip, and the axes is inset by it once.
PLOT_CHECK(l.y_title_width >= 2.0f * font.height());
PLOT_CHECK_NEAR(l.y_title_area.w, l.y_title_width, 1e-6);
PLOT_CHECK_NEAR(l.y_title_area.right(), l.y_label_area.x, 1e-4);
PLOT_CHECK_NEAR(l.axes.x, l.y_title_width + l.y_axis_width, 1e-4);
}
PLOT_TEST(time_axis_tick_labels_use_format_time) { PLOT_TEST(time_axis_tick_labels_use_format_time) {
plot::Plot p; plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
+279 -8
View File
@@ -336,6 +336,81 @@ TextInk scan_text_ink(plottest::HeadlessContext& ctx, plot::u32 x0, plot::u32 x1
return ink; return ink;
} }
/// The ink `text`-coloured pixels form inside `area`: its bounding box in Rect
/// pixels (top-down, like every Rect here) and how many pixels it covers. Asking a
/// sub-rect is how a case reads one strip without the labels beside it -- but a
/// scan can only see ink *inside* the rect it is given, so "nothing was drawn
/// outside" is a scan of the region the overflow would have landed in.
struct RectInk {
InkBox box;
plot::u32 pixels = 0u;
bool any() const noexcept { return box.any(); }
};
RectInk scan_rect_ink(plottest::HeadlessContext& ctx, const plot::Rect& area, plot::Color text) {
RectInk ink;
const plot::u32 x1 = static_cast<plot::u32>(area.right());
const plot::u32 y1 = static_cast<plot::u32>(area.bottom());
for (plot::u32 y = static_cast<plot::u32>(area.y); y < y1; ++y) {
for (plot::u32 x = static_cast<plot::u32>(area.x); x < x1; ++x) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, px));
if (!text_ink(px, text)) continue;
// The extremes, not the first and last match: a row-major scan sees a
// rotated run's widest row wherever it happens to be, so the first match's
// column is the box's left edge only for a run that is monotone in x -- which
// an unrotated line of text is, and a quarter-turned one is not. The first
// match also has to seed all four, since the empty box's sentinel is not a
// coordinate.
if (!ink.box.any()) {
ink.box.x0 = ink.box.x1 = x;
ink.box.y0 = ink.box.y1 = y;
} else {
ink.box.x0 = std::min(ink.box.x0, x);
ink.box.x1 = std::max(ink.box.x1, x);
ink.box.y0 = std::min(ink.box.y0, y);
ink.box.y1 = std::max(ink.box.y1, y);
}
++ink.pixels;
}
}
return ink;
}
/// One band of inked columns: `[first, last]`, both inclusive, in framebuffer x.
struct InkBand {
plot::u32 first = kNoColumn;
plot::u32 last = 0;
plot::u32 width() const noexcept { return first == kNoColumn ? 0u : last - first + 1u; }
};
/// The bands of inked columns `text`-coloured ink forms in `area`, left to right:
/// one band per drawn run that a gap of blank columns separates from its
/// neighbours. Two runs drawn over each other make one band; two separated by
/// background make two, so this is how a case asks how many runs a strip holds and
/// where each of them is.
std::vector<InkBand> ink_column_bands(plottest::HeadlessContext& ctx, const plot::Rect& area,
plot::Color text) {
std::vector<InkBand> bands;
for (plot::u32 x = static_cast<plot::u32>(area.x);
x < static_cast<plot::u32>(area.right()); ++x) {
bool has = false;
for (plot::u32 y = static_cast<plot::u32>(area.y);
y < static_cast<plot::u32>(area.bottom()) && !has; ++y) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, px));
has = text_ink(px, text);
}
if (!has) continue;
if (bands.empty() || bands.back().last + 1u != x) {
bands.push_back(InkBand{x, x});
} else {
bands.back().last = x;
}
}
return bands;
}
/// Holds one GL entry point at a stub for as long as it lives. The entry points /// Holds one GL entry point at a stub for as long as it lives. The entry points
/// are the library's own function pointers (`plot/gl/api.hpp`), so a driver /// are the library's own function pointers (`plot/gl/api.hpp`), so a driver
/// refusal -- a map or a fence that fails while the caps still advertise support /// refusal -- a map or a fence that fails while the caps still advertise support
@@ -1056,12 +1131,14 @@ PLOT_TEST(short_panel_with_an_x_title_still_draws) {
ctx.destroy(); ctx.destroy();
} }
// Fix pass 2, finding 2: every titled y axis was staged at the same centred // Fix pass 2, finding 2: every titled axis of a direction was staged at the same
// position inside the one strip, so a Left "volts" and a Right "amps" drew over // centred position inside the one strip, so a Bottom/Top (or Left/Right) pair drew
// each other -- the overprint the pre-strip placement did not have. Each titled // over each other -- the overprint the pre-strip placement did not have. Each
// axis now owns a text line, so a titled pair of either direction shows two // titled axis now owns a text line, so a pair shows two separated runs of
// separated runs of title-coloured pixels where it used to show one. // title-coloured pixels where it used to show one: two *rows* for an x pair, which
PLOT_TEST(titled_axis_pairs_get_their_own_rows) { // is what this case reads. A titled y pair's two runs are columns now (the title
// is rotated), so they are read by `two_titled_y_axes_get_two_columns` below.
PLOT_TEST(two_titled_x_axes_get_their_own_rows) {
plottest::HeadlessContext ctx(kW, kH); plottest::HeadlessContext ctx(kW, kH);
plot::Status st; plot::Status st;
PLOT_CHECK(ctx.create(&st)); PLOT_CHECK(ctx.create(&st));
@@ -1086,7 +1163,7 @@ PLOT_TEST(titled_axis_pairs_get_their_own_rows) {
const plot::PlotLayout& layout = p.layout(); const plot::PlotLayout& layout = p.layout();
const plot::Color title = r.style().axis_text; const plot::Color title = r.style().axis_text;
// The rows of one strip that hold title-coloured pixels, as the runs of rows // The rows of the x strip that hold title-coloured pixels, as the runs of rows
// they form: one run per titled axis, because the two runs of a pair are a line // they form: one run per titled axis, because the two runs of a pair are a line
// apart and each covers several pixel rows of glyph ink. // apart and each covers several pixel rows of glyph ink.
auto row_bands = [&](const plot::Rect& strip) { auto row_bands = [&](const plot::Rect& strip) {
@@ -1112,7 +1189,201 @@ PLOT_TEST(titled_axis_pairs_get_their_own_rows) {
PLOT_CHECK(layout.x_title_area.h > 0.0f); PLOT_CHECK(layout.x_title_area.h > 0.0f);
PLOT_CHECK(layout.y_title_area.w > 0.0f); PLOT_CHECK(layout.y_title_area.w > 0.0f);
PLOT_CHECK(row_bands(layout.x_title_area) == 2u); // "seconds" above "samples" PLOT_CHECK(row_bands(layout.x_title_area) == 2u); // "seconds" above "samples"
PLOT_CHECK(row_bands(layout.y_title_area) == 2u); // "volts" above "amps" ctx.destroy();
}
/// Whether any pixel of `area` is exactly `c` -- the assertion `pixel_is` can make
/// and `text_ink` cannot: a third of the title colour is below the axis stroke's
/// grey, so an ink scan of the axes rect would find the axis itself. For a chalk
/// element whose *absence* from a rectangle is the property, the exact colour is
/// the predicate that means something.
bool rect_has_exact(plottest::HeadlessContext& ctx, const plot::Rect& area, plot::Color c) {
for (plot::u32 y = static_cast<plot::u32>(area.y);
y < static_cast<plot::u32>(area.bottom()); ++y) {
for (plot::u32 x = static_cast<plot::u32>(area.x);
x < static_cast<plot::u32>(area.right()); ++x) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, px));
if (pixel_is(px, c)) return true;
}
}
return false;
}
// The change itself. The y title is a rotated run now, so its strip is a column one
// text line wide and as tall as the axes, instead of a strip as wide as the title
// with a horizontal run drawn in it. The layout half of that is asserted in
// test_plot.cpp; this is the pixel half -- the ink really is rotated (taller than
// wide for a title fifteen times wider than tall when horizontal), it stays inside
// the column, and it never reaches the data area.
PLOT_TEST(y_title_ink_is_rotated_and_outside_the_axes) {
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));
plot::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
p.add_x_axis({"seconds", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
const char* const title = "millivolts per division";
p.add_y_axis({title, {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
dump_if_requested(r, "vertical_y_title.ppm");
const plot::PlotLayout& layout = p.layout();
const plot::Color ink = r.style().axis_text;
const plot::f32 pad = r.style().grid_label_pad;
// The strip is a column: one text line wide (the renderer handed the plot its own
// scaled font, so the two agree exactly) and as tall as the axes.
PLOT_CHECK_NEAR(layout.y_title_area.w, r.axis_font_px() + 2.0f * pad, 1e-3);
PLOT_CHECK(layout.y_title_area.h > layout.y_title_area.w);
const RectInk drawn = scan_rect_ink(ctx, layout.y_title_area, ink);
PLOT_CHECK(drawn.any());
// Real glyph ink, not one stray anti-aliased pixel: the run covers hundreds.
PLOT_CHECK(drawn.pixels >= 32u);
// Rotated, not merely moved: the run is taller than it is wide. The same string
// drawn on the unrotated basis is ~24 glyphs wide -- seven times the strip it is
// in -- so this is the assertion that fails if the basis is left at (1,0).
PLOT_CHECK(drawn.box.height() > drawn.box.width());
PLOT_CHECK(drawn.box.width() <= static_cast<plot::u32>(layout.y_title_area.w));
// The sign of the basis too, which is the half a rotation and a reflection share:
// the cell is built from the basis and its perpendicular, and with (0,-1) that
// puts the pen -- the run's start -- at the cell's bottom-left and the cell
// growing up and to the *right* of it. A run that advanced the other way round
// the screen would have its cells to the left of the pen, i.e. left of the
// strip's padding, and the ink would start at the strip's edge or off it.
PLOT_CHECK(drawn.box.x0 >= static_cast<plot::u32>(layout.y_title_area.x + pad));
// ... and none of it is inside the axes: the title is drawn in the strip the
// layout reserved, which lies outside the data area.
PLOT_CHECK(layout.y_title_area.right() <= layout.y_label_area.x + 1e-4f);
PLOT_CHECK(!rect_has_exact(ctx, layout.axes, ink));
ctx.destroy();
}
// Two titled y axes both draw in the one strip the layout reserves for them, and
// now that the runs are vertical they cannot stack down its height -- they would
// overlap from top to bottom. Each takes a column instead, so a Left/Right pair
// shows two runs side by side in distinct x ranges: one band of inked columns per
// axis with background between them, where the horizontal-run model drew the pair
// as two rows a line apart in a strip as wide as the widest title.
PLOT_TEST(two_titled_y_axes_get_two_columns) {
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));
plot::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
p.add_x_axis({"seconds", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"volts", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
p.add_y_axis({"amps", {-1.0, 1.0}, false, plot::AxisPlace::Right, false});
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
dump_if_requested(r, "y_title_columns.ppm");
const plot::PlotLayout& layout = p.layout();
const plot::Color ink = r.style().axis_text;
const plot::f32 pad = r.style().grid_label_pad;
// Room for one column per titled axis, and no more: the strip is two text lines
// wide, not two titles' worth of width.
PLOT_CHECK(p.titled_y_axis_count() == 2u);
PLOT_CHECK_NEAR(layout.y_title_area.w, 2.0f * r.axis_font_px() + 2.0f * pad, 1e-3);
const std::vector<InkBand> columns = ink_column_bands(ctx, layout.y_title_area, ink);
PLOT_CHECK(columns.size() == 2u);
// Distinct x ranges, in the order the axes were added: the Left axis' column is
// left of the Right axis'.
PLOT_CHECK(columns[1].first > columns[0].last);
// Each band is one rotated run -- "volts" in one column, "amps" in the next --
// so each is taller than it is wide on its own.
for (const InkBand& column : columns) {
const RectInk run = scan_rect_ink(
ctx, plot::Rect{static_cast<plot::f32>(column.first), layout.y_title_area.y,
static_cast<plot::f32>(column.width()), layout.y_title_area.h},
ink);
PLOT_CHECK(run.any());
PLOT_CHECK(run.box.height() > run.box.width());
}
ctx.destroy();
}
// A y title longer than its strip is cut where the strip ends, exactly as an
// over-long x title is cut where its row ends. That needs the strip's *height* to
// be the run's budget, which is what threading the axis through the clip buys: a
// budget still taken from the strip's width stops the run after two glyphs, and no
// budget at all lets it leave the strip.
//
// The plot's viewport is inset from the framebuffer so the overspill would land on
// pixels this frame can read: an unclipped run leaves the strip and keeps going
// above the plot, where the framebuffer still has room -- "drawn past the strip" is
// otherwise indistinguishable from "clipped at the strip's end and off the target".
PLOT_TEST(over_long_y_title_is_clipped_to_its_strip) {
constexpr plot::u32 kFbW = 640;
constexpr plot::u32 kFbH = 280;
constexpr plot::f32 kPanelY = 80.0f;
constexpr plot::f32 kPanelH = 160.0f;
plottest::HeadlessContext ctx(kFbW, kFbH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
plot::Plot p;
p.set_viewport({0.0f, kPanelY, static_cast<plot::f32>(kFbW), kPanelH});
p.add_x_axis({"", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"millivolts per division of the input signal", {-1.0, 1.0}, false,
plot::AxisPlace::Left, false});
r.begin_frame(kFbW, kFbH, {0.0f, kPanelY, static_cast<plot::f32>(kFbW), kPanelH});
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
dump_if_requested(r, "y_title_clipped.ppm");
const plot::PlotLayout& layout = p.layout();
const plot::Color ink = r.style().axis_text;
const plot::f32 pad = r.style().grid_label_pad;
const plot::Rect& strip = layout.y_title_area;
// Chrome gave way, not the plot: 43 glyphs of title did not shrink the axes past
// its floor, and the strip is still one column.
PLOT_CHECK(layout.axes.h >= 0.5f * kPanelH);
PLOT_CHECK(layout.axes.w > 0.0f);
PLOT_CHECK_NEAR(strip.w, r.axis_font_px() + 2.0f * pad, 1e-3);
const RectInk drawn = scan_rect_ink(ctx, strip, ink);
PLOT_CHECK(drawn.any());
// The run spent the strip's height: a run cut to the 16 px column would be two
// glyphs tall, and this is the assertion that fails if the axis is threaded but
// the budget is not.
PLOT_CHECK(drawn.box.height() * 4u >= 3u * static_cast<plot::u32>(strip.h));
PLOT_CHECK(drawn.box.height() <= static_cast<plot::u32>(strip.h));
// ... and it stopped inside the strip: nothing in the strip's own columns above
// the plot's viewport or below the axes, which is where an unclipped run would
// have run out of the strip and kept drawing.
PLOT_CHECK(!scan_rect_ink(ctx, plot::Rect{strip.x, 0.0f, strip.w, strip.y}, ink).any());
PLOT_CHECK(!scan_rect_ink(ctx,
plot::Rect{strip.x, strip.bottom(), strip.w,
static_cast<plot::f32>(kFbH) - strip.bottom()},
ink)
.any());
ctx.destroy(); ctx.destroy();
} }