Compare commits
1
Commits
03c41adbec
...
59c2e45af7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59c2e45af7 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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