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

+98
View File
@@ -106,6 +106,7 @@ u32 Plot::add_trace(const TraceDesc& desc) {
trace.width = desc.width;
trace.axis_x = desc.axis_x;
trace.axis_y = desc.axis_y;
trace.name = desc.name;
traces_.push_back(trace);
mark_measures_dirty();
return static_cast<u32>(traces_.size() - 1);
@@ -388,6 +389,10 @@ const PlotLayout& Plot::layout() {
}
build_grid();
// The legend is measured last, from the finished `axes`: it lives inside the data
// area and reserves nothing, so it can neither move the strips above nor depend on
// anything but the rect they settled on.
build_legend();
return layout_;
}
@@ -461,4 +466,97 @@ f32 Plot::y_label_width() const {
return widest + 2.0f * style_.grid_label_pad;
}
void Plot::build_legend() {
LegendLayout& legend = layout_.legend;
legend.rows.clear();
legend.panel = Rect{};
// Nothing to measure with, or nothing asked for: an empty layout, and with it a
// plot that lays out exactly as it did before legends existed.
if (text_ == nullptr || !legend_.visible) return;
const f32 pad = legend_.pad;
const f32 line_h = text_->height();
const f32 row_h = std::max(legend_.swatch_h, line_h);
// Entry widths, prefix-maxed: dropping from the end then costs no re-measure, and
// the panel's width after a drop is the width of an entry that is still listed.
legend_name_px_.clear();
for (const Trace& trace : traces_) {
// A trace with no series, no samples, or no name is not an entry -- the same
// "visible" test every other consumer of a trace makes, plus the name.
if (!trace.visible() || trace.name.empty()) continue;
const f32 width = text_->measure(trace.name.c_str());
legend_name_px_.push_back(legend_name_px_.empty() ? width
: std::max(legend_name_px_.back(), width));
}
const u32 entries = static_cast<u32>(legend_name_px_.size());
if (entries == 0u) return;
const f32 ellipsis_w = text_->measure(kLegendEllipsis);
const f32 room_w = layout_.axes.w - 2.0f * legend_.margin;
const f32 room_h = layout_.axes.h - 2.0f * legend_.margin;
// Fit, from every entry down: the panel is a row per entry with `row_gap` between,
// and, once an entry has been dropped, one more row for the marker that says so.
// The marker is part of the panel, so it is part of what has to fit -- a legend
// that dropped entries and then overhung the axes would be the defect the drop
// exists to avoid. When even one entry plus the marker has no room, there is no
// panel at all: a plot's data area is not a place to draw a legend that does not
// fit it.
u32 keep = entries;
f32 panel_w = 0.0f;
f32 panel_h = 0.0f;
for (;;) {
const bool truncated = keep < entries;
const f32 widest = truncated ? std::max(legend_name_px_[keep - 1u], ellipsis_w)
: legend_name_px_[keep - 1u];
const f32 rows = static_cast<f32>(keep + (truncated ? 1u : 0u));
panel_w = 2.0f * pad + legend_.swatch_w + pad + widest;
panel_h = 2.0f * pad + rows * row_h + (rows - 1.0f) * legend_.row_gap;
if (panel_w <= room_w && panel_h <= room_h) break;
if (--keep == 0u) return; // not even one entry and its marker fit
}
// Anchored in the corner `place` names and held `margin` off both edges, which is
// the inset the fit above was measured against -- so the panel is inside `axes` by
// construction and can never cross it, whatever corner it was put in.
f32 x = layout_.axes.x + legend_.margin;
f32 y = layout_.axes.y + legend_.margin;
if (legend_.place == LegendPlace::TopRight || legend_.place == LegendPlace::BottomRight) {
x = layout_.axes.right() - legend_.margin - panel_w;
}
if (legend_.place == LegendPlace::BottomLeft || legend_.place == LegendPlace::BottomRight) {
y = layout_.axes.bottom() - legend_.margin - panel_h;
}
legend.panel = Rect{x, y, panel_w, panel_h};
// The rows themselves: the swatch and the name's line box, both placed here rather
// than left to a renderer to re-derive (one placement rule, one place to read it).
// The name box starts one swatch and one gutter in -- exactly what the panel's
// width allowed for -- and both boxes are centred in the row's height, so a swatch
// shorter than the text line is centred on it instead of sitting on its baseline.
const f32 name_x = x + pad + legend_.swatch_w + pad;
f32 row_y = y + pad;
for (u32 i = 0; i < traces_.size() && legend.rows.size() < keep; ++i) {
const Trace& trace = traces_[i];
if (!trace.visible() || trace.name.empty()) continue;
LegendRow row;
row.trace = i;
row.swatch = Rect{x + pad, row_y + 0.5f * (row_h - legend_.swatch_h), legend_.swatch_w,
legend_.swatch_h};
row.name = Rect{name_x, row_y + 0.5f * (row_h - line_h),
text_->measure(trace.name.c_str()), line_h};
legend.rows.push_back(row);
row_y += row_h + legend_.row_gap;
}
if (keep < entries) {
// Entries were dropped; the marker takes the row after the last kept one, with no
// swatch -- there is no trace to colour -- and the name column's own x, so it
// lines up with the names above it.
LegendRow marker;
marker.ellipsis = true;
marker.name = Rect{name_x, row_y + 0.5f * (row_h - line_h), ellipsis_w, line_h};
legend.rows.push_back(marker);
}
}
} // namespace plot