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

+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