From 59c2e45af7428e1357180ef39185bccb3f422d57 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 5 Oct 2026 18:21:16 +0200 Subject: [PATCH] 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 --- include/plot/plot/plot.hpp | 14 +++++ include/plot/render/renderer.hpp | 6 ++ src/plot/plot.cpp | 2 + src/render/renderer.cpp | 23 +++++++ tests/test_render_smoke.cpp | 105 +++++++++++++++++++++++++++++++ 5 files changed, 150 insertions(+) diff --git a/include/plot/plot/plot.hpp b/include/plot/plot/plot.hpp index f8ec0f0..4d4f555 100644 --- a/include/plot/plot/plot.hpp +++ b/include/plot/plot/plot.hpp @@ -508,6 +508,18 @@ class Plot { void set_x_axis_origin_from_range(u32 axis, bool on); void set_y_axis_origin_from_range(u32 axis, bool on); + /// A one-line caption for the x axis, drawn as **chrome** in the bottom-left corner of the + /// axes with the legend's own backplate behind it. It exists for the thing a shifted origin + /// leaves unsaid: labels counted from a window's left edge are only readable beside the value + /// that edge has, and the reader adds the two. 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, + /// which is the one thing a legend-like panel must not do. The text is the host's, because only + /// the host knows how much precision the sum needs: the axis' own label form carries three + /// significant figures, which is exactly what made the shifted origin necessary. + /// Empty (the default) draws nothing. + void set_x_note(std::string text); + const std::string& x_note() const noexcept { return x_note_; } + /// The measurement for `id`. Re-evaluated only when a cursor moved, when the /// measure was added, or when the series underneath it changed: a different /// series, a longer one, or a sliding window whose sample count stayed the same @@ -848,6 +860,8 @@ class Plot { render::Style style_{}; const render::TextBackend* text_ = nullptr; Legend legend_{}; + /// The x axis' corner caption; see set_x_note(). Empty draws nothing. + std::string x_note_; /// Which parts of the bottom x chrome are reserved at all; see set_x_strip_mode(). XStrip x_strip_ = XStrip::Both; /// The floor a figure imposes on the left gutter; see set_min_y_gutter(). 0.0 means diff --git a/include/plot/render/renderer.hpp b/include/plot/render/renderer.hpp index fdbe4fc..444b85c 100644 --- a/include/plot/render/renderer.hpp +++ b/include/plot/render/renderer.hpp @@ -534,6 +534,12 @@ class Renderer { /// them again, so the panel a test asserts and the panel this draws are the same /// numbers. void draw_legend(const Plot& plot, const PlotLayout& layout); + /// `Plot::x_note()`, in the bottom-left corner of the axes on the legend's backplate. It is + /// measured here rather than in `Plot::layout()` on purpose: unlike a legend it reserves no + /// space and nothing can be picked on it, so there is no measurement a host or a test needs to + /// read back, and a caption that explains the x labels must not be able to move the layout the + /// labels were measured into. + void draw_x_note(const Plot& plot, const PlotLayout& layout); /// The backplate the legend and the crosshair's readout share: the frame's background at /// `kLegendPanelAlpha` over `panel`, with a one-device-pixel border in `border` around the /// outermost pixels the fill covers (the crisp-hairline rule -- a border drawn on the measured diff --git a/src/plot/plot.cpp b/src/plot/plot.cpp index 858c649..8596c7e 100644 --- a/src/plot/plot.cpp +++ b/src/plot/plot.cpp @@ -342,6 +342,8 @@ void Plot::set_y_axis_origin_from_range(u32 axis, bool on) { y_axes_[axis].origin_follows_range = on; } +void Plot::set_x_note(std::string text) { x_note_ = std::move(text); } + f64 Plot::x_axis_origin(u32 axis) const { return axis < x_axes_.size() ? x_axes_[axis].origin : 0.0; } diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index 3075912..7c405e8 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -533,6 +533,7 @@ void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) { draw_measures(plot, layout); draw_annotations(plot); draw_legend(plot, layout); + draw_x_note(plot, layout); // The under text batch goes out here, so the readout's panel below it can // occlude what it covers: legend names, cursor labels, measure labels and // annotation captions are all chrome the panel is allowed to sit on. @@ -1403,6 +1404,28 @@ void Renderer::draw_legend(const Plot& plot, const PlotLayout& layout) { } } +void Renderer::draw_x_note(const Plot& plot, const PlotLayout& layout) { + const std::string& text = plot.x_note(); + if (text.empty()) return; + const Rect& axes = layout.axes; + const f32 line_h = font_backend_.height(); + const f32 width = font_backend_.measure(text.c_str()); + if (!(width > 0.0f) || !(line_h > 0.0f)) return; + // The pad is the legend's, so the two panels read as the same kind of chrome. + const f32 pad = style_.device(4.0f); + const Rect panel{axes.x + pad, axes.bottom() - pad - line_h - 2.0f * pad, width + 2.0f * pad, + line_h + 2.0f * pad}; + // A sliver of axes gets no caption rather than one hanging over the frame: the panel is chrome + // *inside* the axes, and a backplate crossing the axis line would read as a second plot. + if (panel.w > axes.w || panel.h > axes.h) return; + draw_readout_panel(panel, style_.axis_line); + // Bottom-left, beside the labels it qualifies -- the reader adds the caption to a tick, so it + // belongs at the end of the tick row and not in a corner of its own. `glyphs_under_` because + // the panel is flushed with the frame's line geometry and the under-text batch goes out after + // it: text staged into the over batch would be drawn before its own backplate. + stage_text(glyphs_under_, {panel.x + pad, panel.y + pad}, text.c_str(), style_.axis_text); +} + void Renderer::stage_marker(plot::Marker marker, Vec2 centre_px, f32 size_px) { // Marker shapes as line segments, in viewport pixels: the trace's flush maps // them with `pixel_mapping()`, so the size is device pixels whatever the data diff --git a/tests/test_render_smoke.cpp b/tests/test_render_smoke.cpp index f602c9a..9c945fd 100644 --- a/tests/test_render_smoke.cpp +++ b/tests/test_render_smoke.cpp @@ -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(kW), static_cast(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 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 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(lo_x), static_cast(lo_y), + static_cast(hi_x - lo_x + 1u), + static_cast(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 {