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/axis.hpp>
|
||||||
#include <plot/plot/cursor.hpp>
|
#include <plot/plot/cursor.hpp>
|
||||||
#include <plot/plot/grid.hpp>
|
#include <plot/plot/grid.hpp>
|
||||||
|
#include <plot/plot/legend.hpp>
|
||||||
#include <plot/plot/measure.hpp>
|
#include <plot/plot/measure.hpp>
|
||||||
#include <plot/plot/plot.hpp>
|
#include <plot/plot/plot.hpp>
|
||||||
#include <plot/plot/ticks.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/axis.hpp>
|
||||||
#include <plot/plot/cursor.hpp>
|
#include <plot/plot/cursor.hpp>
|
||||||
#include <plot/plot/grid.hpp>
|
#include <plot/plot/grid.hpp>
|
||||||
|
#include <plot/plot/legend.hpp>
|
||||||
#include <plot/plot/measure.hpp>
|
#include <plot/plot/measure.hpp>
|
||||||
#include <plot/plot/trace.hpp>
|
#include <plot/plot/trace.hpp>
|
||||||
#include <plot/render/style.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.
|
/// no text backend there is no title to lay out at all and both stay 0.0f.
|
||||||
f32 y_title_width = 0.0f;
|
f32 y_title_width = 0.0f;
|
||||||
f32 x_title_height = 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.
|
/// 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.
|
/// Grid segments for the primary axis pair (x axis 0, y axis 0), in data space.
|
||||||
const Grid& grid() const noexcept { return grid_; }
|
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:
|
private:
|
||||||
/// One measure plus its cache. The dirty flag is what keeps measurement() from
|
/// 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
|
/// 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
|
/// sides: the column the rotated y title is drawn in, one column per titled
|
||||||
/// axis. Zero with no backend or no titled y axis.
|
/// axis. Zero with no backend or no titled y axis.
|
||||||
f32 y_title_strip_width() const;
|
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_{};
|
Rect viewport_{};
|
||||||
render::Style style_{};
|
render::Style style_{};
|
||||||
const render::TextBackend* text_ = nullptr;
|
const render::TextBackend* text_ = nullptr;
|
||||||
|
Legend legend_{};
|
||||||
|
|
||||||
std::vector<Axis> x_axes_;
|
std::vector<Axis> x_axes_;
|
||||||
std::vector<Axis> y_axes_;
|
std::vector<Axis> y_axes_;
|
||||||
@@ -274,6 +299,12 @@ class Plot {
|
|||||||
std::vector<Cursor> cursors_;
|
std::vector<Cursor> cursors_;
|
||||||
std::vector<MeasureSlot> measures_;
|
std::vector<MeasureSlot> measures_;
|
||||||
std::vector<Annotation> annotations_;
|
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_{};
|
PlotLayout layout_{};
|
||||||
Grid grid_;
|
Grid grid_;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
#include <plot/core/color.hpp>
|
#include <plot/core/color.hpp>
|
||||||
#include <plot/core/types.hpp>
|
#include <plot/core/types.hpp>
|
||||||
#include <plot/data/series.hpp>
|
#include <plot/data/series.hpp>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
namespace plot {
|
namespace plot {
|
||||||
|
|
||||||
@@ -12,6 +13,13 @@ struct TraceDesc {
|
|||||||
Color color = Color::hex(0x4FA3FF);
|
Color color = Color::hex(0x4FA3FF);
|
||||||
f32 width = 1.5f;
|
f32 width = 1.5f;
|
||||||
u32 axis_x = 0, axis_y = 0;
|
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
|
/// 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);
|
Color color = Color::hex(0x4FA3FF);
|
||||||
f32 width = 1.5f;
|
f32 width = 1.5f;
|
||||||
u32 axis_x = 0, axis_y = 0;
|
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.
|
/// True when the trace has a series with at least one sample to draw.
|
||||||
bool visible() const noexcept { return series != nullptr && series->size() > 0; }
|
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:
|
/// `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
|
/// 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.
|
/// 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);
|
void draw(Plot& plot);
|
||||||
|
|
||||||
/// Ends the frame, draining `glGetError()` in a loop so a stale error cannot be
|
/// 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.
|
/// is texture-bound, so this runs once per frame, after all line geometry.
|
||||||
void flush_text() noexcept;
|
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.
|
/// 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);
|
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).
|
/// 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_cursors(const Plot& plot, const PlotLayout& layout);
|
||||||
void draw_measures(const Plot& plot, const PlotLayout& layout);
|
void draw_measures(const Plot& plot, const PlotLayout& layout);
|
||||||
void draw_annotations(const Plot& plot);
|
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
|
/// The built-in backend the renderer owns. Every use of it goes through the
|
||||||
/// `TextBackend` interface -- `self`-typed nowhere -- so swapping it for
|
/// `TextBackend` interface -- `self`-typed nowhere -- so swapping it for
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ u32 Plot::add_trace(const TraceDesc& desc) {
|
|||||||
trace.width = desc.width;
|
trace.width = desc.width;
|
||||||
trace.axis_x = desc.axis_x;
|
trace.axis_x = desc.axis_x;
|
||||||
trace.axis_y = desc.axis_y;
|
trace.axis_y = desc.axis_y;
|
||||||
|
trace.name = desc.name;
|
||||||
traces_.push_back(trace);
|
traces_.push_back(trace);
|
||||||
mark_measures_dirty();
|
mark_measures_dirty();
|
||||||
return static_cast<u32>(traces_.size() - 1);
|
return static_cast<u32>(traces_.size() - 1);
|
||||||
@@ -388,6 +389,10 @@ const PlotLayout& Plot::layout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
build_grid();
|
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_;
|
return layout_;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,4 +466,97 @@ f32 Plot::y_label_width() const {
|
|||||||
return widest + 2.0f * style_.grid_label_pad;
|
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
|
} // namespace plot
|
||||||
+80
-2
@@ -59,6 +59,14 @@ constexpr usize kReadoutChars = 160;
|
|||||||
constexpr Vec2 kTextRight{1.0f, 0.0f};
|
constexpr Vec2 kTextRight{1.0f, 0.0f};
|
||||||
constexpr Vec2 kTextUp{0.0f, -1.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
|
/// 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,
|
/// `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
|
/// `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.
|
// below reads the strips, the axes rect and the transforms it pins.
|
||||||
const PlotLayout& layout = plot.layout();
|
const PlotLayout& layout = plot.layout();
|
||||||
|
|
||||||
// Draw order: chrome, then data, then the annotations over it, and the text
|
// Draw order: chrome, then data, then the overlays over it, and the text last (it
|
||||||
// last (it is texture-bound, so it is one draw call after every line batch).
|
// 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_grid(plot);
|
||||||
draw_axis_lines(plot);
|
draw_axis_lines(plot);
|
||||||
draw_tick_labels(plot, layout);
|
draw_tick_labels(plot, layout);
|
||||||
@@ -334,6 +346,7 @@ void Renderer::draw(Plot& plot) {
|
|||||||
draw_cursors(plot, layout);
|
draw_cursors(plot, layout);
|
||||||
draw_measures(plot, layout);
|
draw_measures(plot, layout);
|
||||||
draw_annotations(plot);
|
draw_annotations(plot);
|
||||||
|
draw_legend(plot, layout);
|
||||||
flush_text();
|
flush_text();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,6 +476,25 @@ Vec2 Renderer::to_viewport(Vec2 px) const noexcept {
|
|||||||
return Vec2{px.x - viewport_.x, px.y - viewport_.y};
|
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) {
|
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));
|
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
|
// -------------------------------------------------------------------- dump
|
||||||
|
|
||||||
bool Renderer::dump_frame_ppm(const char* path, Status* st) const {
|
bool Renderer::dump_frame_ppm(const char* path, Status* st) const {
|
||||||
|
|||||||
@@ -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;
|
for (const plot::GridLine& l : g.lines()) minor += l.major ? 0u : 1u;
|
||||||
PLOT_CHECK(minor > 0); // minor grid is generated too
|
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);
|
||||||
|
}
|
||||||
@@ -2188,3 +2188,228 @@ PLOT_TEST(text_atlas_reports_a_status_when_the_entry_points_are_missing) {
|
|||||||
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
|
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
|
||||||
ctx.destroy();
|
ctx.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The legend's two cases need a count, not a presence: "the swatch colour appears"
|
||||||
|
/// is satisfied by the trace that carries the same colour, and "some ink is in the
|
||||||
|
/// panel" is satisfied by any label the frame happens to put there. Counting the
|
||||||
|
/// pixels of an area that are exactly `c` (see `pixel_is`) is what turns both into
|
||||||
|
/// statements about the swatch.
|
||||||
|
plot::u32 exact_pixels(const std::vector<plot::u8>& frame, plot::u32 fb_w, const plot::Rect& area,
|
||||||
|
plot::Color c) {
|
||||||
|
plot::u32 count = 0;
|
||||||
|
for (plot::u32 y = static_cast<plot::u32>(area.y);
|
||||||
|
y < static_cast<plot::u32>(area.bottom()); ++y) {
|
||||||
|
for (plot::u32 x = static_cast<plot::u32>(area.x);
|
||||||
|
x < static_cast<plot::u32>(area.right()); ++x) {
|
||||||
|
if (pixel_is(&frame[(plot::usize(y) * fb_w + x) * 4u], c)) ++count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole framebuffer as a Rect, for the counts that ask "and nowhere else".
|
||||||
|
const plot::Rect kFrameRect{0.0f, 0.0f, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
|
||||||
|
|
||||||
|
/// The legend's entries are drawn where `Plot::layout()` measured them: a filled
|
||||||
|
/// swatch per trace in that trace's own colour, and the name's ink inside the panel
|
||||||
|
/// and nowhere else in the data area -- and the panel's presence changes nothing
|
||||||
|
/// outside itself.
|
||||||
|
///
|
||||||
|
/// The swatch colour is what makes the count a test. Both traces here are translucent
|
||||||
|
/// (alpha 0.25), while the swatch is drawn opaque, so the exact colour of a trace is
|
||||||
|
/// one that only a swatch can produce: the trace blended over the background -- or
|
||||||
|
/// over a grid line, which is brighter -- never reaches it, and neither do the grid,
|
||||||
|
/// the axis line or the text. Two colours are used so each entry's swatch has to be
|
||||||
|
/// its own trace's, not whichever colour the legend drew for everything.
|
||||||
|
PLOT_TEST(legend_swatch_and_label_are_drawn) {
|
||||||
|
plottest::HeadlessContext ctx(kW, kH);
|
||||||
|
plot::Status st;
|
||||||
|
PLOT_CHECK(ctx.create(&st));
|
||||||
|
PLOT_CHECK(plot::gl::load(nullptr));
|
||||||
|
plot::Renderer r;
|
||||||
|
PLOT_CHECK(r.init(&st));
|
||||||
|
|
||||||
|
plot::data::ArraySeries s(sine_samples(), 0.0, 1.0);
|
||||||
|
// The second trace is a series of its own, well clear of the first: two
|
||||||
|
// translucent strokes stacked over a grid line reach a brightness the `axis_text`
|
||||||
|
// ink predicate below cannot tell from a label, and the "no name ink in the data
|
||||||
|
// area" half of this case has to be about the legend's ink only.
|
||||||
|
plot::data::ArraySeries flat(std::vector<plot::f32>(64u, 0.75f), 0.0, 1.0);
|
||||||
|
plot::Plot p;
|
||||||
|
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||||
|
const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false});
|
||||||
|
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||||
|
const plot::Color green = plot::Color::hex(0x00E070, 0.25f);
|
||||||
|
const plot::Color pink = plot::Color::hex(0xFF3CC8, 0.25f);
|
||||||
|
PLOT_CHECK(p.add_trace({&s, green, 1.5f, ax, ay, "signal"}) == 0u);
|
||||||
|
PLOT_CHECK(p.add_trace({&flat, pink, 1.5f, ax, ay, "noise"}) == 1u);
|
||||||
|
p.set_x_range(ax, {0.0, 640.0}); // one sample per pixel: a flat, cheap frame
|
||||||
|
|
||||||
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||||
|
r.draw(p);
|
||||||
|
r.end_frame();
|
||||||
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||||
|
dump_if_requested(r, "legend.ppm");
|
||||||
|
|
||||||
|
// A copy: the second draw below clears the rows this layout holds.
|
||||||
|
const plot::PlotLayout layout = p.layout();
|
||||||
|
const plot::LegendLayout legend = layout.legend;
|
||||||
|
PLOT_CHECK(legend.rows.size() == 2u);
|
||||||
|
const plot::Legend& cfg = p.legend();
|
||||||
|
const plot::u32 swatch_area = static_cast<plot::u32>(cfg.swatch_w) *
|
||||||
|
static_cast<plot::u32>(cfg.swatch_h);
|
||||||
|
|
||||||
|
const std::vector<plot::u8> frame = read_frame(ctx);
|
||||||
|
|
||||||
|
// Each swatch: its own row's swatch box holds the pixels, and every pixel of that
|
||||||
|
// colour in the whole frame is one of them. The lower bound is the box's area
|
||||||
|
// minus the fringe a 1 px stroke column cannot promise -- a row and a column at
|
||||||
|
// each edge -- and the upper bound lets the outermost columns and rows count
|
||||||
|
// where the fill's edge lands a hair inside them.
|
||||||
|
const plot::Color opaque[2] = {plot::Color{green.r, green.g, green.b, 1.0f},
|
||||||
|
plot::Color{pink.r, pink.g, pink.b, 1.0f}};
|
||||||
|
for (plot::u32 i = 0; i < legend.rows.size(); ++i) {
|
||||||
|
const plot::Rect& box = legend.rows[i].swatch;
|
||||||
|
PLOT_CHECK(box.w > 0.0f && box.h > 0.0f);
|
||||||
|
PLOT_CHECK(box.x >= legend.panel.x && box.right() <= legend.panel.right());
|
||||||
|
PLOT_CHECK(box.y >= legend.panel.y && box.bottom() <= legend.panel.bottom());
|
||||||
|
// The row names its own trace, so the colour under the predicate is that
|
||||||
|
// trace's -- which is what `LegendRow::trace` is for.
|
||||||
|
PLOT_CHECK_NEAR(p.trace(legend.rows[i].trace).color.r, opaque[i].r, 1e-6);
|
||||||
|
const plot::u32 inside = exact_pixels(frame, kW, box, opaque[i]);
|
||||||
|
const plot::u32 anywhere = exact_pixels(frame, kW, kFrameRect, opaque[i]);
|
||||||
|
const plot::u32 in_panel = exact_pixels(frame, kW, legend.panel, opaque[i]);
|
||||||
|
// The swatch's colour fills the swatch's box -- within the row and column an
|
||||||
|
// edge's coverage ramp can reach past it -- and appears nowhere else in the
|
||||||
|
// frame: not over the trace that carries the same colour at 25%, not in the
|
||||||
|
// grid, and not outside the panel, which is where this ink's whole budget is.
|
||||||
|
PLOT_CHECK(inside >= swatch_area - 2u * static_cast<plot::u32>(cfg.swatch_w) -
|
||||||
|
2u * static_cast<plot::u32>(cfg.swatch_h));
|
||||||
|
PLOT_CHECK(anywhere <= swatch_area + 2u * static_cast<plot::u32>(cfg.swatch_w) +
|
||||||
|
2u * static_cast<plot::u32>(cfg.swatch_h));
|
||||||
|
PLOT_CHECK(anywhere == in_panel);
|
||||||
|
// The numbers behind those assertions, so a run says what it measured rather than
|
||||||
|
// only that it passed.
|
||||||
|
std::printf("[legend] row %u trace %u swatch %.2f,%.2f %.2fx%.2f: %u pixels of its colour, "
|
||||||
|
"%u in the frame\n",
|
||||||
|
i, legend.rows[i].trace, box.x, box.y, box.w, box.h, inside, anywhere);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The names: real glyph ink inside the panel, and none in the data area outside it.
|
||||||
|
// This plot has no cursor, no measurement and no annotation, and every other
|
||||||
|
// `axis_text` run -- the tick labels, the titles -- is drawn in a strip outside
|
||||||
|
// `axes`, so the only ink in the data area is the legend's.
|
||||||
|
const plot::Color ink = r.style().axis_text;
|
||||||
|
const plot::u32 in_panel = scan_rect_ink(ctx, legend.panel, ink).pixels;
|
||||||
|
// The data area, inset past the axis lines: they run along the axes' edges and are
|
||||||
|
// brighter than a third of `axis_text` -- the trap `rect_has_exact` exists for -- so
|
||||||
|
// an un-inset scan would be counting the axis rather than a label.
|
||||||
|
const plot::Rect data{layout.axes.x + 2.0f, layout.axes.y + 2.0f, layout.axes.w - 4.0f,
|
||||||
|
layout.axes.h - 4.0f};
|
||||||
|
const plot::u32 in_data = scan_rect_ink(ctx, data, ink).pixels;
|
||||||
|
PLOT_CHECK(in_panel >= 32u); // two names, not one stray anti-aliased pixel
|
||||||
|
PLOT_CHECK(in_data == in_panel); // and every bit of that ink is the legend's
|
||||||
|
// The numbers behind those assertions, so a run says what it measured rather than
|
||||||
|
// only that it passed.
|
||||||
|
std::printf("[legend] panel %.2f,%.2f %.2fx%.2f, %zu rows; name ink %u in the panel, %u in the "
|
||||||
|
"data area\n",
|
||||||
|
legend.panel.x, legend.panel.y, legend.panel.w, legend.panel.h, legend.rows.size(),
|
||||||
|
in_panel, in_data);
|
||||||
|
|
||||||
|
// The panel changes nothing outside itself: the same plot drawn with the legend
|
||||||
|
// switched off -- same traces, same axes rect, same transforms, since a legend
|
||||||
|
// reserves none of the plot -- must come out pixel-identical everywhere except
|
||||||
|
// inside the panel. One pixel of slack on each side, because a fill's stroke
|
||||||
|
// column lets its fringe reach a pixel past the panel's edge.
|
||||||
|
plot::Legend off = cfg;
|
||||||
|
off.visible = false;
|
||||||
|
p.set_legend(off);
|
||||||
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||||
|
r.draw(p);
|
||||||
|
r.end_frame();
|
||||||
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||||
|
const std::vector<plot::u8> bare = read_frame(ctx);
|
||||||
|
const plot::Rect changed{legend.panel.x - 1.0f, legend.panel.y - 1.0f, legend.panel.w + 2.0f,
|
||||||
|
legend.panel.h + 2.0f};
|
||||||
|
PLOT_CHECK(changed.x >= layout.axes.x && changed.right() <= layout.axes.right());
|
||||||
|
PLOT_CHECK(changed.bottom() <= layout.axes.bottom()); // so "inside changed" is inside axes
|
||||||
|
plot::u32 differing = 0;
|
||||||
|
for (plot::u32 y = 0; y < ctx.height; ++y) {
|
||||||
|
for (plot::u32 x = 0; x < ctx.width; ++x) {
|
||||||
|
const plot::usize at = (plot::usize(y) * kW + x) * 4u;
|
||||||
|
if (frame[at] == bare[at] && frame[at + 1u] == bare[at + 1u] &&
|
||||||
|
frame[at + 2u] == bare[at + 2u] && frame[at + 3u] == bare[at + 3u]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
++differing;
|
||||||
|
PLOT_CHECK(changed.contains(static_cast<plot::f32>(x) + 0.5f,
|
||||||
|
static_cast<plot::f32>(y) + 0.5f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The comparison is only evidence if the legend was in the first frame at all.
|
||||||
|
PLOT_CHECK(differing > 0u);
|
||||||
|
ctx.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bottom-left corner is the one a legend anchored from the axes' *opposite*
|
||||||
|
/// corner would miss: the panel has to be down there, inside `axes` and held off the
|
||||||
|
/// two edges that corner is near, with its swatch in the panel and nowhere else --
|
||||||
|
/// including not in the strip below the axes, where the tick labels live.
|
||||||
|
PLOT_TEST(legend_does_not_cover_the_plot_when_placed_bottom_left) {
|
||||||
|
plottest::HeadlessContext ctx(kW, kH);
|
||||||
|
plot::Status st;
|
||||||
|
PLOT_CHECK(ctx.create(&st));
|
||||||
|
PLOT_CHECK(plot::gl::load(nullptr));
|
||||||
|
plot::Renderer r;
|
||||||
|
PLOT_CHECK(r.init(&st));
|
||||||
|
|
||||||
|
plot::data::ArraySeries s(sine_samples(), 0.0, 1.0);
|
||||||
|
plot::Plot p;
|
||||||
|
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||||
|
const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false});
|
||||||
|
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||||
|
// Translucent, as in the case above: the swatch is drawn opaque, so this colour is
|
||||||
|
// the swatch's alone.
|
||||||
|
const plot::Color teal = plot::Color::hex(0x00E070, 0.25f);
|
||||||
|
p.add_trace({&s, teal, 1.5f, ax, ay, "signal"});
|
||||||
|
p.set_x_range(ax, {0.0, 640.0});
|
||||||
|
plot::Legend cfg = p.legend();
|
||||||
|
cfg.place = plot::LegendPlace::BottomLeft;
|
||||||
|
p.set_legend(cfg);
|
||||||
|
|
||||||
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||||
|
r.draw(p);
|
||||||
|
r.end_frame();
|
||||||
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||||
|
dump_if_requested(r, "legend_bottom_left.ppm");
|
||||||
|
|
||||||
|
const plot::PlotLayout layout = p.layout();
|
||||||
|
const plot::LegendLayout& legend = layout.legend;
|
||||||
|
PLOT_CHECK(legend.rows.size() == 1u);
|
||||||
|
const plot::Rect& panel = legend.panel;
|
||||||
|
PLOT_CHECK_NEAR(panel.x, layout.axes.x + cfg.margin, 1e-4);
|
||||||
|
PLOT_CHECK_NEAR(panel.bottom(), layout.axes.bottom() - cfg.margin, 1e-4);
|
||||||
|
PLOT_CHECK(panel.right() <= layout.axes.right());
|
||||||
|
PLOT_CHECK(panel.bottom() <= layout.axes.bottom());
|
||||||
|
|
||||||
|
const std::vector<plot::u8> frame = read_frame(ctx);
|
||||||
|
const plot::Color opaque{teal.r, teal.g, teal.b, 1.0f};
|
||||||
|
const plot::u32 in_panel = exact_pixels(frame, kW, panel, opaque);
|
||||||
|
PLOT_CHECK(in_panel >= static_cast<plot::u32>(cfg.swatch_w - 2.0f) *
|
||||||
|
static_cast<plot::u32>(cfg.swatch_h - 2.0f));
|
||||||
|
// Every pixel of the swatch colour is inside the panel: none in the x label strip
|
||||||
|
// below the axes, none in the y strip, none anywhere else in the frame.
|
||||||
|
PLOT_CHECK(exact_pixels(frame, kW, kFrameRect, opaque) == in_panel);
|
||||||
|
const plot::Rect below{0.0f, layout.axes.bottom(), static_cast<plot::f32>(kW),
|
||||||
|
static_cast<plot::f32>(kH) - layout.axes.bottom()};
|
||||||
|
PLOT_CHECK(exact_pixels(frame, kW, below, opaque) == 0u);
|
||||||
|
// ... with the name drawn beside it.
|
||||||
|
const plot::u32 name_ink = scan_rect_ink(ctx, panel, r.style().axis_text).pixels;
|
||||||
|
PLOT_CHECK(name_ink >= 16u);
|
||||||
|
std::printf("[legend] bottom-left panel %.2f,%.2f %.2fx%.2f; swatch %u pixels in the panel, %u "
|
||||||
|
"in the frame, %u below the axes; name ink %u\n",
|
||||||
|
panel.x, panel.y, panel.w, panel.h, in_panel,
|
||||||
|
exact_pixels(frame, kW, kFrameRect, opaque), exact_pixels(frame, kW, below, opaque),
|
||||||
|
name_ink);
|
||||||
|
ctx.destroy();
|
||||||
|
}
|
||||||
Reference in new issue
Block a user