fix(render): floor the axes so chrome cannot erase it, one title row per titled axis

Two findings on the pass-1 fix:

- The reserved strips were clamped only to the viewport, so a title wider than a
  narrow panel took the rest of it and the axes collapsed: draw_traces() returned
  on a zero-width rect (no trace drawn, nothing reported) and a zero-height one
  flattened every trace onto a single row. The chrome is now capped at half the
  viewport -- the label strip is served first, the title strip takes what is left
  of that allowance -- an over-long title is clipped to its strip by a walk that
  both measures and stages it, and draw_traces() tests the height as well as the
  width.
- Every titled y axis was staged at the same centred position inside one strip, so
  a Left/Right pair of titles overprinted. Each titled axis now owns a text line:
  the x strip grows one line per titled x axis (x_title_height), and the y runs
  stack down the axes-tall strip, which needs no extra width for it.

Tests: `narrow_panel_with_a_long_y_title_still_draws` and
`short_panel_with_an_x_title_still_draws` pin the floor (the axes keeps half the
panel and the trace still covers the columns, with the title clipped into its
strip), `titled_axis_pairs_get_their_own_rows` pins one text row per titled axis
in both directions, and `layout_stacks_one_title_line_per_titled_axis` pins the
strip sizing. All fail against the pass-1 sources.
This commit is contained in:
Martino Ferrari committed 2026-09-27 23:33:06 +02:00
1 parent 8f45108559
commit 1f5aa11de2
6 files changed
+330 -51

No files matched your search

+45 -19
View File
@@ -11,6 +11,16 @@ namespace {
/// changing, so a normal plot converges on the second pass and never reaches this.
constexpr u32 kMaxLayoutPasses = 4;
/// Share of the viewport the axes rect keeps however much chrome the label and
/// title strips ask for. A long SI label or a title wider than a narrow panel can
/// ask for the whole viewport, and an axes rect with no width or no height draws
/// nothing at all -- no grid, no tick, no trace -- without reporting anything, so
/// the strips give way instead. The label strip is what makes the tick values
/// readable, so it is served first and the title strip takes what is left; a title
/// longer than its share of the strip is clipped to it rather than allowed to grow
/// it (see `Renderer::draw_titles()`).
constexpr f32 kMinAxesShare = 0.5f;
const Axis& default_axis() noexcept {
static const Axis axis{};
return axis;
@@ -311,22 +321,33 @@ const PlotLayout& Plot::layout() {
x_height = text_->height() + 2.0f * pad;
}
// The title strips, though, are needed before the loop and never change, unlike
// the label strips: a strip holds exactly one text line, so no tick label can
// make it wider. 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 tall;
// the y strip is one horizontal run wide (see y_title_strip_width).
// the label strips: a strip holds exactly one text line per titled axis and no
// tick label can change that. Nothing at all is reserved without a text backend
// -- 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).
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 && has_x_title() ? text_->height() + 2.0f * pad : 0.0f;
const f32 x_title_h = text_ != nullptr && x_titles != 0u
? static_cast<f32>(x_titles) * text_->height() + 2.0f * pad
: 0.0f;
for (u32 pass = 0;; ++pass) {
// Clamped to the viewport so a huge label set can never push the axes rect
// outside it (or make its width negative). The title strips take what is left
// after the label strip, so the two can never add up past the viewport.
// Chrome is measured against the viewport, then capped so the axes keeps
// kMinAxesShare of it -- the label strip first, the title strip out of what is
// left of the allowance -- which is what keeps axes.w and axes.h positive
// however long the labels and titles are. `viewport_` is what a huge label set
// is clamped to first, so neither strip can push the rect outside it.
y_width = std::clamp(y_width, 0.0f, viewport_.w);
x_height = std::clamp(x_height, 0.0f, viewport_.h);
const f32 y_title_width = std::clamp(y_title_w, 0.0f, viewport_.w - y_width);
const f32 x_title_height = std::clamp(x_title_h, 0.0f, viewport_.h - x_height);
const f32 y_budget = (1.0f - kMinAxesShare) * viewport_.w;
const f32 x_budget = (1.0f - kMinAxesShare) * viewport_.h;
y_width = std::min(y_width, y_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 x_title_height = std::min(x_title_h, x_budget - x_height);
layout_.y_axis_width = y_width;
layout_.x_axis_height = x_height;
layout_.y_title_width = y_title_width;
@@ -344,8 +365,11 @@ const PlotLayout& Plot::layout() {
refresh_ticks();
if (text_ == nullptr || pass + 1 >= kMaxLayoutPasses) break;
const f32 need_y = std::clamp(y_label_width(), 0.0f, viewport_.w);
const f32 need_x = std::clamp(text_->height() + 2.0f * pad, 0.0f, viewport_.h);
// The same caps as above, so this compares like with like and the loop still
// reaches its fixed point on the second pass.
const f32 need_y = std::min(std::clamp(y_label_width(), 0.0f, viewport_.w), y_budget);
const f32 need_x =
std::min(std::clamp(text_->height() + 2.0f * pad, 0.0f, viewport_.h), x_budget);
if (need_y == y_width && need_x == x_height) break; // strips are a fixed point
y_width = need_y;
x_height = need_x;
@@ -385,18 +409,20 @@ f64 Plot::snapped_x(const Cursor& c) const {
return c.x;
}
bool Plot::has_x_title() const noexcept {
u32 Plot::titled_x_axis_count() const noexcept {
u32 n = 0;
for (const Axis& a : x_axes_) {
if (!a.label.empty()) return true;
if (!a.label.empty()) ++n;
}
return false;
return n;
}
bool Plot::has_y_title() const noexcept {
u32 Plot::titled_y_axis_count() const noexcept {
u32 n = 0;
for (const Axis& a : y_axes_) {
if (!a.label.empty()) return true;
if (!a.label.empty()) ++n;
}
return false;
return n;
}
f32 Plot::y_title_strip_width() const {
+74 -22
View File
@@ -50,6 +50,32 @@ 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,
/// slot)` for every glyph it keeps -- `pen` in viewport pixels, `slot` in the font
/// atlas -- 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.
template <class Emit>
f32 walk_text_clipped(Vec2 pen, const char* utf8, f32 budget_px, const render::BitmapFont& font,
f32 px_per_cell, Emit emit) {
if (utf8 == nullptr || !(budget_px > 0.0f)) {
return 0.0f; // no room at all: a strip clipped away stages nothing
}
const f32 scale = px_per_cell / static_cast<f32>(render::BitmapFont::kGlyphPx);
f32 used = 0.0f;
for (const char* p = utf8; *p != '\0';) {
const u32 codepoint = render::BitmapFont::next_codepoint(p);
const f32 advance = font.advance_codepoint(codepoint) * scale;
if (used + advance > budget_px) break; // the next glyph does not fit: cut here
emit(pen, render::BitmapFont::glyph_index(codepoint));
pen.x += advance;
used += advance;
}
return used;
}
} // namespace
// --------------------------------------------------------------- AxisFont
@@ -326,6 +352,16 @@ Vec2 Renderer::stage_text(Vec2 pos_px, const char* utf8) {
return glyphs_.add_text(to_viewport(pos_px), utf8, font_, font_backend_.cell_px());
}
f32 Renderer::clipped_text_width(const char* utf8, f32 budget_px) const {
return walk_text_clipped(Vec2{}, utf8, budget_px, font_, font_backend_.cell_px(),
[](Vec2, u32) {});
}
f32 Renderer::stage_text_clipped(Vec2 pos_px, const char* utf8, f32 budget_px) {
return walk_text_clipped(to_viewport(pos_px), utf8, budget_px, font_, font_backend_.cell_px(),
[this](Vec2 pen, u32 slot) { glyphs_.add_glyph(pen, slot); });
}
// -------------------------------------------------------------------- grid
void Renderer::draw_grid(const Plot& plot) {
@@ -407,42 +443,58 @@ 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, one text line
// outside the tick labels: below the x labels for an x axis, beside the y labels
// for a y axis. The strips are what makes a title over the data impossible --
// `layout.axes` is inset by them -- rather than merely unlikely, so nothing here
// may fall back to an inside-the-axes position.
// 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.
const f32 pad = style_.grid_label_pad;
const f32 line_h = font_backend_.height();
for (u32 i = 0; i < plot.x_axis_count(); ++i) {
// X titles: the strip's rows top down, one per titled axis.
for (u32 i = 0, row = 0; i < plot.x_axis_count(); ++i) {
const Axis& axis = plot.x_axis(i);
if (axis.label.empty()) continue;
// Centred on the strip: the tick labels have their own row above it, so the
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);
// Centred on the strip: the tick labels have their own rows above it, so the
// title needs no alignment of its own.
const f32 width = font_backend_.measure(axis.label.c_str());
stage_text({layout.x_title_area.x + 0.5f * (layout.x_title_area.w - width),
layout.x_title_area.y + 0.5f * (layout.x_title_area.h - line_h)},
axis.label.c_str());
stage_text_clipped({strip.x + 0.5f * (strip.w - width),
strip.y + pad + static_cast<f32>(row) * line_h},
axis.label.c_str(), budget);
++row;
}
for (u32 i = 0; i < plot.y_axis_count(); ++i) {
// 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) {
const Axis& axis = plot.y_axis(i);
if (axis.label.empty()) continue;
// A y title is a horizontal run in the vertical strip the layout sized for
// the widest of them, centred on the axes' height: it reads like the x title,
// only beside the plot instead of under it. A run longer than the axes is
// clipped by the viewport, never folded back over the data.
const f32 width = font_backend_.measure(axis.label.c_str());
stage_text({layout.y_title_area.x + 0.5f * (layout.y_title_area.w - width),
layout.y_title_area.y + 0.5f * (layout.y_title_area.h - line_h)},
axis.label.c_str());
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;
}
}
// ------------------------------------------------------------------- data
void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
if (!(layout.axes.w > 0.0f)) {
return; // a collapsed plot area has no pixel to select a level for
// Both dimensions: a zero-width rect selects no level, and a zero-height one
// would flatten every trace onto a single row -- a frame that looks like a
// rendering choice rather than the collapsed plot it is.
if (!(layout.axes.w > 0.0f) || !(layout.axes.h > 0.0f)) {
return;
}
for (u32 i = 0; i < plot.trace_count(); ++i) {