Compare commits

...
1 Commits
Author SHA1 Message Date
Martino FerrariandClaude Opus 4.6 59c2e45af7 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>
2026-10-05 18:21:16 +02:00
5 changed files with 150 additions and 0 deletions

No files matched your search

+14
View File
@@ -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
+6
View File
@@ -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
+2
View File
@@ -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;
}
+23
View File
@@ -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
+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 {