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:
1 parent
03c41adbec
commit
59c2e45af7
5 files changed
+150
No files matched your search
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user