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

+62 -37
View File
@@ -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;
}
}