feat(plot): per-trace legend with measured layout and swatches
This commit is contained in:
1 parent
9dc3b1e5e6
commit
2d51b978d7
9 files changed
+850
-2
No files matched your search
@@ -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
|
||||
+80
-2
@@ -59,6 +59,14 @@ constexpr usize kReadoutChars = 160;
|
||||
constexpr Vec2 kTextRight{1.0f, 0.0f};
|
||||
constexpr Vec2 kTextUp{0.0f, -1.0f};
|
||||
|
||||
/// How much of its alpha the legend's panel keeps. The panel is the frame's own
|
||||
/// background, so at 1.0 it would be a hole in the plot with the traces cut off at
|
||||
/// its edge; at this share the data underneath stays faintly visible and the panel
|
||||
/// reads as something laid *over* the plot -- which is what the border is for. A
|
||||
/// host whose background is already translucent gets a proportionally fainter
|
||||
/// panel, which is the same statement about its own theme.
|
||||
constexpr f32 kLegendPanelAlpha = 0.85f;
|
||||
|
||||
/// Walks the longest prefix of `utf8` that fits `budget_px` along `axis`, calling
|
||||
/// `emit(pen, glyph)` for every glyph it keeps -- `pen` in viewport pixels,
|
||||
/// `glyph` the backend's own entry for that codepoint -- and returning the length
|
||||
@@ -324,8 +332,12 @@ void Renderer::draw(Plot& plot) {
|
||||
// below reads the strips, the axes rect and the transforms it pins.
|
||||
const PlotLayout& layout = plot.layout();
|
||||
|
||||
// Draw order: chrome, then data, then the annotations over it, and the text
|
||||
// last (it is texture-bound, so it is one draw call after every line batch).
|
||||
// Draw order: chrome, then data, then the overlays over it, and the text last (it
|
||||
// is texture-bound, so it is one draw call after every line batch). The legend
|
||||
// closes the line batches: its panel is a backplate, so it is drawn over
|
||||
// everything geometric -- a legend under a trace is a legend nobody can read --
|
||||
// while its names join the frame's single text flush with every other label, which
|
||||
// is what keeps the two-pipeline order the whole frame is built on.
|
||||
draw_grid(plot);
|
||||
draw_axis_lines(plot);
|
||||
draw_tick_labels(plot, layout);
|
||||
@@ -334,6 +346,7 @@ void Renderer::draw(Plot& plot) {
|
||||
draw_cursors(plot, layout);
|
||||
draw_measures(plot, layout);
|
||||
draw_annotations(plot);
|
||||
draw_legend(plot, layout);
|
||||
flush_text();
|
||||
}
|
||||
|
||||
@@ -463,6 +476,25 @@ Vec2 Renderer::to_viewport(Vec2 px) const noexcept {
|
||||
return Vec2{px.x - viewport_.x, px.y - viewport_.y};
|
||||
}
|
||||
|
||||
void Renderer::fill_pixels(const Rect& rect, Color color) {
|
||||
if (!(rect.w > 0.0f) || !(rect.h > 0.0f)) return;
|
||||
// The line pipeline has no filled primitive and does not need one: a stroke's
|
||||
// quad is its perpendicular width, and the fragment shader ramps on that
|
||||
// perpendicular alone -- a stroke is not capped at its ends -- so a *horizontal*
|
||||
// stroke `rect.h` wide from the rect's left edge to its right edge is the rect
|
||||
// itself, opaque everywhere but the half pixel at each horizontal edge where the
|
||||
// ramp lives.
|
||||
//
|
||||
// A fill per pixel column, the way the measurement band fills its interval, is the
|
||||
// other way to read this, and it is the wrong one here: a legend panel is
|
||||
// *measured*, so its edges never land on the pixel grid, and a column fill of a
|
||||
// rect that is half a pixel off lights every one of its columns at 0.8 -- a swatch
|
||||
// that is a washed-out version of the trace it is supposed to stand for. One
|
||||
// instance instead of one per column is the same answer for the cost.
|
||||
lines_.add_segment({rect.x, rect.y + 0.5f * rect.h}, {rect.right(), rect.y + 0.5f * rect.h});
|
||||
flush_lines(pixel_mapping(), color, rect.h);
|
||||
}
|
||||
|
||||
void Renderer::stage_segment(f64 x0, f64 y0, f64 x1, f64 y1, bool log_x, bool log_y) {
|
||||
lines_.add_segment(pre_affine(x0, y0, log_x, log_y), pre_affine(x1, y1, log_x, log_y));
|
||||
}
|
||||
@@ -802,6 +834,52 @@ void Renderer::draw_annotations(const Plot& plot) {
|
||||
}
|
||||
}
|
||||
|
||||
void Renderer::draw_legend(const Plot& plot, const PlotLayout& layout) {
|
||||
const LegendLayout& legend = layout.legend;
|
||||
if (legend.rows.empty()) return;
|
||||
|
||||
const Rect& panel = legend.panel;
|
||||
if (plot.legend().frame) {
|
||||
// The panel first, so every entry is on top of it: the frame's own background
|
||||
// at a partial alpha, which dims whatever it covers instead of erasing it.
|
||||
Color background = style_.background;
|
||||
background.a *= kLegendPanelAlpha;
|
||||
fill_pixels(panel, background);
|
||||
// The border frames the pixels the fill covers, not the measured rect's own
|
||||
// fractional edges: those are wherever the names happened to measure to, and a
|
||||
// hairline drawn on one is lit half-heartedly -- a frame bright on three sides and
|
||||
// dim on the fourth reads as a mistake. These are the outermost lit columns and
|
||||
// rows the fill's span rounds out to (the same rounding the fill itself does), so
|
||||
// every side of the frame is one crisp hairline.
|
||||
const f32 left = std::ceil(panel.x - 0.5f) + 0.5f;
|
||||
const f32 right = std::ceil(panel.right() - 0.5f) - 0.5f;
|
||||
const f32 top = std::ceil(panel.y - 0.5f) + 0.5f;
|
||||
const f32 bottom = std::ceil(panel.bottom() - 0.5f) - 0.5f;
|
||||
lines_.add_segment({left, top}, {right, top});
|
||||
lines_.add_segment({left, bottom}, {right, bottom});
|
||||
lines_.add_segment({left, top}, {left, bottom});
|
||||
lines_.add_segment({right, top}, {right, bottom});
|
||||
flush_lines(pixel_mapping(), style_.axis_line, 1.0f);
|
||||
}
|
||||
|
||||
for (const LegendRow& row : legend.rows) {
|
||||
// The truncation row names no trace: it has no swatch to colour and its text is
|
||||
// the marker itself, so both branches read their string from one place -- the
|
||||
// layout measured it, this stages it.
|
||||
if (row.ellipsis) {
|
||||
stage_text({row.name.x, row.name.y}, kLegendEllipsis);
|
||||
continue;
|
||||
}
|
||||
// Opaque whatever the trace's own alpha is: a swatch drawn at a trace's 15% would
|
||||
// be the least legible thing in the frame, and the swatch is the one element that
|
||||
// says which colour a legend entry *is*.
|
||||
Color swatch = plot.trace(row.trace).color;
|
||||
swatch.a = 1.0f;
|
||||
fill_pixels(row.swatch, swatch);
|
||||
stage_text({row.name.x, row.name.y}, plot.trace(row.trace).name.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- dump
|
||||
|
||||
bool Renderer::dump_frame_ppm(const char* path, Status* st) const {
|
||||
|
||||
Reference in new issue
Block a user