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

+276
View File
@@ -563,3 +563,279 @@ PLOT_TEST(grid_lines_are_tick_aligned_data_space_segments) {
for (const plot::GridLine& l : g.lines()) minor += l.major ? 0u : 1u;
PLOT_CHECK(minor > 0); // minor grid is generated too
}
// ------------------------------------------------------------------- legend
namespace {
/// The scene the legend cases measure: three named traces on one axis pair, a text
/// backend to measure the names with, and a viewport with room to spare. The names
/// are deliberately of different widths -- a panel sized to the *widest* one is what
/// the first case below checks, and names of one width could not tell that from a
/// panel sized to the first or the last.
struct LegendScene {
plot::render::EmbeddedMonoFont font;
plot::data::ArraySeries series;
plot::Plot plot;
LegendScene() : series(ramp(64), 0.0, 1.0) {
plot.set_text_backend(&font);
plot.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ax = plot.add_x_axis({"t", {0.0, 63.0}, false, plot::AxisPlace::Bottom, false});
const u32 ay = plot.add_y_axis({"v", {0.0, 63.0}, false, plot::AxisPlace::Left, false});
plot.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, ax, ay, "sine"});
plot.add_trace({&series, plot::Color::hex(0xFFB020), 1.5f, ax, ay, "noise floor"});
plot.add_trace({&series, plot::Color::hex(0x00E070), 1.5f, ax, ay, "ramp"});
}
};
/// One row's height is the taller of the swatch and a text line: the number every
/// other assertion here is written against, derived from the config the layout reads.
f32 legend_row_height(const plot::Legend& cfg, const plot::render::TextBackend& font) {
return std::max(cfg.swatch_h, font.height());
}
} // namespace
PLOT_TEST(legend_panel_wraps_the_entries) {
LegendScene scene;
const plot::PlotLayout& l = scene.plot.layout();
const plot::LegendLayout& legend = l.legend;
const plot::Legend& cfg = scene.plot.legend();
PLOT_CHECK(legend.rows.size() == 3u);
const f32 pad = cfg.pad;
const f32 row_h = legend_row_height(cfg, scene.font);
const f32 row_gap = cfg.row_gap;
const f32 widest = std::max({scene.font.measure("sine"), scene.font.measure("noise floor"),
scene.font.measure("ramp")});
// Anchored in the corner `place` names (top right by default), one `margin` in
// from the two edges that corner is near...
PLOT_CHECK_NEAR(legend.panel.right(), l.axes.right() - cfg.margin, 1e-4);
PLOT_CHECK_NEAR(legend.panel.y, l.axes.y + cfg.margin, 1e-4);
// ... and measured, not guessed: a padding, the swatch, a gutter and the widest
// name across; a padding, the rows and the gaps between them down.
PLOT_CHECK_NEAR(legend.panel.w, 2.0f * pad + cfg.swatch_w + pad + widest, 1e-4);
PLOT_CHECK_NEAR(legend.panel.h, 2.0f * pad + 3.0f * row_h + 2.0f * row_gap, 1e-4);
// Every row in trace order, one row gap below the last, with the boxes the
// renderer stages from already placed -- no arithmetic left for a caller.
const f32 name_x = legend.panel.x + pad + cfg.swatch_w + pad;
f32 row_y = legend.panel.y + pad;
for (u32 i = 0; i < legend.rows.size(); ++i) {
const plot::LegendRow& row = legend.rows[i];
PLOT_CHECK(row.trace == i);
PLOT_CHECK(!row.ellipsis);
PLOT_CHECK_NEAR(row.swatch.x, legend.panel.x + pad, 1e-4);
PLOT_CHECK_NEAR(row.swatch.y, row_y + 0.5f * (row_h - cfg.swatch_h), 1e-4);
PLOT_CHECK_NEAR(row.swatch.w, cfg.swatch_w, 1e-4);
PLOT_CHECK_NEAR(row.swatch.h, cfg.swatch_h, 1e-4);
PLOT_CHECK_NEAR(row.name.x, name_x, 1e-4);
PLOT_CHECK_NEAR(row.name.y, row_y + 0.5f * (row_h - scene.font.height()), 1e-4);
PLOT_CHECK_NEAR(row.name.h, scene.font.height(), 1e-4);
// Both boxes are inside the panel, swatch to panel edge and name to the padding
// the panel reserved for it.
PLOT_CHECK(row.swatch.x >= legend.panel.x);
PLOT_CHECK(row.swatch.right() <= legend.panel.right());
PLOT_CHECK(row.name.right() <= legend.panel.right() - pad + 1e-4f);
row_y += row_h + row_gap;
}
// The wrapper property, stated as the number it is: the widest row's name ends
// exactly one padding short of the panel's edge, which is what "the panel is
// sized to the widest name" means. A panel sized to another row's name would fail
// this by that row's difference in width.
PLOT_CHECK_NEAR(legend.rows[1].name.w, widest, 1e-4);
PLOT_CHECK_NEAR(legend.rows[1].name.right() + pad, legend.panel.right(), 1e-4);
}
/// A legend is inside `axes` in every corner, held off the two edges its corner is
/// near by `margin` and off the other two by its own measured size. The clamp is
/// what stops a legend from being drawn over the tick labels or the data the plot
/// reserved no room for -- it is inside the data area by construction, since the fit
/// above is measured against exactly this inset.
PLOT_TEST(legend_is_clamped_inside_the_axes) {
LegendScene scene;
const plot::LegendPlace places[4] = {plot::LegendPlace::TopLeft, plot::LegendPlace::TopRight,
plot::LegendPlace::BottomLeft,
plot::LegendPlace::BottomRight};
for (const plot::LegendPlace place : places) {
plot::Legend cfg = scene.plot.legend();
cfg.place = place;
scene.plot.set_legend(cfg);
const plot::PlotLayout& l = scene.plot.layout();
const plot::Rect& panel = l.legend.panel;
PLOT_CHECK(!l.legend.empty());
// Inside the data area: half-open containment, so the far edges compare directly.
PLOT_CHECK(panel.x >= l.axes.x);
PLOT_CHECK(panel.y >= l.axes.y);
PLOT_CHECK(panel.right() <= l.axes.right());
PLOT_CHECK(panel.bottom() <= l.axes.bottom());
const bool near_right = place == plot::LegendPlace::TopRight ||
place == plot::LegendPlace::BottomRight;
const bool near_bottom = place == plot::LegendPlace::BottomLeft ||
place == plot::LegendPlace::BottomRight;
PLOT_CHECK_NEAR(near_right ? panel.right() : panel.x,
near_right ? l.axes.right() - cfg.margin : l.axes.x + cfg.margin, 1e-4);
PLOT_CHECK_NEAR(near_bottom ? panel.bottom() : panel.y,
near_bottom ? l.axes.bottom() - cfg.margin : l.axes.y + cfg.margin, 1e-4);
}
}
/// Entries that do not fit are dropped from the end and the panel says so with a
/// final marker row; a plot with no room for even one entry and its marker gets no
/// legend at all rather than a panel over the axes. Both halves are the same rule
/// read in opposite directions, so they are asserted together.
PLOT_TEST(legend_drops_overflowing_entries) {
plot::render::EmbeddedMonoFont font;
plot::data::ArraySeries series(ramp(64), 0.0, 1.0);
plot::Plot p;
p.set_text_backend(&font);
p.set_viewport({0.0f, 0.0f, 400.0f, 260.0f});
const u32 ax = p.add_x_axis({"t", {0.0, 63.0}, false, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {0.0, 63.0}, false, plot::AxisPlace::Left, false});
constexpr u32 kNamed = 20;
for (u32 i = 0; i < kNamed; ++i) p.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, ax, ay,
"trace " + std::to_string(i)});
const plot::PlotLayout& l = p.layout();
const plot::LegendLayout& legend = l.legend;
const plot::Legend& cfg = p.legend();
const f32 pad = cfg.pad;
const f32 row_h = legend_row_height(cfg, font);
// Re-derive the rule rather than assert a coincidence: the largest number of kept
// entries whose panel -- with the marker row it needs once anything was dropped --
// still fits the inset box. `k + 1` rows is the truncated case, `k` the untruncated
// one; both are tried from the top down, exactly as the layout does.
const f32 room_h = l.axes.h - 2.0f * cfg.margin;
u32 expected = 0;
for (u32 k = kNamed; k > 0u; --k) {
const bool truncated = k < kNamed;
const f32 rows = static_cast<f32>(k + (truncated ? 1u : 0u));
if (2.0f * pad + rows * row_h + (rows - 1.0f) * cfg.row_gap <= room_h) {
expected = k;
break;
}
}
// The scene has to be one where the rule actually bites, or the case proves
// nothing about dropping entries.
PLOT_CHECK(expected != 0u);
PLOT_CHECK(expected < kNamed);
PLOT_CHECK(legend.rows.size() == expected + 1u);
// The kept entries are the first `expected` ones, in trace order: entries are
// dropped from the end, never from the middle.
for (u32 i = 0; i < expected; ++i) {
PLOT_CHECK(!legend.rows[i].ellipsis);
PLOT_CHECK(legend.rows[i].trace == i);
}
const plot::LegendRow& marker = legend.rows.back();
PLOT_CHECK(marker.ellipsis);
PLOT_CHECK(marker.name.x == legend.rows[0].name.x); // the marker lines up with the names
PLOT_CHECK_NEAR(marker.name.w, font.measure(plot::kLegendEllipsis), 1e-4);
PLOT_CHECK(marker.swatch.w == 0.0f && marker.swatch.h == 0.0f); // no trace, no swatch
// The panel with the marker is inside `axes`, which is the property the drop
// exists to keep: a panel that overhung the plot would be worse than a short list.
PLOT_CHECK(legend.panel.x >= l.axes.x && legend.panel.right() <= l.axes.right());
PLOT_CHECK(legend.panel.y >= l.axes.y && legend.panel.bottom() <= l.axes.bottom());
// Not even one entry plus the marker fits: no legend at all, and nothing drawn.
plot::Plot tiny;
tiny.set_text_backend(&font);
tiny.set_viewport({0.0f, 0.0f, 120.0f, 60.0f});
const u32 tax = tiny.add_x_axis({"t", {0.0, 63.0}, false, plot::AxisPlace::Bottom, false});
const u32 tay = tiny.add_y_axis({"v", {0.0, 63.0}, false, plot::AxisPlace::Left, false});
tiny.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, tax, tay, "sine"});
const plot::PlotLayout& tl = tiny.layout();
PLOT_CHECK(tl.legend.empty());
PLOT_CHECK_NEAR(tl.legend.panel.w, 0.0f, 1e-6);
PLOT_CHECK_NEAR(tl.legend.panel.h, 0.0f, 1e-6);
}
/// A trace with no name is not an entry, and neither is a trace with no series to
/// draw -- the same "visible" test every other consumer of a trace makes. This is
/// the whole cost of the feature for a caller that never names a trace: nothing.
PLOT_TEST(unnamed_traces_produce_no_legend) {
plot::render::EmbeddedMonoFont font;
plot::data::ArraySeries series(ramp(64), 0.0, 1.0);
plot::Plot p;
p.set_text_backend(&font);
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ax = p.add_x_axis({"t", {0.0, 63.0}, false, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {0.0, 63.0}, false, plot::AxisPlace::Left, false});
p.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, ax, ay}); // no name
p.add_trace({nullptr, plot::Color::hex(0xFFB020), 1.5f, ax, ay, "ghost"}); // named, no series
p.add_trace({&series, plot::Color::hex(0x00E070), 1.5f, ax, ay, "sig"});
const plot::PlotLayout& l = p.layout();
PLOT_CHECK(l.legend.rows.size() == 1u);
PLOT_CHECK(l.legend.rows[0].trace == 2u); // the named, drawable one, by its own id
PLOT_CHECK(!l.legend.rows[0].ellipsis);
// Without the one name, the layout is empty and zero-sized: no panel, no rows.
plot::Plot unnamed;
unnamed.set_text_backend(&font);
unnamed.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 uax = unnamed.add_x_axis({"t", {0.0, 63.0}, false, plot::AxisPlace::Bottom, false});
const u32 uay = unnamed.add_y_axis({"v", {0.0, 63.0}, false, plot::AxisPlace::Left, false});
unnamed.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, uax, uay});
unnamed.add_trace({&series, plot::Color::hex(0xFFB020), 1.5f, uax, uay});
const plot::LegendLayout& none = unnamed.layout().legend;
PLOT_CHECK(none.empty());
PLOT_CHECK_NEAR(none.panel.w, 0.0f, 1e-6);
PLOT_CHECK_NEAR(none.panel.h, 0.0f, 1e-6);
}
/// A plot has no font of its own, so a legend with nothing to measure a name with is
/// no legend: the plot keeps the defaults it laid out with before legends existed.
PLOT_TEST(no_text_backend_produces_no_legend) {
plot::data::ArraySeries series(ramp(64), 0.0, 1.0);
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ax = p.add_x_axis({"t", {0.0, 63.0}, false, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {0.0, 63.0}, false, plot::AxisPlace::Left, false});
p.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, ax, ay, "sine"});
const plot::PlotLayout& l = p.layout();
PLOT_CHECK(p.text_backend() == nullptr);
PLOT_CHECK(l.legend.empty());
PLOT_CHECK_NEAR(l.legend.panel.w, 0.0f, 1e-6);
PLOT_CHECK_NEAR(l.legend.panel.h, 0.0f, 1e-6);
// Nothing else moved either: the strips are the no-backend defaults, not metrics a
// legend could not measure.
PLOT_CHECK_NEAR(l.y_axis_width, 56.0f, 1e-6);
PLOT_CHECK_NEAR(l.x_axis_height, 22.0f, 1e-6);
}
/// Switching the legend off is not the same as the plot having nothing to list: the
/// entries exist, the layout is empty, and -- the reason it matters -- the axes rect
/// is *identical*, because a legend is drawn inside the data area and reserves none
/// of it. A legend that changed `axes` would rescale every plot the moment a trace
/// was named.
PLOT_TEST(legend_hidden_by_visible_false_draws_nothing) {
LegendScene scene;
const plot::Rect shown_axes = scene.plot.layout().axes;
PLOT_CHECK(!scene.plot.layout().legend.empty());
plot::Legend cfg = scene.plot.legend();
cfg.visible = false;
scene.plot.set_legend(cfg);
const plot::PlotLayout& l = scene.plot.layout();
PLOT_CHECK(l.legend.empty());
PLOT_CHECK_NEAR(l.legend.panel.w, 0.0f, 1e-6);
PLOT_CHECK_NEAR(l.legend.panel.h, 0.0f, 1e-6);
PLOT_CHECK_NEAR(l.axes.x, shown_axes.x, 1e-6);
PLOT_CHECK_NEAR(l.axes.y, shown_axes.y, 1e-6);
PLOT_CHECK_NEAR(l.axes.w, shown_axes.w, 1e-6);
PLOT_CHECK_NEAR(l.axes.h, shown_axes.h, 1e-6);
// ... and switching it back on lists the same three entries again.
cfg.visible = true;
scene.plot.set_legend(cfg);
PLOT_CHECK(scene.plot.layout().legend.rows.size() == 3u);
}