feat(plot): vertical y-axis label via a rotated text run
This commit is contained in:
1 parent
1cb5970757
commit
daeb31b1ea
6 files changed
+453
-80
No files matched your search
@@ -27,7 +27,12 @@ struct PlotLayout {
|
||||
Rect axes;
|
||||
Rect y_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.
|
||||
Rect y_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
|
||||
/// 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
|
||||
/// titled x axis; `y_title_width` is the widest y title plus the padding, since
|
||||
/// y titles are horizontal runs that stack down the axes-tall strip and need
|
||||
/// width, not another row of it. With no text backend there is no title to
|
||||
/// measure and both stay 0.0f.
|
||||
/// titled x axis and `y_title_width` is one line per titled y axis too: a
|
||||
/// rotated title occupies length along the axes' height, so a second titled y
|
||||
/// axis takes a *column* beside the first rather than another row of width. With
|
||||
/// no text backend there is no title to lay out at all and both stay 0.0f.
|
||||
f32 y_title_width = 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
|
||||
/// text backend asks for. Zero with no backend or no y axis.
|
||||
f32 y_label_width() const;
|
||||
/// Widest y axis title plus the label padding on both sides: the vertical strip
|
||||
/// a horizontal y title fits in. Zero with no backend or no titled y axis.
|
||||
/// One text line of width per titled y axis, plus the label padding on both
|
||||
/// 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;
|
||||
|
||||
Rect viewport_{};
|
||||
|
||||
@@ -261,15 +261,22 @@ class Renderer {
|
||||
/// Lays one run of text out at a framebuffer position and stages it, shifting
|
||||
/// it into viewport space. Returns the pen past the run.
|
||||
Vec2 stage_text(Vec2 pos_px, const char* utf8);
|
||||
/// Width of the longest prefix of `utf8` that fits `budget_px`, at the size the
|
||||
/// text pipeline draws with: exactly what `stage_text_clipped()` stages.
|
||||
f32 clipped_text_width(const char* utf8, f32 budget_px) const;
|
||||
/// Stages as much of `utf8` as fits `budget_px` at `pos_px` and returns the
|
||||
/// width it staged. Titles are the runs that need this: a title longer than the
|
||||
/// strip the layout reserved for it is cut there, rather than being allowed to
|
||||
/// grow the strip (which would shrink the plot) or to spill over the tick labels
|
||||
/// and the data (which is the defect the strips exist to prevent).
|
||||
f32 stage_text_clipped(Vec2 pos_px, const char* utf8, f32 budget_px);
|
||||
/// Width of the longest prefix of `utf8` that fits `budget_px` along `axis`, at
|
||||
/// the size the text pipeline draws with: exactly what `stage_text_clipped()`
|
||||
/// stages.
|
||||
f32 clipped_text_width(const char* utf8, f32 budget_px, Vec2 axis) const;
|
||||
/// Stages as much of `utf8` as fits `budget_px` along `axis` at `pos_px` and
|
||||
/// returns the length it staged. Titles are the runs that need this: a title
|
||||
/// longer than the strip the layout reserved for it is cut there, rather than
|
||||
/// being allowed to grow the strip (which would shrink the plot) or to spill
|
||||
/// 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_axis_lines(const Plot& plot);
|
||||
|
||||
+12
-9
@@ -336,9 +336,10 @@ const PlotLayout& Plot::layout() {
|
||||
// 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
|
||||
// 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
|
||||
// widest y title, since a y title is a horizontal run that stacks down the strip
|
||||
// and so needs width rather than another row of it (see y_title_strip_width).
|
||||
// per titled x axis tall (they are stacked in it); the y strip is one text line
|
||||
// per titled y axis *wide*, because the y title is drawn rotated and so spends
|
||||
// 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 f32 y_title_w = text_ != nullptr ? y_title_strip_width() : 0.0f;
|
||||
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 {
|
||||
// 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;
|
||||
f32 widest = 0.0f;
|
||||
for (const Axis& a : y_axes_) {
|
||||
if (a.label.empty()) continue;
|
||||
widest = std::max(widest, text_->measure(a.label.c_str()));
|
||||
}
|
||||
return widest > 0.0f ? widest + 2.0f * style_.grid_label_pad : 0.0f;
|
||||
const u32 titles = titled_y_axis_count();
|
||||
if (titles == 0u) return 0.0f;
|
||||
return static_cast<f32>(titles) * text_->height() + 2.0f * style_.grid_label_pad;
|
||||
}
|
||||
|
||||
f32 Plot::y_label_width() const {
|
||||
|
||||
+62
-37
@@ -51,19 +51,35 @@ u64 clamp_index(f64 v, u64 n) noexcept {
|
||||
constexpr usize kNumberChars = 24;
|
||||
constexpr usize kReadoutChars = 160;
|
||||
|
||||
/// Walks the longest prefix of `utf8` that fits `budget_px`, 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 width that prefix
|
||||
/// occupies. 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.
|
||||
/// The run bases the axis titles are drawn with. A title reads left to right in
|
||||
/// the x strip (`kTextRight`), and bottom-to-top in the y strip (`kTextUp`: a
|
||||
/// quarter turn anticlockwise on screen, where y grows downward). Both are unit
|
||||
/// vectors, which is what `walk_text_clipped()` spends its budget along and what
|
||||
/// `TextBatch` builds the glyph cell from (see `TextInstance`).
|
||||
constexpr Vec2 kTextRight{1.0f, 0.0f};
|
||||
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`
|
||||
/// 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>
|
||||
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)) {
|
||||
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;
|
||||
if (used + advance > budget_px) break; // the next glyph does not fit: cut here
|
||||
emit(pen, *glyph);
|
||||
pen.x += advance;
|
||||
pen = pen + axis * advance;
|
||||
used += advance;
|
||||
}
|
||||
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_);
|
||||
}
|
||||
|
||||
f32 Renderer::clipped_text_width(const char* utf8, f32 budget_px) const {
|
||||
return walk_text_clipped(Vec2{}, utf8, budget_px, default_font_, font_backend_.scale(),
|
||||
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(), axis,
|
||||
[](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();
|
||||
return walk_text_clipped(to_viewport(pos_px), utf8, budget_px, default_font_, scale,
|
||||
[this, scale](Vec2 pen, const render::Glyph& glyph) {
|
||||
glyphs_.add_glyph(pen, glyph, scale);
|
||||
return walk_text_clipped(to_viewport(pos_px), utf8, budget_px, default_font_, scale, axis,
|
||||
[this, scale, axis](Vec2 pen, const render::Glyph& glyph) {
|
||||
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) {
|
||||
// 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
|
||||
// a Left/Right (or Bottom/Top) pair of titles from being drawn over each other,
|
||||
// where the pass before this one put them all on one centred run. Each run is
|
||||
// also cut to its strip, so a title longer than the strip -- which the layout
|
||||
// caps, because a title may shrink the plot but never erase it -- stops at the
|
||||
// strip's padding instead of spilling over the labels or the data.
|
||||
// axis gets a text line of its own inside that strip -- a row for an x axis, a
|
||||
// column for a y one -- which is what keeps a Left/Right (or Bottom/Top) pair of
|
||||
// titles from being drawn over each other, where the pass before that put them
|
||||
// all on one centred run. Each run is also cut to its strip, so a title longer
|
||||
// than the strip -- which the layout caps, because a title may shrink the plot
|
||||
// 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 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) {
|
||||
const Axis& axis = plot.x_axis(i);
|
||||
if (axis.label.empty()) continue;
|
||||
const Rect& strip = layout.x_title_area;
|
||||
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
|
||||
// title needs no alignment of its own.
|
||||
stage_text_clipped({strip.x + 0.5f * (strip.w - width),
|
||||
strip.y + pad + static_cast<f32>(row) * line_h},
|
||||
axis.label.c_str(), budget);
|
||||
axis.label.c_str(), budget, kTextRight);
|
||||
++row;
|
||||
}
|
||||
|
||||
// Y titles: the same, stacked down the axes-tall strip and centred on it as a
|
||||
// group, so the single titled axis an ordinary plot has keeps the row it always
|
||||
// had. A y title reads horizontally like the x ones, so its width is what the
|
||||
// strip had to reserve; the extra lines cost height, which the axes already
|
||||
// provides.
|
||||
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) {
|
||||
// Y titles: the same strips, cut the same way, but each run is rotated a quarter
|
||||
// turn so it reads bottom-to-top -- axis (0,-1), the direction the run advances
|
||||
// in -- which is what lets the strip be one line *wide* instead of as wide as the
|
||||
// title: a rotation, not a reflection, so the text is not mirrored (the shader
|
||||
// builds the cell's other axis as the basis' perpendicular).
|
||||
//
|
||||
// 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);
|
||||
if (axis.label.empty()) continue;
|
||||
const Rect& strip = layout.y_title_area;
|
||||
const f32 budget = strip.w - 2.0f * pad;
|
||||
const f32 width = clipped_text_width(axis.label.c_str(), budget);
|
||||
const f32 top = strip.y + std::max(0.0f, 0.5f * (strip.h - stack)) +
|
||||
static_cast<f32>(row) * line_h;
|
||||
stage_text_clipped({strip.x + 0.5f * (strip.w - width), top}, axis.label.c_str(), budget);
|
||||
++row;
|
||||
const f32 budget = strip.h - 2.0f * pad;
|
||||
const f32 length = clipped_text_width(axis.label.c_str(), budget, kTextUp);
|
||||
stage_text_clipped({strip.x + pad + static_cast<f32>(column) * line_h,
|
||||
strip.bottom() - 0.5f * (strip.h - length)},
|
||||
axis.label.c_str(), budget, kTextUp);
|
||||
++column;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+71
-10
@@ -82,10 +82,12 @@ PLOT_TEST(layout_derives_label_areas_from_font_metrics) {
|
||||
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.x_axis_height, font.height() + 2.0f * pad, 1e-4);
|
||||
// The y axis carries the title "volts": it is drawn horizontally, so the strip
|
||||
// left of the label strip is as wide as the measured title. There is no x axis,
|
||||
// so the x direction reserves nothing beyond its label strip.
|
||||
PLOT_CHECK_NEAR(l.y_title_width, font.measure("volts") + 2.0f * pad, 1e-4);
|
||||
// The y axis carries the title "volts", drawn rotated to read bottom-to-top, so
|
||||
// the strip left of the label strip is one text line *wide* -- the column the
|
||||
// run advances up -- and not as wide as the title is when horizontal. There is
|
||||
// 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.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);
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// Every titled axis of a direction gets its own text line inside its strip;
|
||||
/// without that, the second titled axis of an edge is drawn on top of the first.
|
||||
/// Every titled axis of a direction gets its own text line inside its strip; without
|
||||
/// 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::render::EmbeddedMonoFont font;
|
||||
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);
|
||||
// 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);
|
||||
// ... while the y titles stack down the axes-tall strip, so the second one costs
|
||||
// height the axes already has rather than a second column of width.
|
||||
PLOT_CHECK_NEAR(l.y_title_width,
|
||||
std::max(font.measure("volts"), font.measure("amps")) + 2.0f * pad, 1e-4);
|
||||
// ... and the y titles, rotated, take a *column* each: the second titled y axis
|
||||
// costs another text line of width rather than a row of the strip's height, and
|
||||
// the pair still cannot be drawn over each other. The widest title's own width
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// 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::Plot p;
|
||||
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
||||
|
||||
+279
-8
@@ -336,6 +336,81 @@ TextInk scan_text_ink(plottest::HeadlessContext& ctx, plot::u32 x0, plot::u32 x1
|
||||
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
|
||||
/// 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
|
||||
@@ -1056,12 +1131,14 @@ PLOT_TEST(short_panel_with_an_x_title_still_draws) {
|
||||
ctx.destroy();
|
||||
}
|
||||
|
||||
// Fix pass 2, finding 2: every titled y axis was staged at the same centred
|
||||
// position inside the one strip, so a Left "volts" and a Right "amps" drew over
|
||||
// each other -- the overprint the pre-strip placement did not have. Each titled
|
||||
// axis now owns a text line, so a titled pair of either direction shows two
|
||||
// separated runs of title-coloured pixels where it used to show one.
|
||||
PLOT_TEST(titled_axis_pairs_get_their_own_rows) {
|
||||
// Fix pass 2, finding 2: every titled axis of a direction was staged at the same
|
||||
// centred position inside the one strip, so a Bottom/Top (or Left/Right) pair drew
|
||||
// over each other -- the overprint the pre-strip placement did not have. Each
|
||||
// titled axis now owns a text line, so a pair shows two separated runs of
|
||||
// title-coloured pixels where it used to show one: two *rows* for an x pair, which
|
||||
// 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);
|
||||
plot::Status 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::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
|
||||
// apart and each covers several pixel rows of glyph ink.
|
||||
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.y_title_area.w > 0.0f);
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user