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
@@ -50,6 +50,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/plot.hpp>
|
||||
#include <plot/plot/ticks.hpp>
|
||||
|
||||
@@ -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
|
||||
@@ -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_;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -101,6 +101,13 @@ class Renderer {
|
||||
/// `Plot::layout()` is computed exactly once, here, and handed to every helper:
|
||||
/// it runs the auto scale, the strip fixed-point loop and the grid build, so
|
||||
/// re-deriving it per helper would rebuild the ticks and the grid per helper.
|
||||
///
|
||||
/// A legend the plot has is part of the frame: the panel and its swatches are
|
||||
/// line geometry staged with everything else, the names are glyphs on the text
|
||||
/// path, and both read the boxes `PlotLayout::legend` measured. It is the last
|
||||
/// line batch, so the legend cannot be drawn under a trace, and it leaves no GL
|
||||
/// state of its own -- the frame's blend and its neighbours' scissor already say
|
||||
/// everything a fill and a hairline border need (see end_frame's residue list).
|
||||
void draw(Plot& plot);
|
||||
|
||||
/// Ends the frame, draining `glGetError()` in a loop so a stale error cannot be
|
||||
@@ -254,6 +261,14 @@ class Renderer {
|
||||
/// is texture-bound, so this runs once per frame, after all line geometry.
|
||||
void flush_text() noexcept;
|
||||
|
||||
/// Fills `rect` -- in framebuffer pixels, like `Rect` -- with `color`, in one
|
||||
/// instance: a horizontal stroke whose width is the rect's height *is* the rect,
|
||||
/// because a stroke's quad is its perpendicular width and the fragment shader
|
||||
/// ramps on that perpendicular alone -- the ends are not capped (see
|
||||
/// `fill_pixels()` in the implementation for why a per-column fill, the way the
|
||||
/// measurement band fills, is the wrong primitive here). A rect with no width or
|
||||
/// no height stages nothing.
|
||||
void fill_pixels(const Rect& rect, Color color);
|
||||
/// Stages one data-space segment, taking the two axes' log flags into account.
|
||||
void stage_segment(f64 x0, f64 y0, f64 x1, f64 y1, bool log_x, bool log_y);
|
||||
/// Stages `n` interleaved `x,y` data-space points (a gathered window).
|
||||
@@ -290,6 +305,11 @@ class Renderer {
|
||||
void draw_cursors(const Plot& plot, const PlotLayout& layout);
|
||||
void draw_measures(const Plot& plot, const PlotLayout& layout);
|
||||
void draw_annotations(const Plot& plot);
|
||||
/// The legend: the panel and its swatches as line geometry, the names on the text
|
||||
/// path. It reads the boxes `Plot::layout()` measured rather than deriving any of
|
||||
/// them again, so the panel a test asserts and the panel this draws are the same
|
||||
/// numbers.
|
||||
void draw_legend(const Plot& plot, const PlotLayout& layout);
|
||||
|
||||
/// The built-in backend the renderer owns. Every use of it goes through the
|
||||
/// `TextBackend` interface -- `self`-typed nowhere -- so swapping it for
|
||||
|
||||
Reference in new issue
Block a user