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

+107
View File
@@ -0,0 +1,107 @@
#pragma once
#include <vector>
#include <plot/core/rect.hpp>
#include <plot/core/types.hpp>
namespace plot {
/// Which corner of `axes` the legend panel is anchored to.
///
/// There is no "outside the plot" placement: the strips around `axes` are the tick
/// labels' and the axis titles', and a legend that reserved space would resize the
/// plot the moment a caller named a trace. The panel is drawn inside the data area,
/// over the traces, which is why it spends `Legend::margin` keeping off the axes'
/// edges.
enum class LegendPlace { TopLeft, TopRight, BottomLeft, BottomRight };
/// The legend's configuration, read by `Plot::layout()` every time it runs.
///
/// All of it is deliberately coarse: the size, the row heights and the panel's
/// extent come from the text backend's metrics and the names themselves, never from
/// a caller's arithmetic, so a legend can only be *placed* and never mis-measured.
struct Legend {
/// Draw the legend at all. This is on by default and costs nothing to leave on:
/// a legend with no entries -- no trace named, or no backend to measure a name
/// with -- draws nothing and lays out nothing, so the feature is free for a
/// caller who never names a trace.
bool visible = true;
LegendPlace place = LegendPlace::TopRight;
/// Paint the panel: the frame's own `Style::background` at a partial alpha, with a
/// one-pixel border. Off, the entries float on the data with nothing between them
/// and a dense trace.
bool frame = true;
/// Inset from the panel's edge to its first row, and the gutter between a row's
/// swatch and its name. One knob for both, so the panel's interior spacing is one
/// number rather than two.
f32 pad = 6.0f;
/// The swatch's width, and the height it is drawn at whatever the text line's is:
/// `max(swatch_h, line height)` is the row's height, so a small swatch is centred
/// in the line rather than stretching it.
f32 swatch_w = 14.0f;
f32 swatch_h = 8.0f;
/// Blank space between two rows.
f32 row_gap = 3.0f;
/// How far the panel is held back from the `axes` edge. It is the anchor's inset
/// *and* the budget the panel has to fit in, which is what makes "never crosses
/// `axes`" a property of one number instead of two that can disagree: a panel that
/// does not fit inside `axes` inset by this drops entries rather than overhanging
/// the plot (see `LegendLayout`).
f32 margin = 8.0f;
};
/// One legend entry: which trace it names, and the two boxes it is drawn in.
///
/// The boxes are the *layout's* output, not a renderer's input to be re-derived:
/// `Plot::layout()` places the swatch and the name's pen, and the renderer stages
/// exactly those pixels. A renderer that computed either from the panel rect would
/// be a second, independent copy of the placement rule -- which is the bug the
/// measured layout exists to prevent -- so nothing here needs arithmetic after the
/// fact.
struct LegendRow {
/// Index of the trace this row names: `Plot::trace(trace)` is what the row's
/// swatch is coloured by. The filter that chose the row (visible, named) is not
/// repeated by the renderer. Not meaningful on the truncation row.
u32 trace = 0;
/// This row is the truncation marker rather than a trace: entries did not fit and
/// the panel ends with `kLegendEllipsis` in place of the ones it dropped. Always
/// the last row, and its `swatch` is empty -- there is no trace to colour.
bool ellipsis = false;
/// The swatch's box, `Legend::swatch_w` x `Legend::swatch_h`, centred in the row.
/// Empty on the truncation row; the row's name box starts where the other rows'
/// names do either way, so the marker lines up with the names above it.
Rect swatch;
/// The name's line box: its top-left is the pen the run is staged at and its
/// height is one text line, so the box *is* the placement. Its width is what the
/// backend measured for this row's own string (the truncation row measures
/// `kLegendEllipsis`).
Rect name;
};
/// The measured legend: the panel to paint and the rows to draw in it, in the same
/// pixel space as `PlotLayout`, which `Plot::layout()` fills alongside its own
/// rects.
///
/// It is a member of `PlotLayout` rather than a second call so the numbers a test
/// asserts and the numbers the renderer draws are one set: a panel rect, and one
/// entry per row with the trace and both boxes. Everything is zero/empty when there
/// is nothing to draw -- no backend, no named visible trace, or no room for even one
/// entry and the truncation marker -- and an empty legend is what makes a plot that
/// never names a trace lay out byte-for-byte as it did before legends existed.
struct LegendLayout {
/// The panel, always inside `PlotLayout::axes`. Zero-sized when there is nothing
/// to draw.
Rect panel;
/// The rows to draw, in trace order, truncated from the end when they did not fit.
std::vector<LegendRow> rows;
bool empty() const noexcept { return rows.empty(); }
};
/// The last row of a truncated legend. ASCII, and one constant for both sides of the
/// feature, because the embedded atlas has no U+2026: the three-dot form is the one
/// that draws, and the layout measures this string while the renderer stages it, so
/// the two cannot disagree about how wide the row is.
inline constexpr const char* kLegendEllipsis = "...";
} // namespace plot
+31
View File
@@ -8,6 +8,7 @@
#include <plot/plot/axis.hpp>
#include <plot/plot/cursor.hpp>
#include <plot/plot/grid.hpp>
#include <plot/plot/legend.hpp>
#include <plot/plot/measure.hpp>
#include <plot/plot/trace.hpp>
#include <plot/render/style.hpp>
@@ -50,6 +51,12 @@ struct PlotLayout {
/// no text backend there is no title to lay out at all and both stay 0.0f.
f32 y_title_width = 0.0f;
f32 x_title_height = 0.0f;
/// The measured legend: inside `axes`, never reserving any part of it, and empty
/// when there is nothing to draw. It is measured with the same text backend the
/// strips above are, and against the same `axes` rect the caller reads back, so
/// the panel a test asserts and the panel the renderer paints are one set of
/// numbers rather than two derivations of one rule (see LegendLayout).
LegendLayout legend;
};
/// The public façade: axes, traces, cursors, annotations and their layout.
@@ -221,6 +228,17 @@ class Plot {
/// Grid segments for the primary axis pair (x axis 0, y axis 0), in data space.
const Grid& grid() const noexcept { return grid_; }
// --- legend -----------------------------------------------------------
/// The legend's configuration. A legend is on unless a caller turns it off and
/// costs nothing until a trace is named: with no named visible trace -- or no text
/// backend to measure a name with -- `layout()` produces an empty layout and
/// nothing is drawn.
const Legend& legend() const noexcept { return legend_; }
/// Replaces the configuration. The next `layout()` measures the panel with it, so
/// a caller can move the legend -- or switch it off -- between frames.
void set_legend(const Legend& legend) noexcept { legend_ = legend; }
private:
/// One measure plus its cache. The dirty flag is what keeps measurement() from
/// re-evaluating every frame; `last_series`, `last_size` and `last_x0` catch a
@@ -263,10 +281,17 @@ class Plot {
/// sides: the column the rotated y title is drawn in, one column per titled
/// axis. Zero with no backend or no titled y axis.
f32 y_title_strip_width() const;
/// Measures the legend into `layout_.legend`: which traces have an entry, how wide
/// the panel has to be, where it is anchored, and one row per entry it can fit.
/// Zeroes the layout and returns when there is nothing to measure with or nothing
/// to list -- an empty legend is what keeps the feature free for a caller who
/// never names a trace.
void build_legend();
Rect viewport_{};
render::Style style_{};
const render::TextBackend* text_ = nullptr;
Legend legend_{};
std::vector<Axis> x_axes_;
std::vector<Axis> y_axes_;
@@ -274,6 +299,12 @@ class Plot {
std::vector<Cursor> cursors_;
std::vector<MeasureSlot> measures_;
std::vector<Annotation> annotations_;
/// Scratch for build_legend(): the name width of every trace that has an entry, in
/// trace order and prefix-maxed in place, so the fit loop reads the width a given
/// number of kept entries needs in O(1) instead of re-measuring the names it is
/// about to drop. A member rather than a local, so a steady-state frame's legend
/// build allocates nothing.
std::vector<f32> legend_name_px_;
PlotLayout layout_{};
Grid grid_;
+12
View File
@@ -2,6 +2,7 @@
#include <plot/core/color.hpp>
#include <plot/core/types.hpp>
#include <plot/data/series.hpp>
#include <string>
namespace plot {
@@ -12,6 +13,13 @@ struct TraceDesc {
Color color = Color::hex(0x4FA3FF);
f32 width = 1.5f;
u32 axis_x = 0, axis_y = 0;
/// What the trace is called, for the legend. Empty means "do not list it", which
/// is the whole cost of a legend for a caller that never names one: the trailing
/// default is what keeps the five-field aggregate initialisation every existing
/// caller writes -- `{&series, colour, width, axis_x, axis_y}` -- working
/// unchanged, exactly as `AxisDesc`'s trailing format does (and, in a build with
/// `-Wextra`, warning-clean).
std::string name{};
};
/// A series bound to a colour, width and axis pair. The series pointer may be
@@ -22,6 +30,10 @@ struct Trace {
Color color = Color::hex(0x4FA3FF);
f32 width = 1.5f;
u32 axis_x = 0, axis_y = 0;
/// The legend's entry for this trace, empty when it has none. A name does not
/// make a trace drawable: the legend lists a trace only when it is visible, so a
/// null series is skipped there exactly as it is by every other consumer.
std::string name{};
/// True when the trace has a series with at least one sample to draw.
bool visible() const noexcept { return series != nullptr && series->size() > 0; }