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

+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;
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
View File
@@ -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();
}