feat(plot): an x axis caption drawn as chrome, not as an annotation

A host that re-anchors its x labels on a grid has to name the anchor somewhere, and
an `Annotation` cannot serve: its anchor is a data coordinate, so the caption crawls
across the plot under a pan and drifts under a zoom.

`Plot::set_x_note()` takes the host's own string -- the host is the only one that
knows how much precision the sum of a label and its caption needs -- and
`Renderer::draw_x_note()` places it in the axes' bottom-left corner in pixels, on the
legend's own backplate. It is measured in the renderer rather than in `Plot::layout()`
because it reserves no space and nothing can be picked on it, and a caption explaining
the x labels must not be able to move the layout those labels were measured into.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-10-05 18:21:16 +02:00
1 parent 03c41adbec
commit 59c2e45af7
5 files changed
+150

No files matched your search

+105
View File
@@ -3171,6 +3171,111 @@ PLOT_TEST(legend_does_not_cover_the_plot_when_placed_bottom_left) {
ctx.destroy();
}
/// `Plot::set_x_note()`'s caption is *chrome*: it sits in the bottom-left corner of the
/// axes on the legend's own backplate, and panning or zooming the x range does not move
/// it by a pixel. That is the whole reason it exists rather than an `Annotation`, whose
/// anchor is a data coordinate and so crawls under a pan and drifts under a zoom.
///
/// The caption's rect is deliberately not readable back -- it reserves no space and
/// nothing can be picked on it -- so the test measures it the only way a reader can see
/// it: the pixels a frame *with* the note differs from the same frame without one. The
/// bounding box of that difference is the caption's footprint, and the assertion is that
/// two completely different x ranges produce the *same* box.
PLOT_TEST(the_x_note_is_chrome_that_a_pan_or_a_zoom_does_not_move) {
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::data::ArraySeries s(sine_samples(), 0.0, 1.0);
plot::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
PLOT_CHECK(p.add_trace({&s, plot::Color::hex(0x00E070, 1.0f), 1.5f, ax, ay, "signal"}) == 0u);
// The footprint of the note at one x range: the bounding box of the pixels that the
// caption, and only the caption, is responsible for.
const auto footprint = [&](const plot::Range& view, const char* dump) {
plot::Rect box{0.0f, 0.0f, 0.0f, 0.0f};
p.set_x_range(ax, view);
p.set_x_note("t0 = 1.23457e+06 s");
r.begin_frame(kW, kH, kFrameRect);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
dump_if_requested(r, dump);
const std::vector<plot::u8> with = read_frame(ctx);
p.set_x_note("");
r.begin_frame(kW, kH, kFrameRect);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
const std::vector<plot::u8> without = read_frame(ctx);
plot::u32 lo_x = kW, lo_y = kH, hi_x = 0, hi_y = 0, differing = 0;
for (plot::u32 y = 0; y < kH; ++y) {
for (plot::u32 x = 0; x < kW; ++x) {
const plot::usize at = (plot::usize(y) * kW + x) * 4u;
if (with[at] == without[at] && with[at + 1u] == without[at + 1u] &&
with[at + 2u] == without[at + 2u] && with[at + 3u] == without[at + 3u]) {
continue;
}
++differing;
lo_x = std::min(lo_x, x);
lo_y = std::min(lo_y, y);
hi_x = std::max(hi_x, x);
hi_y = std::max(hi_y, y);
}
}
// A comparison of two frames is only evidence if the caption was in one of them.
PLOT_CHECK(differing > 0u);
box = plot::Rect{static_cast<plot::f32>(lo_x), static_cast<plot::f32>(lo_y),
static_cast<plot::f32>(hi_x - lo_x + 1u),
static_cast<plot::f32>(hi_y - lo_y + 1u)};
return box;
};
const plot::Rect wide = footprint({0.0, 640.0}, "x_note.ppm");
// The corner: inside the axes, held off the left and bottom edges by a pad of a few
// pixels, and nowhere near the right-hand side or the top.
const plot::Rect axes = p.layout().axes;
PLOT_CHECK(wide.x > axes.x && wide.x < axes.x + 16.0f);
PLOT_CHECK(wide.bottom() <= axes.bottom() && wide.bottom() > axes.bottom() - 16.0f);
PLOT_CHECK(wide.right() < axes.x + 0.5f * axes.w); // a caption, not a banner
PLOT_CHECK(wide.h >= 8.0f && wide.h < 0.5f * axes.h);
// Real glyph ink on the backplate, so the box is a caption and not an empty panel. The
// frame has to be re-drawn *with* the note first: the measurement above left the
// note-less frame in the buffer, and `scan_rect_ink` reads the live one.
p.set_x_range(ax, {0.0, 640.0});
p.set_x_note("t0 = 1.23457e+06 s");
r.begin_frame(kW, kH, kFrameRect);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
PLOT_CHECK(scan_rect_ink(ctx, wide, r.style().axis_text).pixels >= 16u);
// The same caption under a pan to a different decade and a 64x zoom: an annotation
// anchored in data would have left the frame entirely, and the one here has not moved
// a pixel. The text is fixed across the three, since this case is about the position.
const plot::Rect panned = footprint({320.0, 960.0}, "x_note_panned.ppm");
const plot::Rect zoomed = footprint({300.0, 310.0}, "x_note_zoomed.ppm");
PLOT_CHECK_NEAR(panned.x, wide.x, 1e-4);
PLOT_CHECK_NEAR(panned.y, wide.y, 1e-4);
PLOT_CHECK_NEAR(panned.w, wide.w, 1e-4);
PLOT_CHECK_NEAR(panned.h, wide.h, 1e-4);
PLOT_CHECK_NEAR(zoomed.x, wide.x, 1e-4);
PLOT_CHECK_NEAR(zoomed.y, wide.y, 1e-4);
PLOT_CHECK_NEAR(zoomed.w, wide.w, 1e-4);
PLOT_CHECK_NEAR(zoomed.h, wide.h, 1e-4);
std::printf("[x note] footprint %.0f,%.0f %.0fx%.0f in axes %.0f,%.0f %.0fx%.0f; panned "
"%.0f,%.0f %.0fx%.0f, zoomed %.0f,%.0f %.0fx%.0f\n",
wide.x, wide.y, wide.w, wide.h, axes.x, axes.y, axes.w, axes.h, panned.x, panned.y,
panned.w, panned.h, zoomed.x, zoomed.y, zoomed.w, zoomed.h);
ctx.destroy();
}
// --- one physical size at any DPI --------------------------------------------
namespace {