feat(plot): per-trace legend with measured layout and swatches

This commit is contained in:
Martino Ferrari committed 2026-09-28 11:56:06 +02:00
1 parent 9dc3b1e5e6
commit 2d51b978d7
9 files changed
+850 -2

No files matched your search

+225
View File
@@ -2188,3 +2188,228 @@ PLOT_TEST(text_atlas_reports_a_status_when_the_entry_points_are_missing) {
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
ctx.destroy();
}
/// The legend's two cases need a count, not a presence: "the swatch colour appears"
/// is satisfied by the trace that carries the same colour, and "some ink is in the
/// panel" is satisfied by any label the frame happens to put there. Counting the
/// pixels of an area that are exactly `c` (see `pixel_is`) is what turns both into
/// statements about the swatch.
plot::u32 exact_pixels(const std::vector<plot::u8>& frame, plot::u32 fb_w, const plot::Rect& area,
plot::Color c) {
plot::u32 count = 0;
for (plot::u32 y = static_cast<plot::u32>(area.y);
y < static_cast<plot::u32>(area.bottom()); ++y) {
for (plot::u32 x = static_cast<plot::u32>(area.x);
x < static_cast<plot::u32>(area.right()); ++x) {
if (pixel_is(&frame[(plot::usize(y) * fb_w + x) * 4u], c)) ++count;
}
}
return count;
}
/// The whole framebuffer as a Rect, for the counts that ask "and nowhere else".
const plot::Rect kFrameRect{0.0f, 0.0f, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
/// The legend's entries are drawn where `Plot::layout()` measured them: a filled
/// swatch per trace in that trace's own colour, and the name's ink inside the panel
/// and nowhere else in the data area -- and the panel's presence changes nothing
/// outside itself.
///
/// The swatch colour is what makes the count a test. Both traces here are translucent
/// (alpha 0.25), while the swatch is drawn opaque, so the exact colour of a trace is
/// one that only a swatch can produce: the trace blended over the background -- or
/// over a grid line, which is brighter -- never reaches it, and neither do the grid,
/// the axis line or the text. Two colours are used so each entry's swatch has to be
/// its own trace's, not whichever colour the legend drew for everything.
PLOT_TEST(legend_swatch_and_label_are_drawn) {
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);
// The second trace is a series of its own, well clear of the first: two
// translucent strokes stacked over a grid line reach a brightness the `axis_text`
// ink predicate below cannot tell from a label, and the "no name ink in the data
// area" half of this case has to be about the legend's ink only.
plot::data::ArraySeries flat(std::vector<plot::f32>(64u, 0.75f), 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});
const plot::Color green = plot::Color::hex(0x00E070, 0.25f);
const plot::Color pink = plot::Color::hex(0xFF3CC8, 0.25f);
PLOT_CHECK(p.add_trace({&s, green, 1.5f, ax, ay, "signal"}) == 0u);
PLOT_CHECK(p.add_trace({&flat, pink, 1.5f, ax, ay, "noise"}) == 1u);
p.set_x_range(ax, {0.0, 640.0}); // one sample per pixel: a flat, cheap frame
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
dump_if_requested(r, "legend.ppm");
// A copy: the second draw below clears the rows this layout holds.
const plot::PlotLayout layout = p.layout();
const plot::LegendLayout legend = layout.legend;
PLOT_CHECK(legend.rows.size() == 2u);
const plot::Legend& cfg = p.legend();
const plot::u32 swatch_area = static_cast<plot::u32>(cfg.swatch_w) *
static_cast<plot::u32>(cfg.swatch_h);
const std::vector<plot::u8> frame = read_frame(ctx);
// Each swatch: its own row's swatch box holds the pixels, and every pixel of that
// colour in the whole frame is one of them. The lower bound is the box's area
// minus the fringe a 1 px stroke column cannot promise -- a row and a column at
// each edge -- and the upper bound lets the outermost columns and rows count
// where the fill's edge lands a hair inside them.
const plot::Color opaque[2] = {plot::Color{green.r, green.g, green.b, 1.0f},
plot::Color{pink.r, pink.g, pink.b, 1.0f}};
for (plot::u32 i = 0; i < legend.rows.size(); ++i) {
const plot::Rect& box = legend.rows[i].swatch;
PLOT_CHECK(box.w > 0.0f && box.h > 0.0f);
PLOT_CHECK(box.x >= legend.panel.x && box.right() <= legend.panel.right());
PLOT_CHECK(box.y >= legend.panel.y && box.bottom() <= legend.panel.bottom());
// The row names its own trace, so the colour under the predicate is that
// trace's -- which is what `LegendRow::trace` is for.
PLOT_CHECK_NEAR(p.trace(legend.rows[i].trace).color.r, opaque[i].r, 1e-6);
const plot::u32 inside = exact_pixels(frame, kW, box, opaque[i]);
const plot::u32 anywhere = exact_pixels(frame, kW, kFrameRect, opaque[i]);
const plot::u32 in_panel = exact_pixels(frame, kW, legend.panel, opaque[i]);
// The swatch's colour fills the swatch's box -- within the row and column an
// edge's coverage ramp can reach past it -- and appears nowhere else in the
// frame: not over the trace that carries the same colour at 25%, not in the
// grid, and not outside the panel, which is where this ink's whole budget is.
PLOT_CHECK(inside >= swatch_area - 2u * static_cast<plot::u32>(cfg.swatch_w) -
2u * static_cast<plot::u32>(cfg.swatch_h));
PLOT_CHECK(anywhere <= swatch_area + 2u * static_cast<plot::u32>(cfg.swatch_w) +
2u * static_cast<plot::u32>(cfg.swatch_h));
PLOT_CHECK(anywhere == in_panel);
// The numbers behind those assertions, so a run says what it measured rather than
// only that it passed.
std::printf("[legend] row %u trace %u swatch %.2f,%.2f %.2fx%.2f: %u pixels of its colour, "
"%u in the frame\n",
i, legend.rows[i].trace, box.x, box.y, box.w, box.h, inside, anywhere);
}
// The names: real glyph ink inside the panel, and none in the data area outside it.
// This plot has no cursor, no measurement and no annotation, and every other
// `axis_text` run -- the tick labels, the titles -- is drawn in a strip outside
// `axes`, so the only ink in the data area is the legend's.
const plot::Color ink = r.style().axis_text;
const plot::u32 in_panel = scan_rect_ink(ctx, legend.panel, ink).pixels;
// The data area, inset past the axis lines: they run along the axes' edges and are
// brighter than a third of `axis_text` -- the trap `rect_has_exact` exists for -- so
// an un-inset scan would be counting the axis rather than a label.
const plot::Rect data{layout.axes.x + 2.0f, layout.axes.y + 2.0f, layout.axes.w - 4.0f,
layout.axes.h - 4.0f};
const plot::u32 in_data = scan_rect_ink(ctx, data, ink).pixels;
PLOT_CHECK(in_panel >= 32u); // two names, not one stray anti-aliased pixel
PLOT_CHECK(in_data == in_panel); // and every bit of that ink is the legend's
// The numbers behind those assertions, so a run says what it measured rather than
// only that it passed.
std::printf("[legend] panel %.2f,%.2f %.2fx%.2f, %zu rows; name ink %u in the panel, %u in the "
"data area\n",
legend.panel.x, legend.panel.y, legend.panel.w, legend.panel.h, legend.rows.size(),
in_panel, in_data);
// The panel changes nothing outside itself: the same plot drawn with the legend
// switched off -- same traces, same axes rect, same transforms, since a legend
// reserves none of the plot -- must come out pixel-identical everywhere except
// inside the panel. One pixel of slack on each side, because a fill's stroke
// column lets its fringe reach a pixel past the panel's edge.
plot::Legend off = cfg;
off.visible = false;
p.set_legend(off);
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
const std::vector<plot::u8> bare = read_frame(ctx);
const plot::Rect changed{legend.panel.x - 1.0f, legend.panel.y - 1.0f, legend.panel.w + 2.0f,
legend.panel.h + 2.0f};
PLOT_CHECK(changed.x >= layout.axes.x && changed.right() <= layout.axes.right());
PLOT_CHECK(changed.bottom() <= layout.axes.bottom()); // so "inside changed" is inside axes
plot::u32 differing = 0;
for (plot::u32 y = 0; y < ctx.height; ++y) {
for (plot::u32 x = 0; x < ctx.width; ++x) {
const plot::usize at = (plot::usize(y) * kW + x) * 4u;
if (frame[at] == bare[at] && frame[at + 1u] == bare[at + 1u] &&
frame[at + 2u] == bare[at + 2u] && frame[at + 3u] == bare[at + 3u]) {
continue;
}
++differing;
PLOT_CHECK(changed.contains(static_cast<plot::f32>(x) + 0.5f,
static_cast<plot::f32>(y) + 0.5f));
}
}
// The comparison is only evidence if the legend was in the first frame at all.
PLOT_CHECK(differing > 0u);
ctx.destroy();
}
/// The bottom-left corner is the one a legend anchored from the axes' *opposite*
/// corner would miss: the panel has to be down there, inside `axes` and held off the
/// two edges that corner is near, with its swatch in the panel and nowhere else --
/// including not in the strip below the axes, where the tick labels live.
PLOT_TEST(legend_does_not_cover_the_plot_when_placed_bottom_left) {
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});
// Translucent, as in the case above: the swatch is drawn opaque, so this colour is
// the swatch's alone.
const plot::Color teal = plot::Color::hex(0x00E070, 0.25f);
p.add_trace({&s, teal, 1.5f, ax, ay, "signal"});
p.set_x_range(ax, {0.0, 640.0});
plot::Legend cfg = p.legend();
cfg.place = plot::LegendPlace::BottomLeft;
p.set_legend(cfg);
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
dump_if_requested(r, "legend_bottom_left.ppm");
const plot::PlotLayout layout = p.layout();
const plot::LegendLayout& legend = layout.legend;
PLOT_CHECK(legend.rows.size() == 1u);
const plot::Rect& panel = legend.panel;
PLOT_CHECK_NEAR(panel.x, layout.axes.x + cfg.margin, 1e-4);
PLOT_CHECK_NEAR(panel.bottom(), layout.axes.bottom() - cfg.margin, 1e-4);
PLOT_CHECK(panel.right() <= layout.axes.right());
PLOT_CHECK(panel.bottom() <= layout.axes.bottom());
const std::vector<plot::u8> frame = read_frame(ctx);
const plot::Color opaque{teal.r, teal.g, teal.b, 1.0f};
const plot::u32 in_panel = exact_pixels(frame, kW, panel, opaque);
PLOT_CHECK(in_panel >= static_cast<plot::u32>(cfg.swatch_w - 2.0f) *
static_cast<plot::u32>(cfg.swatch_h - 2.0f));
// Every pixel of the swatch colour is inside the panel: none in the x label strip
// below the axes, none in the y strip, none anywhere else in the frame.
PLOT_CHECK(exact_pixels(frame, kW, kFrameRect, opaque) == in_panel);
const plot::Rect below{0.0f, layout.axes.bottom(), static_cast<plot::f32>(kW),
static_cast<plot::f32>(kH) - layout.axes.bottom()};
PLOT_CHECK(exact_pixels(frame, kW, below, opaque) == 0u);
// ... with the name drawn beside it.
const plot::u32 name_ink = scan_rect_ink(ctx, panel, r.style().axis_text).pixels;
PLOT_CHECK(name_ink >= 16u);
std::printf("[legend] bottom-left panel %.2f,%.2f %.2fx%.2f; swatch %u pixels in the panel, %u "
"in the frame, %u below the axes; name ink %u\n",
panel.x, panel.y, panel.w, panel.h, in_panel,
exact_pixels(frame, kW, kFrameRect, opaque), exact_pixels(frame, kW, below, opaque),
name_ink);
ctx.destroy();
}