Compare commits

...
2 Commits
Author SHA1 Message Date
Martino Ferrari 1d5b286611 crosshair: axis badges, clustered cursor labels, and the shared-y horizontal line
CI / debug (tests + all adapters) (push) Canceled after 0s
CI / coverage (floor + measured table) (push) Canceled after 0s
CI / asan (AddressSanitizer) (push) Canceled after 0s
- Axis badges: the y value in the y label strip and the x time in the x strip, each a
  full-opacity panel in the crosshair's colour, **centred on the line it names**. The tick
  text is flushed before the fill -- the over-text batch is flushed after every line batch,
  so without that flush the label behind printed through the panel (the "transparent
  backplate" defect).
- Cursor labels: collected across the cursor loop and clustered at its end, so two marks in
  one column share a panel with one line each instead of overprinting, and a label stays on
  its own mark's row.
- Figure: a split row's cells share one y (`set_share_row_y`), so the horizontal line is
  drawn in every cell of the hovered row -- one value, one scale, spanning the row -- and
  still in no other row.

Tests: `figure_crosshair_horizontal_spans_a_split_rows_cells`, plus the render-smoke
additions for the badge placement/flush and the clustered labels.
2026-11-05 17:58:49 +01:00
Martino Ferrari 2cb31cbb91 figure: x axes follow the column structure; split rows keep the right gutter on the last cell only 2026-11-04 07:00:52 +01:00
9 changed files with 835 additions and 102 deletions

No files matched your search

+19
View File
@@ -91,6 +91,14 @@ struct Crosshair {
/// On by default, and free to leave on: a plot with no visible trace has no rows to
/// measure and the panel is then not placed at all.
bool show_readout = true;
/// Write the two values the crosshair's lines meet the axes at as small badges in the
/// label strips -- the y value beside the y ticks, the x value beside the x ticks, each
/// in its axis' own format. On by default: it is the axis reading an oscilloscope puts
/// where the eye already is, and a host that does not want it turns it off
/// (`Plot::set_crosshair_axis_labels()`). The badges are placed only when the strip they
/// belong to exists and the line they belong to is drawn (a vertical-only crosshair has
/// no y to badge, and a row whose x strip went to a neighbour badges nothing).
bool axis_labels = true;
/// The data x of the vertical line, on x axis 0.
f64 x = 0.0;
/// The data y of the horizontal line, on y axis 0. Meaningless for a vertical-only
@@ -277,6 +285,17 @@ struct CrosshairLayout {
/// `Crosshair::show_readout` is off, or when the panel could not be placed inside the
/// axes at all.
Rect panel{};
/// The two axis badges, when `Crosshair::axis_labels` is on and the crosshair is active:
/// the y value in the **y label strip**, right-aligned on the axis line at the horizontal
/// line's height, and the x value in the **x label strip**, centred on the vertical
/// line's column -- each in its axis' own format, so a badge reads exactly like the tick
/// labels beside it. Zero-sized (and the text empty) when the flag is off, the crosshair
/// is inactive, the line they belong to is not drawn, their strip does not exist (a row
/// whose x strip went to the row below), or there is no text backend to measure them.
Rect y_axis_badge{};
std::string y_axis_text;
Rect x_axis_badge{};
std::string x_axis_text;
/// One row per visible trace, in trace order -- see `CrosshairRow`. Present whether or
/// not the panel could list them, so the markers are drawn even in a plot too small to
/// hold a panel.
+27 -17
View File
@@ -32,15 +32,16 @@ struct FigureLayout {
/// rect here; a row of several tiles its rect left to right with `col_gap` between
/// cells, the cells' widths summing to the row's less the gaps.
std::vector<Rect> cells;
/// The strip the figure's one shared x axis is drawn in: the bottom row's own
/// The strip the figure's bottom x axis is drawn in: the bottom row's own
/// `PlotLayout::x_label_area` (for a bottom row split into several cells, only the **last
/// cell's** strip -- the others' strips are their own `plot(i).layout().x_label_area`, which
/// is where the controller's axis-zoom hit test reads each cell's gestures from), at the bottom of the bottom row's rect and so as
/// wide as that row's axes rather than the whole figure. Read back from the row
/// is where the controller's axis-zoom hit test reads each cell's gestures from), at the
/// bottom of the bottom row's rect and so as wide as that row's axes rather than the whole
/// figure. Read back from the row
/// rather than derived here, so a host and the frame cannot disagree about where
/// the axis is. It is nonzero whenever the figure has rows and the bottom row
/// reserves a label strip -- including the case where every row labels itself, in
/// which case it is simply the bottom row's own strip, one of several.
/// reserves a label strip. A row above whose columns differ from the row below keeps
/// a strip of its own (see `Figure`), which is its plots' `x_label_area`, not this.
///
/// It is the rect the controller's axis-zoom hit test reads (`x_label_area` is what
/// a middle drag in a label strip enters `DragMode::AxisZoomX` from), which is why
@@ -70,12 +71,15 @@ struct FigureLayout {
/// **The shared x axis.** Row heights are an equal division of the figure by
/// default, `set_row_weights()` gives a row more of it, and every row keeps a floor
/// height so chrome can never erase a plot. When `set_x_labels_only_on_bottom()` is
/// on -- the default -- the figure's one x axis is the **bottom row's own**: the rows
/// above reserve no x chrome at all, and the bottom row keeps its own label and title
/// strips, so those labels and that title are drawn once, from the bottom row's own
/// ticks and transform. That places the shared axis *inside* a row, where the
/// controller's axis-zoom hit test can reach it, instead of in a band outside every
/// row that no gesture could land in.
/// on -- the default -- the x axis is the **bottom row's own** for every run of rows
/// with the *same columns* (the same number of cells and the same normalised cell
/// weights): those rows above reserve no x chrome at all, and the bottom row of the run
/// keeps its own label and title strips, so those labels and that title are drawn once,
/// from its own ticks and transform. A row whose columns differ from the row below keeps
/// its own strip, drawn under each of its cells, because the axis below is at another
/// scale and width. The rule is structural, never a function of what a plot holds. That
/// places a shared axis *inside* a row, where the controller's axis-zoom hit test can
/// reach it, instead of in a band outside every row that no gesture could land in.
///
/// **The x link.** `plot(i).set_x_range()` and a controller gesture both reach a
/// member's x range the same way, so a figure cannot see either of them happen.
@@ -186,14 +190,17 @@ class Figure {
/// than row by row for the left gutter's reason: the rows' axes have to line up, and
/// they end on a common column only if they are told one width. See
/// `Plot::set_right_gutter()` for what it is for. Negative values are clamped to 0.
/// In a split row whose cells share one y (`set_share_row_y`) only the **last** cell
/// keeps it, taken off the row like the y gutter, so the gap between two plots of the
/// row is the column gap.
void set_right_gutter(f32 device_px) noexcept {
right_gutter_ = device_px > 0.0f ? device_px : 0.0f;
}
f32 right_gutter() const noexcept { return right_gutter_; }
/// Whether the shared x axis is the figure's: every row reserves no x label strip
/// and one shared axis is drawn under the last row (the default), or every row
/// draws its own labels and the figure draws nothing extra.
/// Whether rows with the same columns as the row below hand it their x axis (the
/// default; a row with different columns keeps its own), or every row draws its own
/// labels and the figure draws nothing extra.
void set_x_labels_only_on_bottom(bool on) noexcept { labels_bottom_only_ = on; }
bool x_labels_only_on_bottom() const noexcept { return labels_bottom_only_; }
@@ -240,9 +247,12 @@ class Figure {
/// Places the crosshair with the pointer's **plot** and that plot's local y (spec §17): the
/// shared x goes to every plot -- vertical lines everywhere, as the plain overload does -- and
/// the horizontal line is drawn **only in `hovered_row`** -- the **flattened plot index**, not
/// the figure-row index; they differ when a row holds several cells -- where its y is a value of
/// *that* plot's own scale. It is the fix for the reported defect -- a figure showing no horizontal
/// the horizontal line is drawn in `hovered_row` -- the **flattened plot index**, not the
/// figure-row index; they differ when a row holds several cells -- where its y is a value of
/// *that* plot's own scale. **When the row is a split whose cells share one y
/// (`set_share_row_y`), the line is drawn in every cell of that row**: it is one value on one
/// scale, so stopping it at the divider would hide the number the neighbouring cell is read
/// against. Rows never share y with each other, so no other row gets a line. It is the fix for the reported defect -- a figure showing no horizontal
/// line at all -- without inventing a figure-wide y, which would mean a different value in
/// every row: the line appears where the pointer is, and no row ever shows another row's y.
///
+16
View File
@@ -557,6 +557,14 @@ class Plot {
/// belongs to. An id that names no cursor is ignored, like every other setter here.
void set_cursor_color(u32 id, Color color);
/// A cursor's caption (see `Cursor::label`): a vertical marker's badge text -- a horizontal
/// ruler writes its own value and needs no caption to be readable. Rendered on a
/// backplate, and labels whose backplates meet are merged into one panel with one line per
/// label (see the renderer), which is what keeps a marker pair half a division apart
/// readable instead of a smear of two texts over one column. An empty string removes the
/// caption; an id that names no cursor is ignored, like every other setter here.
void set_cursor_label(u32 id, std::string label);
/// A cursor's stroke: its logical width (`<= 0` for the style's default) and its dash
/// (`dash.off <= 0` for solid). The instrument's own, like its colour -- see `Cursor::width`.
void set_cursor_stroke(u32 id, f32 width, Vec2 dash);
@@ -888,6 +896,14 @@ class Plot {
/// **not** part of `set_crosshair()`, so placing a hover cannot silently change it.
void set_crosshair_readout(bool on) noexcept { crosshair_.show_readout = on; }
/// Writes or clears the two **axis badges** (see `Crosshair::axis_labels`): the y value in
/// the y label strip and the x value in the x label strip, at the heights and columns the
/// crosshair's lines meet the axes at. Takes effect on the next `layout()`. **Not** part of
/// `set_crosshair()`, for the same reason the readout flag is not.
void set_crosshair_axis_labels(bool on) noexcept { crosshair_.axis_labels = on; }
/// The axis-badge flag, as set.
bool crosshair_axis_labels() const noexcept { return crosshair_.axis_labels; }
/// Overrides this plot's crosshair stroke and dash, in logical pixels, without touching the
/// global style every other plot draws with (spec §17): each field left negative in `s`
/// defers to the matching `Style::crosshair_*` metric, so `CrosshairStyle{}` (the default)
+53 -15
View File
@@ -95,6 +95,24 @@ struct FrameStats {
/// a guard -- and the frame reports it (see `last_frame_had_gl_error()`).
///
/// Not thread-safe, and confined to the thread that owns the current context.
/// One cursor label's measured text and placed badge: what `Renderer::collect_cursor_label()`
/// builds and `Renderer::draw_clustered_cursor_labels()` places and draws. A rendering
/// detail of the cursor labels' backplates, declared at namespace scope only because a
/// private nested type cannot be named in an out-of-class member definition's signature.
struct CursorLabel {
std::string text; ///< the one line the backplate carries
Color border{}; ///< the panel's border: the cursor's own colour
f32 anchor = 0.0f; ///< the coordinate the label belongs to (x for a marker, y for a ruler)
f32 w = 0.0f; ///< the measured text width
bool horizontal = false;
/// The placed pen and backplate: centred on the anchor across the mark, nudged onto a
/// free row when a close neighbour's badge is in the way, and clustered into one panel
/// by `draw_clustered_cursor_labels()`.
Vec2 pen{};
Rect box{};
};
class Renderer {
public:
Renderer() noexcept = default;
@@ -238,12 +256,13 @@ class Renderer {
/// exactly as a stand-alone plot is, and `draw(Plot&)` itself is unchanged for
/// single-plot hosts.
/// The figure's one shared x axis is the **bottom row's own**, so it is drawn by
/// that row's normal path: when the figure says labels belong on the bottom only
/// (the default), the rows above reserve no x chrome and the bottom row stages its
/// own labels and title once, inside its own strip -- the rect
/// `FigureLayout::x_axis_strip` reads back. With that switched off, every row keeps
/// its own chrome and each draws its own labels; there is then no shared axis.
/// A shared x axis is the **bottom row's own** (of a run of rows with the same
/// columns), so it is drawn by that row's normal path: when the figure says labels
/// belong on the bottom only (the default), the rows above that match the row below
/// reserve no x chrome and the bottom row stages its own labels and title once,
/// inside its own strip -- the rect `FigureLayout::x_axis_strip` reads back. A row
/// whose columns differ keeps and draws its own; with that switched off, every row
/// does and there is no shared axis.
/// Styling, the text backend and `ui_scale` are installed on **every** row before
/// the figure computes its layout, so the strips the rows measure and the strokes
@@ -620,13 +639,22 @@ class Renderer {
/// the clip, and a range maximum that undershoots the edge by a ulp is not mistaken for
/// off the plot.
void draw_cursors(const Plot& plot, const PlotLayout& layout);
/// One cursor's label: the caller's caption for either orientation, and for a horizontal
/// ruler the value itself, written in its y axis' own tick format (`Axis::format_value()`)
/// -- the number a ruler exists to be read off. A vertical cursor's caption starts at the
/// top of the axes beside its marker; a ruler's text starts at the axes' left edge on its
/// own row, with the caption first on that line when there is one.
void draw_cursor_label(const Plot& plot, const PlotLayout& layout, const Cursor& cursor,
f32 at);
/// One cursor's label, measured rather than drawn: a horizontal ruler's line is the value
/// itself, written in its y axis' own tick format (`Axis::format_value()`) -- the number a
/// ruler exists to be read off -- with the caption first on the line when there is one; a
/// vertical cursor's line is its caption, beside the marker at the top of the axes. The
/// label is then placed (centred on its mark, stacked clear of close neighbours) and drawn
/// clustered (`draw_clustered_cursor_labels()`), which is what keeps a pair of readout
/// marks half a division apart readable: two texts over one column is a smear, one panel
/// with two lines is a readout.
void collect_cursor_label(const Plot& plot, const PlotLayout& layout, const Cursor& cursor,
f32 at, std::vector<CursorLabel>& labels);
/// Draws the placed labels' backplates -- one panel per cluster of meeting boxes, every
/// line at its own placed pen -- and stages the lines. Clustering is what keeps a pair of
/// readout marks half a division apart readable: two texts over one column is a smear, one
/// panel with two lines is a readout.
void draw_clustered_cursor_labels(const PlotLayout& layout,
const std::vector<CursorLabel>& labels);
/// The regions: the fill, one bound per edge, the optional label. Every rect comes
/// from `PlotLayout::regions` -- the same geometry the controller hit-tests -- so
/// a region is drawn where it can be grabbed, and neither side derives the other's
@@ -655,8 +683,12 @@ class Renderer {
/// outermost pixels the fill covers (the crisp-hairline rule -- a border drawn on the measured
/// rect's fractional edges is lit half-heartedly, and a frame bright on three sides and dim on the
/// fourth reads as a mistake). One helper, two callers: the border's colour is the only thing a
/// legend panel and a readout panel do not share.
void draw_readout_panel(const Rect& panel, Color border);
/// legend panel and a readout panel do not share. `background_alpha` scales the frame's own
/// background under the border: the legend and the crosshair's readout keep the shared
/// translucent backplate (what they cover stays faintly visible), while a *badge* -- a
/// cursor's value box, the crosshair's axis badges -- is read against whatever it sits on
/// (ticks, a noisy trace) and is drawn opaque.
void draw_readout_panel(const Rect& panel, Color border, float background_alpha);
/// One trace's colour swatch in `box`, 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 an entry or a readout row *is*. The legend's entries and the crosshair's
@@ -686,6 +718,12 @@ class Renderer {
/// Drawn after every other interior pass -- the legend included -- because it is what the
/// pointer is on: a readout the legend covered would be the feature failing at its one job.
void draw_crosshair_readout(const Plot& plot, const PlotLayout& layout);
/// The crosshair's **axis badges** (`CrosshairLayout::y_axis_badge`/`x_axis_badge`): the
/// values the two lines meet the axes at, each on the shared readout backplate with its
/// border in the crosshair's colour, drawn over the tick labels they sit among. Called
/// from `draw_plot()` after the label strips are drawn, which is the only order in which a
/// badge in a strip can sit on the ticks and still be read.
void draw_crosshair_axis_badges(const PlotLayout& layout);
/// The host's box-zoom frame (`PlotLayout::selection`): a fill at `selection_fill` under a border at
/// `selection_edge`, or nothing at all when nothing was swept. Drawn last of the interior passes, so
+94 -23
View File
@@ -73,6 +73,38 @@ f32 place_cells(const Rect& r, u32 n, const std::vector<f32>& weights, f32 col_g
return gap;
}
/// The share of the row each of its `n` cells is given by `weights`, exactly as `place_cells`
/// reads them: an empty (or wrongly sized, or non-positive-total) vector means equal shares.
std::vector<f32> normalised_shares(const std::vector<f32>& weights, u32 n) {
std::vector<f32> shares(n, 1.0f / static_cast<f32>(n));
f32 total = 0.0f;
if (weights.size() == n) {
for (const f32 w : weights) total += w;
}
if (total > 0.0f) {
for (u32 i = 0; i < n; ++i) shares[i] = weights[i] / total;
}
return shares;
}
/// Whether two rows have the **same columns**: the same number of cells and the same
/// normalised cell weights (within 1e-4). The rows are all the figure's width, so such rows
/// get cells with identical x extents -- the same y gutter, gap, floors and right gutter
/// apply to both -- and one row's x axis serves the other. Structural, never content: it
/// reads the cell counts and the weights the host set and nothing a plot holds.
bool same_columns(const std::vector<u32>& cells_per_row,
const std::vector<std::vector<f32>>& cell_weights, u32 row_a, u32 row_b) {
const u32 n = cells_per_row[row_a];
if (n != cells_per_row[row_b]) return false;
if (n == 1u) return true;
const std::vector<f32> a = normalised_shares(cell_weights[row_a], n);
const std::vector<f32> b = normalised_shares(cell_weights[row_b], n);
for (u32 i = 0; i < n; ++i) {
if (std::fabs(a[i] - b[i]) > 1e-4f) return false;
}
return true;
}
/// A coordinate in the shared axis' **axis space**: `log10` when the figure's one
/// x axis -- the bottom row's, the row the shared strip belongs to -- is
/// logarithmic, the value itself otherwise. A following group's span and offset
@@ -558,12 +590,13 @@ const FigureLayout& Figure::layout() {
}
const f32 figure_h = std::max(0.0f, viewport_.h);
// The rows tile the figure itself: the shared x axis is the *bottom row's own*, so
// nothing is taken off the figure for it. That is what keeps the axis reachable --
// its strip is a real `PlotLayout::x_label_area` inside the bottom row's rect, which
// is the rect the controller's axis-zoom hit test reads and the strip an x title is
// drawn in -- and it is why an interior row reserves no x chrome at all instead of
// leaving a band no one owns.
// The rows tile the figure itself: an x axis is a row's *own* strip (the bottom row's, and
// the strip of any row whose columns differ from the row below), so nothing is taken off
// the figure for it. That is what keeps the axis reachable -- its strip is a real
// `PlotLayout::x_label_area` inside a row's rect, which is the rect the controller's
// axis-zoom hit test reads and the strip an x title is drawn in -- and it is why a row
// that hands its axis to the one below reserves no x chrome at all instead of leaving a
// band no one owns.
const f32 content_h = figure_h;
// The gap is dropped, rather than squeezing the rows, when the figure cannot hold
// it: a figure one pixel tall with four rows still gives every row a positive
@@ -633,18 +666,31 @@ const FigureLayout& Figure::layout() {
if (n > 1u && used < col_gap_) layout_.col_gap = 0.0f;
}
const u32 bottom_row = rows - 1u;
// Which rows carry their own x strip, decided structurally and before the first
// measurement pass so that pass and the final one see the same strips. The bottom row
// always has one. A row above hides its strip only when the row directly below has the
// same columns (see `same_columns()`), because then the cells of the two rows have the
// same x extents and the axis below serves this row too; the rule is transitive, since
// that row is itself either labelled or hidden in favour of one that matches it. A row
// whose columns differ from the one below -- `[1][2]` over `[3]` -- keeps its own strip,
// drawn under each of its cells, because the axis below is at another scale and width.
// A figure told to label every row keeps the strips everywhere.
std::vector<XStrip> strips(rows, XStrip::Both);
if (labels_bottom_only_) {
for (u32 row = rows - 1u; row-- > 0u;) {
// By induction the row below is labelled or itself hidden in favour of a matching
// row, so hiding this one on a match with it is the transitive rule.
if (same_columns(cells_per_row_, cell_weights_, row, row + 1u)) strips[row] = XStrip::None;
}
}
for (u32 row = 0; row < rows; ++row) {
// One x axis for the whole figure, and it is the bottom row's: every plot keeps its
// x chrome -- labels, title and the strip the controller's axis zoom is found in --
// and every plot above the bottom row gives all of it to that row, so the labels and
// the title are not drawn once per row. A figure told to label every row keeps them
// everywhere, and then there is no shared axis to speak of: each plot has its own. A
// split bottom row labels each of its cells, each against its own x axis.
const XStrip strip = labels_bottom_only_ && row != bottom_row ? XStrip::None : XStrip::Both;
const XStrip strip = strips[row];
// A split row whose cells share one y range also shares one y axis, drawn by its first
// cell: every other cell reserves no y chrome at all (see `YStrip::None`).
const bool shared_y = share_row_y_ && cells_per_row_[row] > 1u;
// ... and its right gutter is the last cell's alone, so the gap between two plots of the
// row is the column gap and not a gutter more (see the placement block below).
const u32 last = row_first_[row + 1u] - 1u;
for (u32 i = row_first_[row]; i < row_first_[row + 1u]; ++i) {
Plot& p = plots_[i];
p.set_viewport(layout_.cells[i]);
@@ -653,7 +699,7 @@ const FigureLayout& Figure::layout() {
// Before either pass below, and the same value for every plot: nothing is measured on
// the right, so unlike the left gutter this needs no measurement pass -- the plots
// end on a common column because they are all told the one width.
p.set_right_gutter(right_gutter_);
p.set_right_gutter(shared_y && i != last ? 0.0f : right_gutter_);
}
}
// After the viewports, so a row's ticks and grid -- built from its axes rect -- and
@@ -708,18 +754,28 @@ const FigureLayout& Figure::layout() {
// to hold the axis. The gutter is capped at half the row, so a row too narrow for it
// degrades to ordinary cells instead of producing a negative rect. The cells still tile the
// row: the first starts on its left edge and the last still ends on its right edge.
//
// The right gutter is taken off the same way and belongs to the **last** cell alone (the
// other cells were told none above): left to every cell, the visible gap between two
// plots would be the column gap plus a whole right gutter. Together with the y gutter it is
// capped at half the row, and never exceeds the figure's own right gutter.
for (u32 row = 0; row < rows; ++row) {
const u32 n = cells_per_row_[row];
if (!share_row_y_ || n <= 1u) continue;
const Rect& r = layout_.rows[row];
const f32 g_eff = std::max(0.0f, std::min(gutter, r.w / 2.0f));
const f32 rg_eff = std::max(0.0f, std::min(right_gutter_, r.w / 2.0f - g_eff));
Rect* cells = &layout_.cells[row_first_[row]];
const f32 used = place_cells(Rect{r.x + g_eff, r.y, r.w - g_eff, r.h}, n, cell_weights_[row],
col_gap_, floor_scale, cells);
const f32 used = place_cells(Rect{r.x + g_eff, r.y, r.w - g_eff - rg_eff, r.h}, n,
cell_weights_[row], col_gap_, floor_scale, cells);
cells[0].x -= g_eff;
cells[0].w += g_eff;
cells[n - 1u].w += rg_eff;
layout_.col_gap = used < col_gap_ ? 0.0f : layout_.col_gap;
for (u32 i = row_first_[row]; i < row_first_[row + 1u]; ++i) plots_[i].set_viewport(layout_.cells[i]);
// What the cell actually reserved, so a row too narrow for the whole gutter is not asked
// for more than it was given (the same value whenever the row has room).
plots_[row_first_[row + 1u] - 1u].set_right_gutter(rg_eff);
}
// A one-row figure imposes exactly what that row measured, so it lays out as the plot
// would on its own; a figure whose rows all need the same strip imposes that, which is
@@ -729,12 +785,12 @@ const FigureLayout& Figure::layout() {
p.layout();
}
// The figure's one x axis is the bottom row's, so the strip it is drawn in is that
// row's own label area -- the rect the controller hit-tests and the renderer stages
// its labels in, read back here rather than derived again, so a host and the frame
// cannot disagree about where the shared axis is. It is zero-sized only when there
// are no rows at all: with `set_x_labels_only_on_bottom(false)` it is simply the last
// row's own strip, one of several.
// `x_axis_strip` is the bottom row's own label area (the last plot's) -- the rect the
// controller hit-tests and the renderer stages its labels in, read back here rather than
// derived again, so a host and the frame cannot disagree about where it is. Rows above
// that carry their own strip (their columns differ from the row below, or every row is
// labelled) have it in their plots' own `layout().x_label_area`. It is zero-sized only
// when there are no rows at all.
layout_.x_axis_strip = plots_.back().layout().x_label_area;
return layout_;
}
@@ -763,6 +819,21 @@ void Figure::set_crosshair(f64 x, u32 hovered_row, f64 y_in_row, bool active) {
// than clearing a hover the pointer still has.
if (!active || hovered_row >= plots_.size()) return;
plots_[hovered_row].set_crosshair(x, y_in_row, true);
// **The cells of a split row share one y**, so the horizontal line means the same value in
// every one of them: it spans the row rather than stopping at the divider between two cells
// that read the same scale. Only that row -- rows do not share y with each other, so a
// sibling row's line would be a value on a scale it does not have -- and only when the row
// really is split (`set_share_row_y`, which is what makes the y common in the first place).
if (!share_row_y_) return;
const u32 row = row_of_plot(hovered_row);
if (row >= cells_per_row_.size() || cells_per_row_[row] <= 1u) return;
const u32 first = row_first_[row];
for (u32 k = 0; k < cells_per_row_[row]; ++k) {
const u32 cell = first + k;
if (cell != hovered_row && cell < plots_.size()) {
plots_[cell].set_crosshair(x, y_in_row, true);
}
}
}
void Figure::clear_crosshair() {
+67 -2
View File
@@ -311,6 +311,11 @@ void Plot::set_cursor_color(u32 id, Color color) {
cursors_[id].color = color;
}
void Plot::set_cursor_label(u32 id, std::string label) {
if (id >= cursors_.size()) return;
cursors_[id].label = std::move(label);
}
void Plot::set_cursor_stroke(u32 id, f32 width, Vec2 dash) {
if (id >= cursors_.size()) return;
cursors_[id].width = width;
@@ -1312,8 +1317,8 @@ const PlotLayout& Plot::layout() {
y_width = y_axes_.empty() ? 0.0f : 2.0f * pad;
x_height = text_->height() + 2.0f * pad;
}
// A row of a `Figure` gives its x strip to the figure's one shared axis -- the
// bottom row's own strip, where that axis lives -- so it reserves none: the seed and
// A row of a `Figure` that hands its x axis to the row below (which has the same
// columns, so the same x extents) reserves no strip: the seed and
// the fixed point below both start from zero, and `axes` then reaches the viewport's
// bottom edge. Set here rather than in the loop, because the loop's `need_x` is what
// the strip wants and this overrides the want, not the measurement.
@@ -1888,6 +1893,14 @@ void Plot::build_crosshair() {
const auto inside = [](f32 v, f32 lo, f32 hi) {
return std::clamp(v, std::min(lo, hi), std::max(lo, hi));
};
crosshair.y_axis_badge = Rect{};
crosshair.y_axis_text.clear();
crosshair.x_axis_badge = Rect{};
crosshair.x_axis_text.clear();
if (!crosshair_.active) {
crosshair.rows.clear();
return;
}
// The two lines, each a stroke box held *inside* the axes by half its own width. The
// renderer stages the stroke across the box's centre line, so this is the whole placement
@@ -1918,6 +1931,58 @@ void Plot::build_crosshair() {
crosshair.horizontal = Rect{axes.x, at - half, axes.w, stroke};
}
// The axis badges, when asked for (`Crosshair::axis_labels`): the values the two lines
// meet the axes at, written in the strips the tick labels are written in and in each
// axis' own format, so a badge reads exactly like the tick beside it. Placed here rather
// than by the renderer, for the readout panel's reason -- one placement rule, one place to
// read it -- and left empty whenever there is nothing honest to place: no text backend to
// measure with, the line they belong to not drawn (a vertical-only crosshair has no y),
// or their strip absent or too small (a row whose x strip went to the row below, a y
// gutter collapsed to a title). The x badge is held to the axes' own columns as well, so
// a crosshair in the leftmost cell of a split row cannot badge into its neighbour's.
if (crosshair_.axis_labels && text_ != nullptr) {
const f32 badge_pad = style_.device(4.0f);
const f32 badge_h = text_->height() + 2.0f * badge_pad;
if (crosshair_.horizontal && layout_.y_label_area.w > 0.0f &&
layout_.y_label_area.h >= badge_h) {
char text[64];
y_axis(0u).format_value(crosshair_.y, text, sizeof text);
crosshair.y_axis_text.assign(text);
const f32 w = text_->measure(crosshair.y_axis_text.c_str()) + 2.0f * badge_pad;
// Right-aligned on the axis line -- where the y ticks end -- and **centred** on the
// horizontal line's own height, the way the x badge is centred on its column: the badge
// names the line it sits beside, and a badge whose top edge *is* that line reads as
// belonging to the tick below it. A badge is as wide as its own text and is capped by
// nothing else, so it
// is held within the **left strips' union**: the label strip first, the title strip
// beside it when there is one, and never over the data or off the viewport's left
// edge. A `std::clamp` on an inverted pair is undefined, which is exactly the state
// "the strip is narrower than the badge" produces -- hence the ordered min/max chain.
const f32 left = layout_.y_title_area.w > 0.0f ? layout_.y_title_area.x
: layout_.y_label_area.x;
f32 bx = layout_.y_label_area.right() - w;
if (bx < left) bx = left;
bx = std::min(bx, axes.x - w);
if (bx < left) bx = left; // a badge wider than the union stays at its left edge
const f32 at = inside(static_cast<f32>(ty.to_screen_y(crosshair_.y)) - 0.5f * badge_h,
layout_.y_label_area.y + badge_pad,
layout_.y_label_area.bottom() - badge_pad - badge_h);
crosshair.y_axis_badge = Rect{bx, at, w, badge_h};
}
if (crosshair_.vertical && layout_.x_label_area.w > 0.0f &&
layout_.x_label_area.h >= badge_h) {
char text[64];
x_axis(0u).format_value(crosshair_.x, text, sizeof text);
crosshair.x_axis_text.assign(text);
const f32 w = text_->measure(crosshair.x_axis_text.c_str()) + 2.0f * badge_pad;
const f32 column =
inside(static_cast<f32>(tx.to_screen_x(crosshair_.x)), axes.x, axes.right());
const f32 x = inside(column - 0.5f * w, layout_.x_label_area.x,
layout_.x_label_area.right() - w);
crosshair.x_axis_badge = Rect{x, layout_.x_label_area.y, w, badge_h};
}
}
// The rows: one per *visible* trace -- a trace with no series or no samples is not under
// the pointer, so it has nothing to say -- named or not, because the readout is about what
// the pointer is over and not about identity. The vector is filled in place and its entries
+183 -32
View File
@@ -480,10 +480,11 @@ void Renderer::draw(Figure& figure) {
// than laying each row out a second time. Nothing draws before this: a row's
// viewport is the figure's to set.
//
// The figure's one x axis needs no pass of its own: it is the bottom row's own, so
// that row stages it in its own strip through `draw_tick_labels()` like any other
// plot's axis -- and every row above it reserved no x chrome, which is what keeps
// the labels from appearing once per row without a second staging here.
// A shared x axis needs no pass of its own: it is the bottom row's own (of a run of rows
// with the same columns), so that row stages it in its own strip through
// `draw_tick_labels()` like any other plot's axis -- and every row of the run above it
// reserved no x chrome, which is what keeps the labels from appearing once per row without
// a second staging here. A row whose columns differ stages its own.
figure.layout();
for (u32 row = 0; row < rows; ++row) {
Plot& plot = figure.plot(row);
@@ -564,6 +565,10 @@ void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) {
clear_clip();
draw_tick_labels(plot, layout);
draw_titles(plot, layout);
// The crosshair's axis badges, last of all: they sit in the label strips among the tick
// labels, so they are drawn after them and unclipped -- and over them, which is the point
// (a badge the ticks print through is two numbers in one place).
draw_crosshair_axis_badges(layout);
}
void Renderer::end_frame() noexcept {
@@ -878,8 +883,8 @@ void Renderer::draw_tick_labels(const Plot& plot, const PlotLayout& layout) {
const f32 line_h = font_backend_.height();
// X labels: unless the plot gave its x label strip away -- a row of a `Figure`
// that reserves no x chrome, because the figure draws one shared axis in the bottom
// row's own strip instead -- in which case it has no strip to put them in and their
// that reserves no x chrome, because the row below has the same columns and draws the
// axis in its own strip instead -- in which case it has no strip to put them in and their
// absence is the point. A plot with no strip would otherwise stage its labels just
// above `axes`' bottom edge, i.e. over its own data. Centred under their tick on the
// strip's single text line, which is the one rule for a plot's labels and a figure's
@@ -1674,6 +1679,9 @@ void Renderer::draw_cursors(const Plot& plot, const PlotLayout& layout) {
// does not depend on the marker's own colour differing from the accent -- a cursor
// created in the accent colour is otherwise indistinguishable from an idle one.
const HotHandles hot = plot.hot_handles();
// Collected across the loop below and drawn clustered at its end -- see
// `draw_clustered_cursor_labels()` for why one panel can carry several lines.
std::vector<CursorLabel> labels;
for (u32 i = 0; i < plot.cursor_count(); ++i) {
const Cursor& cursor = plot.cursor(i);
@@ -1729,34 +1737,143 @@ void Renderer::draw_cursors(const Plot& plot, const PlotLayout& layout) {
flush_lines(mapping(plot, cursor.axis_x, 0, x_drawn),
on ? style_.cursor : cursor.color, width, cursor.dash);
}
draw_cursor_label(plot, layout, cursor, drawn);
// The labels are drawn after the strokes, clustered: collecting them all first is what
// lets two marks in the same column share one panel instead of overprinting each other.
collect_cursor_label(plot, layout, cursor, drawn, labels);
}
draw_clustered_cursor_labels(layout, labels);
}
void Renderer::draw_cursor_label(const Plot& plot, const PlotLayout& layout, const Cursor& cursor,
f32 at) {
const f32 gap = style_.device(3.0f);
void Renderer::collect_cursor_label(const Plot& plot, const PlotLayout& layout,
const Cursor& cursor, f32 at,
std::vector<CursorLabel>& labels) {
(void)layout;
if (cursor.is_horizontal()) {
// A ruler's text is the value it is *for*, written the way the y axis writes its own
// tick labels, so a value read off a ruler reads exactly like the tick beside it. The
// caller's caption, when there is one, is staged first on the same line, so neither the
// caption nor the value has to be dropped -- and both stay one text run's worth of
// glyphs, since the pen advances per glyph either way.
const Vec2 pen{layout.axes.x + gap, at + style_.device(2.0f)};
// caller's caption, when there is one, leads the same line.
char value[32];
plot.y_axis(cursor.axis_y).format_value(cursor.y, value, sizeof value);
if (cursor.label.empty()) {
stage_text(glyphs_under_, pen, value, style_.axis_text);
return;
}
stage_text(glyphs_under_, pen, cursor.label.c_str(), style_.axis_text);
stage_text(glyphs_under_, {pen.x + font_backend_.measure(cursor.label.c_str()) + gap, pen.y},
value, style_.axis_text);
const std::string text =
cursor.label.empty() ? std::string(value) : cursor.label + " " + value;
CursorLabel label;
label.text = text;
label.border = cursor.color;
label.anchor = at; // the ruler's own row: the badge is centred on it
label.w = font_backend_.measure(text.c_str());
label.horizontal = true;
labels.push_back(std::move(label));
return;
}
if (!cursor.label.empty()) {
stage_text(glyphs_under_, {at + gap, layout.axes.y + style_.device(2.0f)},
cursor.label.c_str(), style_.axis_text);
if (cursor.label.empty()) return;
CursorLabel label;
label.text = cursor.label;
label.border = cursor.color;
label.anchor = at; // the marker's own column: the badge is centred on it
label.w = font_backend_.measure(cursor.label.c_str());
labels.push_back(std::move(label));
}
void Renderer::draw_clustered_cursor_labels(const PlotLayout& layout,
const std::vector<CursorLabel>& labels) {
if (labels.empty()) return;
const f32 pad = style_.device(3.0f);
const f32 line_h = font_backend_.height();
const Rect& axes = layout.axes;
// **Placement first, clustering second.** Each badge is centred on the mark it belongs to
// -- a ruler's badge straddles the ruler's own row, a marker's badge straddles the
// marker's own column -- because a badge that drifts from its mark is a number without an
// anchor. When two badges meet, the later one drops one text line (downward from its
// centred position) until it is clear, so a column of close rulers reads as a stack of
// numbers in their own rows rather than as texts printed through each other; the rows keep
// each badge beside its own mark, which a sequential stack from the panel's top would not.
std::vector<CursorLabel> placed;
placed.reserve(labels.size());
std::vector<CursorLabel> by_anchor = labels;
std::stable_sort(by_anchor.begin(), by_anchor.end(),
[](const CursorLabel& a, const CursorLabel& b) { return a.anchor < b.anchor; });
for (CursorLabel& label : by_anchor) {
f32 x = 0.0f;
f32 y = 0.0f;
if (label.horizontal) {
x = axes.x + pad;
y = label.anchor - 0.5f * line_h; // centred on the ruler's own row
} else {
// Centred on the marker's column, held inside the axes: a badge half over an edge is
// half a badge.
x = std::clamp(label.anchor - 0.5f * label.w, axes.x + pad,
std::max(axes.x + pad, axes.right() - pad - label.w));
y = axes.y + pad;
}
for (;;) {
const Rect box{x - pad, y - pad, label.w + 2.0f * pad, line_h + 2.0f * pad};
bool meets = false;
for (const CursorLabel& p : placed) {
meets = box.x < p.box.x + p.box.w && p.box.x < box.x + box.w &&
box.y < p.box.y + p.box.h && p.box.y < box.y + box.h;
if (meets) break;
}
label.pen = Vec2{x, y};
label.box = box;
if (!meets) break;
y += line_h; // one row down: still beside the mark, no longer on its neighbour
}
placed.push_back(label);
}
// The panels: one per cluster of meeting backplates -- the boxes' union plus padding, the
// border the cluster's first label's own -- with every line staged at its *placed* pen, so
// the panel is a unified plate behind badges that each still sit on their own mark.
const f32 margin = style_.device(2.0f);
std::vector<Rect> bounds;
std::vector<std::vector<std::size_t>> groups;
std::vector<char> used(placed.size(), 0);
for (std::size_t i = 0; i < placed.size(); ++i) {
if (used[i] != 0) continue;
used[i] = 1;
bounds.push_back(placed[i].box);
groups.emplace_back(1, i);
Rect& u = bounds.back();
for (bool grew = true; grew;) {
grew = false;
for (std::size_t j = 0; j < placed.size(); ++j) {
if (used[j] != 0) continue;
const Rect inflated{u.x - margin, u.y - margin, u.w + 2.0f * margin,
u.h + 2.0f * margin};
const Rect& b = placed[j].box;
const bool meet = inflated.x < b.x + b.w && b.x < inflated.x + inflated.w &&
inflated.y < b.y + b.h && b.y < inflated.y + inflated.h;
if (!meet) continue;
used[j] = 1;
groups.back().push_back(j);
const f32 rx = std::min(u.x, b.x);
const f32 ry = std::min(u.y, b.y);
u = Rect{rx, ry, std::max(u.x + u.w, b.x + b.w) - rx,
std::max(u.y + u.h, b.y + b.h) - ry};
grew = true;
}
}
}
for (std::size_t g = 0; g < groups.size(); ++g) {
Rect panel = bounds[g];
panel.x -= pad;
panel.y -= pad;
panel.w += 2.0f * pad;
panel.h += 2.0f * pad;
// Inside the axes, shifted rather than clipped: a panel that hangs over an edge is a
// second plot; a panel nudged along it is still a readout of what it labels. Opaque: a
// badge is read against ticks and traces, and the badge's line beneath it is covered --
// the box interrupting its own ruler is what makes the two read as one instrument.
panel.x = std::clamp(panel.x, axes.x,
std::max(axes.x, axes.right() - panel.w));
panel.y = std::clamp(panel.y, axes.y,
std::max(axes.y, axes.bottom() - panel.h));
draw_readout_panel(panel, placed[groups[g].front()].border, 1.0f);
for (std::size_t k = 0; k < groups[g].size(); ++k) {
const CursorLabel& label = placed[groups[g][k]];
stage_text(glyphs_under_, label.pen, label.text.c_str(), style_.axis_text);
}
}
}
@@ -2022,7 +2139,7 @@ void Renderer::draw_legend(const Plot& plot, const PlotLayout& layout) {
// The panel first, so every entry is on top of it. A legend's border is the frame's axis colour;
// the readout's is the crosshair's own (see draw_crosshair_readout), which is the only thing that
// differs between the two panels -- so it is the one parameter the shared helper takes.
if (plot.legend().frame) draw_readout_panel(legend.panel, style_.axis_line);
if (plot.legend().frame) draw_readout_panel(legend.panel, style_.axis_line, kLegendPanelAlpha);
for (const LegendRow& row : legend.rows) {
// The truncation row names no trace: it has no swatch to colour and its text is
@@ -2052,7 +2169,7 @@ void Renderer::draw_x_note(const Plot& plot, const PlotLayout& layout) {
// A sliver of axes gets no caption rather than one hanging over the frame: the panel is chrome
// *inside* the axes, and a backplate crossing the axis line would read as a second plot.
if (panel.w > axes.w || panel.h > axes.h) return;
draw_readout_panel(panel, style_.axis_line);
draw_readout_panel(panel, style_.axis_line, kLegendPanelAlpha);
// Bottom-left, beside the labels it qualifies -- the reader adds the caption to a tick, so it
// belongs at the end of the tick row and not in a corner of its own. `glyphs_under_` because
// the panel is flushed with the frame's line geometry and the under-text batch goes out after
@@ -2121,12 +2238,13 @@ void Renderer::draw_selection_frame(const PlotLayout& layout) {
flush_lines(pixel_mapping(), style_.selection_edge, style_.device(1.0f));
}
void Renderer::draw_readout_panel(const Rect& panel, Color border) {
void Renderer::draw_readout_panel(const Rect& panel, Color border, float background_alpha) {
if (!(panel.w > 0.0f) || !(panel.h > 0.0f)) return;
// The frame's own background at a partial alpha, which dims whatever it covers instead of erasing
// it -- the one backplate both readout panels use.
// The frame's own background scaled by the caller's opacity, which dims whatever it covers
// -- or, at full opacity, covers it outright: a badge read against ticks or a noisy trace
// is read *through* nothing.
Color background = style_.background;
background.a *= kLegendPanelAlpha;
background.a *= background_alpha;
fill_pixels(panel, background);
// The border frames the pixels the fill covers, not the measured rect's own fractional edges:
// those are wherever the content happened to measure to, and a hairline drawn on one is lit
@@ -2225,14 +2343,47 @@ void Renderer::draw_crosshair_lines(const Plot& plot, const PlotLayout& layout)
}
}
void Renderer::draw_crosshair_axis_badges(const PlotLayout& layout) {
const CrosshairLayout& crosshair = layout.crosshair;
if (!crosshair.active) return;
const bool y_badge = crosshair.y_axis_badge.w > 0.0f && !crosshair.y_axis_text.empty();
const bool x_badge = crosshair.x_axis_badge.w > 0.0f && !crosshair.x_axis_text.empty();
if (!y_badge && !x_badge) return;
// The same backplate the readout panel has, its border in the crosshair's colour too --
// the badge is that panel's value column, moved to where the line meets the axis it
// names. Drawn here rather than in the readout pass because it sits in a *label strip*:
// the strips are drawn after the interior clip is cleared, so a badge that lived in the
// clipped pass would be trimmed to nothing.
//
// **The tick labels are flushed first, and that is the whole point of this panel.** They
// are staged into the over-text batch, which the frame flushes after *every* line batch --
// so a fill drawn straight away would sit under them and the label it covers would print
// through the badge (the "transparent" backplate). They are already placed at this point,
// so pushing them out costs one batch: the fill then covers them, and the badge's own text
// is staged after both fills and leaves with the rest of the over batch, over everything.
flush_text(glyphs_over_);
const f32 pad = style_.device(4.0f);
if (y_badge) draw_readout_panel(crosshair.y_axis_badge, style_.crosshair, 1.0f);
if (x_badge) draw_readout_panel(crosshair.x_axis_badge, style_.crosshair, 1.0f);
if (y_badge) {
stage_text(glyphs_over_,
{crosshair.y_axis_badge.x + pad, crosshair.y_axis_badge.y + pad},
crosshair.y_axis_text.c_str(), style_.axis_text);
}
if (x_badge) {
stage_text(glyphs_over_,
{crosshair.x_axis_badge.x + pad, crosshair.x_axis_badge.y + pad},
crosshair.x_axis_text.c_str(), style_.axis_text);
}
}
void Renderer::draw_crosshair_readout(const Plot& plot, const PlotLayout& layout) {
const CrosshairLayout& crosshair = layout.crosshair;
if (crosshair.empty()) return;
// The readout: the same backplate a legend has (the shared `draw_readout_panel()`), with its
// border in the crosshair's own colour -- which is what makes the panel read as part of the
// instrument that placed it rather than as a second legend.
draw_readout_panel(crosshair.panel, style_.crosshair);
draw_readout_panel(crosshair.panel, style_.crosshair, kLegendPanelAlpha);
for (const CrosshairRow& row : crosshair.rows) {
if (!row.listed) continue;
+288 -2
View File
@@ -1257,10 +1257,11 @@ PLOT_TEST(figure_cells_share_x_link_and_crosshair) {
}
PLOT_TEST(figure_cells_x_labels_bottom_row_only) {
plot::Figure figure = split_figure({1, 2});
// Rows with the same columns share the bottom one's axis: {1,1,1} labels the last row only.
plot::Figure figure = split_figure({1, 1, 1});
const plot::FigureLayout& fl = figure.layout();
PLOT_CHECK(figure.plot(0u).layout().x_label_area.h == 0.0f);
PLOT_CHECK(figure.plot(1u).layout().x_label_area.h > 0.0f);
PLOT_CHECK(figure.plot(1u).layout().x_label_area.h == 0.0f);
PLOT_CHECK(figure.plot(2u).layout().x_label_area.h > 0.0f);
PLOT_CHECK_NEAR(fl.x_axis_strip.h, figure.plot(2u).layout().x_label_area.h, 0.001);
}
@@ -1291,6 +1292,44 @@ PLOT_TEST(figure_row_y_cells_share_a_range_after_a_gesture) {
PLOT_CHECK(!figure.row_y_auto(0u));
}
/// The horizontal line is one *value*, and a split row's cells share one y scale: the line
/// spans the whole row instead of stopping at the divider between two cells that read the same
/// numbers. The rows of the figure still do not share y, so no other row gets one.
PLOT_TEST(figure_crosshair_horizontal_spans_a_split_rows_cells) {
plot::Figure figure = split_figure({2, 1});
figure.set_share_row_y(true);
figure.layout();
// The pointer in the *second* cell of the split row, half-way down it.
const plot::Rect cell1 = figure.layout().cells[1];
const plot::Transform ty = figure.plot(1u).y_transform(0u);
const plot::f32 y = static_cast<plot::f32>(ty.to_data_y(cell1.y + 0.5f * cell1.h));
figure.set_crosshair(50.0, 1u, y, true);
figure.layout();
const plot::CrosshairLayout& left = figure.plot(0u).layout().crosshair;
const plot::CrosshairLayout& right = figure.plot(1u).layout().crosshair;
const plot::CrosshairLayout& other = figure.plot(2u).layout().crosshair;
// Both cells of the row carry the line, at one screen row: they share the y scale, so the
// row's own y is the same value's height in each.
PLOT_CHECK(left.horizontal.w > 0.0f);
PLOT_CHECK(right.horizontal.w > 0.0f);
PLOT_CHECK_NEAR(left.horizontal.y + 0.5f * left.horizontal.h,
right.horizontal.y + 0.5f * right.horizontal.h, 0.75);
// The unsplit row keeps the shared-x vertical line and no horizontal one -- a line there
// would mean a value on a scale that row does not have.
PLOT_CHECK(other.vertical.w > 0.0f);
PLOT_CHECK(other.horizontal.h == 0.0f);
// The spread does not touch the x: both cells keep the one shared column.
PLOT_CHECK(left.vertical.w > 0.0f);
PLOT_CHECK(right.vertical.w > 0.0f);
// Without `set_share_row_y` the cells are separate scales again, and the line is the
// hovered cell's alone.
figure.set_share_row_y(false);
figure.set_crosshair(50.0, 1u, y, true);
figure.layout();
PLOT_CHECK(figure.plot(1u).layout().crosshair.horizontal.w > 0.0f);
PLOT_CHECK(figure.plot(0u).layout().crosshair.horizontal.h == 0.0f);
}
PLOT_TEST(figure_row_y_auto_fits_the_union_and_ignores_empty_cells) {
plot::Figure figure = split_figure({3});
for (plot::u32 i = 0; i < 3u; ++i) figure.plot(i).set_y_auto_scale(0u, true);
@@ -1408,3 +1447,250 @@ PLOT_TEST(figure_row_y_sharing_off_still_gives_every_cell_a_gutter) {
figure.layout();
PLOT_CHECK(figure.plot(1u).layout().y_axis_width > 0.0f);
}
// ------------------------------------------------- x axes follow the columns
namespace {
/// A split figure with a text backend and a y axis of a real magnitude on every plot (so the
/// shared y gutter is non-zero), the host's configuration: row-shared y, a 6 px column gap and
/// a 35 px right gutter.
plot::Figure host_like_figure(const std::vector<plot::u32>& cells, bool share_y = true) {
plot::Figure figure = split_figure(cells, false);
for (plot::u32 i = 0; i < figure.plot_count(); ++i) give_magnitude_row(figure.plot(i), i);
figure.set_col_gap(6.0f);
figure.set_right_gutter(35.0f);
figure.set_share_row_y(share_y);
return figure;
}
bool shows_x_axis(plot::Figure& figure, plot::u32 plot_index) {
const plot::PlotLayout& l = figure.plot(plot_index).layout();
return l.x_axis_height > 0.0f && l.x_label_area.h > 0.0f;
}
bool hides_x_axis(plot::Figure& figure, plot::u32 plot_index) {
const plot::PlotLayout& l = figure.plot(plot_index).layout();
return l.x_axis_height == 0.0f && l.x_label_area.h == 0.0f;
}
} // namespace
PLOT_TEST(figure_x_axis_a_split_row_over_an_unsplit_one_keeps_its_own) {
plot::Figure figure = host_like_figure({2, 1});
figure.layout();
PLOT_CHECK(shows_x_axis(figure, 0u));
PLOT_CHECK(shows_x_axis(figure, 1u));
PLOT_CHECK(shows_x_axis(figure, 2u));
// ... and the other way round.
plot::Figure other = host_like_figure({1, 2});
other.layout();
PLOT_CHECK(shows_x_axis(other, 0u));
PLOT_CHECK(shows_x_axis(other, 1u));
PLOT_CHECK(shows_x_axis(other, 2u));
}
PLOT_TEST(figure_x_axis_rows_with_the_same_columns_share_the_bottom_axis) {
plot::Figure figure = host_like_figure({2, 2});
figure.layout();
PLOT_CHECK(hides_x_axis(figure, 0u));
PLOT_CHECK(hides_x_axis(figure, 1u));
PLOT_CHECK(shows_x_axis(figure, 2u));
PLOT_CHECK(shows_x_axis(figure, 3u));
// The hidden row's cells have exactly the x extents of the cells below, so the axis below
// serves them.
const plot::FigureLayout& fl = figure.layout();
for (plot::u32 c = 0; c < 2u; ++c) {
PLOT_CHECK_NEAR(fl.cells[c].x, fl.cells[2u + c].x, 1e-4);
PLOT_CHECK_NEAR(fl.cells[c].w, fl.cells[2u + c].w, 1e-4);
const plot::PlotLayout& up = figure.plot(c).layout();
const plot::PlotLayout& down = figure.plot(2u + c).layout();
PLOT_CHECK_NEAR(up.axes.x, down.axes.x, 1e-4);
PLOT_CHECK_NEAR(up.axes.w, down.axes.w, 1e-4);
PLOT_CHECK_NEAR(up.y_axis_width, down.y_axis_width, 1e-4);
}
// The same without row-shared y (every cell has its own y strip).
plot::Figure own_y = host_like_figure({2, 2}, false);
own_y.layout();
PLOT_CHECK(hides_x_axis(own_y, 0u) && hides_x_axis(own_y, 1u));
PLOT_CHECK(shows_x_axis(own_y, 2u) && shows_x_axis(own_y, 3u));
for (plot::u32 c = 0; c < 2u; ++c) {
PLOT_CHECK_NEAR(own_y.plot(c).layout().axes.x, own_y.plot(2u + c).layout().axes.x, 1e-4);
PLOT_CHECK_NEAR(own_y.plot(c).layout().axes.w, own_y.plot(2u + c).layout().axes.w, 1e-4);
}
}
PLOT_TEST(figure_x_axis_matching_compares_normalised_weights) {
const std::vector<plot::f32> one_three{1.0f, 3.0f};
const std::vector<plot::f32> two_six{2.0f, 6.0f};
const std::vector<plot::f32> halves{1.0f, 1.0f};
{ // 1:3 over 1:3
plot::Figure figure = host_like_figure({2, 2});
PLOT_CHECK(figure.set_cell_weights(0u, one_three));
PLOT_CHECK(figure.set_cell_weights(1u, one_three));
figure.layout();
PLOT_CHECK(hides_x_axis(figure, 0u) && hides_x_axis(figure, 1u));
PLOT_CHECK(shows_x_axis(figure, 2u) && shows_x_axis(figure, 3u));
const plot::FigureLayout& fl = figure.layout();
PLOT_CHECK_NEAR(fl.cells[0].w, fl.cells[2].w, 1e-3);
PLOT_CHECK_NEAR(figure.plot(1u).layout().axes.x, figure.plot(3u).layout().axes.x, 1e-3);
}
{ // 1:3 over 2:6 -- normalised, so the same columns
plot::Figure figure = host_like_figure({2, 2});
PLOT_CHECK(figure.set_cell_weights(0u, one_three));
PLOT_CHECK(figure.set_cell_weights(1u, two_six));
figure.layout();
PLOT_CHECK(hides_x_axis(figure, 0u) && hides_x_axis(figure, 1u));
}
{ // 1:3 over 1:1
plot::Figure figure = host_like_figure({2, 2});
PLOT_CHECK(figure.set_cell_weights(0u, one_three));
PLOT_CHECK(figure.set_cell_weights(1u, halves));
figure.layout();
PLOT_CHECK(shows_x_axis(figure, 0u) && shows_x_axis(figure, 1u));
PLOT_CHECK(shows_x_axis(figure, 2u) && shows_x_axis(figure, 3u));
}
{ // 1:3 over (no weights = equal)
plot::Figure figure = host_like_figure({2, 2});
PLOT_CHECK(figure.set_cell_weights(0u, one_three));
figure.layout();
PLOT_CHECK(shows_x_axis(figure, 0u) && shows_x_axis(figure, 1u));
}
{ // explicit 1:1 over (no weights = equal)
plot::Figure figure = host_like_figure({2, 2});
PLOT_CHECK(figure.set_cell_weights(0u, halves));
figure.layout();
PLOT_CHECK(hides_x_axis(figure, 0u) && hides_x_axis(figure, 1u));
}
{ // a different number of cells never matches, whatever the weights
plot::Figure figure = host_like_figure({3, 2});
figure.layout();
PLOT_CHECK(shows_x_axis(figure, 0u) && shows_x_axis(figure, 2u));
}
}
PLOT_TEST(figure_x_axis_unsplit_rows_are_unchanged) {
plot::Figure figure = host_like_figure({1, 1, 1});
figure.layout();
PLOT_CHECK(hides_x_axis(figure, 0u));
PLOT_CHECK(hides_x_axis(figure, 1u));
PLOT_CHECK(shows_x_axis(figure, 2u));
PLOT_CHECK_NEAR(figure.layout().x_axis_strip.h, figure.plot(2u).layout().x_label_area.h, 1e-4);
}
PLOT_TEST(figure_x_axis_follows_a_chain_of_matching_rows) {
{ // [2]/[2]/[1]: row 0 matches row 1 and hides; row 1 does not match row 2 and shows.
plot::Figure figure = host_like_figure({2, 2, 1});
figure.layout();
PLOT_CHECK(hides_x_axis(figure, 0u) && hides_x_axis(figure, 1u));
PLOT_CHECK(shows_x_axis(figure, 2u) && shows_x_axis(figure, 3u));
PLOT_CHECK(shows_x_axis(figure, 4u));
}
{ // [2]/[2]/[2]: transitive -- only the last row carries the axis.
plot::Figure figure = host_like_figure({2, 2, 2});
figure.layout();
for (plot::u32 i = 0; i < 4u; ++i) PLOT_CHECK(hides_x_axis(figure, i));
PLOT_CHECK(shows_x_axis(figure, 4u) && shows_x_axis(figure, 5u));
}
{ // [1]/[2]/[2]: the unsplit row matches nothing below it.
plot::Figure figure = host_like_figure({1, 2, 2});
figure.layout();
PLOT_CHECK(shows_x_axis(figure, 0u));
PLOT_CHECK(hides_x_axis(figure, 1u) && hides_x_axis(figure, 2u));
PLOT_CHECK(shows_x_axis(figure, 3u) && shows_x_axis(figure, 4u));
}
{ // the figure's x_axis_strip is still the last plot's label area
plot::Figure figure = host_like_figure({2, 1});
const plot::FigureLayout& fl = figure.layout();
PLOT_CHECK(fl.x_axis_strip.h > 0.0f);
PLOT_CHECK_NEAR(fl.x_axis_strip.h, figure.plot(2u).layout().x_label_area.h, 1e-4);
}
}
PLOT_TEST(figure_x_axis_labels_on_every_row_when_asked) {
plot::Figure figure = host_like_figure({2, 2, 1});
figure.set_x_labels_only_on_bottom(false);
figure.layout();
for (plot::u32 i = 0; i < figure.plot_count(); ++i) PLOT_CHECK(shows_x_axis(figure, i));
}
// -------------------------------------------- the gap between cells of a row
PLOT_TEST(figure_split_row_gap_between_plot_frames_is_the_column_gap) {
for (const plot::u32 n : {2u, 3u, 4u}) {
plot::Figure figure = host_like_figure({n});
const plot::FigureLayout& fl = figure.layout();
PLOT_CHECK_NEAR(fl.col_gap, 6.0, 1e-4);
for (plot::u32 k = 0; k + 1u < n; ++k) {
PLOT_CHECK_NEAR(figure.plot(k + 1u).layout().axes.x - figure.plot(k).layout().axes.right(),
6.0, 1e-3);
PLOT_CHECK_NEAR(fl.cells[k + 1u].x - fl.cells[k].right(), 6.0, 1e-3);
// Only the last cell holds the right gutter.
PLOT_CHECK_NEAR(figure.plot(k).layout().right_gutter, 0.0, 1e-6);
}
// Equal weights give equal axes widths, the first cell included.
for (plot::u32 k = 1; k < n; ++k) {
PLOT_CHECK_NEAR(figure.plot(k).layout().axes.w, figure.plot(0u).layout().axes.w, 0.6);
}
// The cells tile the row exactly.
PLOT_CHECK_NEAR(fl.cells[0].x, fl.rows[0].x, 1e-4);
PLOT_CHECK_NEAR(fl.cells[n - 1u].right(), fl.rows[0].right(), 1e-3);
// The right gutter is still there, on the last cell.
const plot::PlotLayout& last = figure.plot(n - 1u).layout();
PLOT_CHECK_NEAR(fl.cells[n - 1u].right() - last.axes.right(), 35.0, 1e-3);
PLOT_CHECK_NEAR(last.right_gutter, 35.0, 1e-3);
// The y gutter still sits on the first cell alone.
PLOT_CHECK(figure.plot(0u).layout().axes.x > fl.cells[0].x);
PLOT_CHECK_NEAR(figure.plot(1u).layout().axes.x, fl.cells[1].x, 1e-4);
}
}
PLOT_TEST(figure_split_row_gap_with_weights_and_a_y_gutter) {
plot::Figure figure = host_like_figure({1, 2});
const std::vector<plot::f32> w{3.0f, 1.0f};
PLOT_CHECK(figure.set_cell_weights(1u, w));
const plot::FigureLayout& fl = figure.layout();
const plot::Rect a0 = figure.plot(1u).layout().axes;
const plot::Rect a1 = figure.plot(2u).layout().axes;
PLOT_CHECK_NEAR(a1.x - a0.right(), 6.0, 1e-3);
PLOT_CHECK_NEAR(a0.w - 64.0, (a1.w - 64.0) * 3.0, 1.0);
PLOT_CHECK_NEAR(fl.cells[2].right(), fl.rows[1].right(), 1e-3);
PLOT_CHECK_NEAR(fl.cells[1].x, fl.rows[1].x, 1e-4);
PLOT_CHECK_NEAR(fl.cells[2].right() - a1.right(), 35.0, 1e-3);
// The unsplit row above keeps its own right gutter on its one plot.
PLOT_CHECK_NEAR(fl.cells[0].right() - figure.plot(0u).layout().axes.right(), 35.0, 1e-3);
}
PLOT_TEST(figure_split_row_gutters_degrade_on_a_narrow_row) {
plot::Figure figure = host_like_figure({3});
figure.set_viewport(plot::Rect{0.0f, 0.0f, 60.0f, 100.0f});
const plot::FigureLayout& fl = figure.layout();
double sum = 0.0;
for (const plot::Rect& c : fl.cells) {
PLOT_CHECK(c.w >= 0.0f && c.h >= 0.0f);
sum += c.w;
}
PLOT_CHECK_NEAR(sum + fl.col_gap * 2.0, 60.0, 0.05);
PLOT_CHECK_NEAR(fl.cells[2].right(), 60.0, 0.05);
for (plot::u32 i = 0; i < 3u; ++i) PLOT_CHECK(figure.plot(i).layout().axes.w >= 0.0f);
}
PLOT_TEST(figure_row_gutter_without_sharing_is_the_old_layout) {
plot::Figure figure = host_like_figure({2}, false);
const plot::FigureLayout& fl = figure.layout();
// Plain cells: equal halves of the row less the gap, every plot with its own right gutter.
PLOT_CHECK_NEAR(fl.cells[0].w, (fl.rows[0].w - 6.0) / 2.0, 1e-3);
PLOT_CHECK_NEAR(fl.cells[1].w, (fl.rows[0].w - 6.0) / 2.0, 1e-3);
PLOT_CHECK_NEAR(fl.cells[1].x - fl.cells[0].right(), 6.0, 1e-4);
for (plot::u32 i = 0; i < 2u; ++i) {
PLOT_CHECK_NEAR(figure.plot(i).layout().right_gutter, 35.0, 1e-3);
PLOT_CHECK_NEAR(fl.cells[i].right() - figure.plot(i).layout().axes.right(), 35.0, 1e-3);
}
// An unsplit row is unchanged by sharing being on.
plot::Figure unsplit = host_like_figure({1, 1});
const plot::FigureLayout& ul = unsplit.layout();
for (plot::u32 i = 0; i < 2u; ++i) {
PLOT_CHECK_NEAR(ul.cells[i].w, ul.rows[i].w, 1e-4);
PLOT_CHECK_NEAR(ul.cells[i].right() - unsplit.plot(i).layout().axes.right(), 35.0, 1e-3);
}
}
+88 -11
View File
@@ -5750,17 +5750,22 @@ PLOT_TEST(horizontal_ruler_label_reads_its_value) {
const InkExtent ink = ink_extent(frame, kW, kH, ruler_pixel);
PLOT_CHECK(ink.pixels > 0u);
// One horizontal run: shallow in rows, spanning essentially the axes' own width.
PLOT_CHECK(ink.rows() <= 3u);
// One horizontal run: the stroke itself is shallow, but the label's backplate is drawn in
// the ruler's own colour too (see the clustered cursor labels), and its border spans one
// text line plus its padding above and below the stroke -- measured 28 rows on the suite's
// own font. The bound is that panel with a stroke's worth of slack, not the old bare-text
// run of three.
PLOT_CHECK(ink.rows() <= 32u);
PLOT_CHECK(ink.cols() + 2u >= static_cast<plot::u32>(axes.w));
// And at the row the transform says -- a ruler drawn at the wrong y, or a vertical column
// that happens to be inside the rect, cannot satisfy both of these.
PLOT_CHECK(static_cast<plot::f32>(ink.top) <= expected_row + 1.0f);
PLOT_CHECK(static_cast<plot::f32>(ink.bottom) >= expected_row - 1.0f);
// The label: text ink just under the ruler at its left end, inside the axes. The tick
// The label: text ink centred on the ruler at its left end, inside the axes. The tick
// labels are outside `axes`, in their own strips, so this can only be the ruler's value.
const plot::Rect label{axes.x + 1.0f, expected_row, 60.0f, 16.0f};
// Centred, not below: the badge straddles the ruler's own row (see the clustered labels).
const plot::Rect label{axes.x + 1.0f, expected_row - 9.0f, 60.0f, 18.0f};
PLOT_CHECK(predicate_pixels(frame, kW, label, text_pixel) > 0u);
// The clip: every ruler-coloured pixel of the frame is inside `axes`.
@@ -6096,15 +6101,65 @@ PLOT_TEST(crosshair_ink_stays_inside_the_axes) {
PLOT_CHECK(crosshair.rows[2].marker.x < axes.x);
PLOT_CHECK(crosshair.rows[2].marker.x >= layout.y_label_area.x);
// Every crosshair-coloured pixel of the frame is inside `axes`, and there are some: not one in a
// label or title strip, not one over the host's surface outside the viewport.
// Every crosshair-coloured pixel of the frame is inside `axes` or inside one of the two
// axis badges, and there are some. The badges are the deliberate exception -- the
// crosshair's own axis readings, drawn in the strips the ticks are written in (see
// `Crosshair::axis_labels`) -- and the layout named their rects, so the exception is
// checked against exactly what was placed, not against a loose region.
PLOT_CHECK(crosshair.y_axis_badge.w > 0.0f);
PLOT_CHECK(crosshair.x_axis_badge.w > 0.0f);
PLOT_CHECK(crosshair.y_axis_badge.right() <= axes.x + 0.5f); // beside the y ticks
PLOT_CHECK(crosshair.x_axis_badge.y >= axes.bottom() - 0.5f); // beside the x ticks
const plot::u32 inside = predicate_pixels(frame, kW, axes, crosshair_pixel);
// The badge rects are the panels' own frames, and a hairline's anti-aliased fringe can sit
// half a pixel outside one -- the badge's own ink either way, so the counts read over the
// rects inflated by a pixel. The inflation is held out of the axes: a badge sits *against*
// an axis line, and a fringe column read into the axes' own pixels would be counted twice.
const auto inflate_badge = [&axes](const plot::Rect& rect, bool toward_axes) {
plot::Rect out{rect.x - 1.0f, rect.y - 1.0f, rect.w + 2.0f, rect.h + 2.0f};
if (toward_axes && out.right() > axes.x) out.w -= out.right() - axes.x;
if (!toward_axes && out.y < axes.bottom()) {
const f32 d = axes.bottom() - out.y;
out.y += d;
out.h -= d;
}
return out;
};
const plot::Rect y_badge_rect = inflate_badge(crosshair.y_axis_badge, true);
const plot::Rect x_badge_rect = inflate_badge(crosshair.x_axis_badge, false);
const plot::u32 y_badge = predicate_pixels(frame, kW, y_badge_rect, crosshair_pixel);
const plot::u32 x_badge = predicate_pixels(frame, kW, x_badge_rect, crosshair_pixel);
PLOT_CHECK(inside > 0u);
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, crosshair_pixel) == inside);
PLOT_CHECK(predicate_pixels(frame, kW, layout.y_label_area, crosshair_pixel) == 0u);
PLOT_CHECK(predicate_pixels(frame, kW, layout.y_title_area, crosshair_pixel) == 0u);
PLOT_CHECK(predicate_pixels(frame, kW, layout.x_label_area, crosshair_pixel) == 0u);
PLOT_CHECK(predicate_pixels(frame, kW, layout.x_title_area, crosshair_pixel) == 0u);
PLOT_CHECK(y_badge > 0u);
PLOT_CHECK(x_badge > 0u);
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, crosshair_pixel) ==
inside + y_badge + x_badge);
// The badges may reach past the label strip into the title strip beside it -- a badge is as
// wide as its own text and is capped by nothing but the strips' union -- so the title
// strips' rule is: **every crosshair-coloured pixel is the badge's own**, i.e. within one
// badge rect's one-pixel fringe. A stray pixel there is a second drawing in a strip that
// belongs to the axis title.
const auto title_stray = [&](const plot::Rect& strip, const plot::Rect& badge) {
const plot::Rect frame_rect{badge.x - 1.0f, badge.y - 1.0f, badge.w + 2.0f, badge.h + 2.0f};
plot::u32 stray = 0;
for (plot::i32 y = static_cast<plot::i32>(strip.y);
y < static_cast<plot::i32>(strip.bottom()); ++y) {
for (plot::i32 x = static_cast<plot::i32>(strip.x);
x < static_cast<plot::i32>(strip.right()); ++x) {
const plot::f32 fx = static_cast<plot::f32>(x);
const plot::f32 fy = static_cast<plot::f32>(y);
const plot::u8* pixel =
&frame[(static_cast<plot::usize>(y) * kW + static_cast<plot::usize>(x)) * 4u];
if (!crosshair_pixel(pixel)) continue;
const bool own = fx >= frame_rect.x && fx < frame_rect.x + frame_rect.w &&
fy >= frame_rect.y && fy < frame_rect.y + frame_rect.h;
if (!own) ++stray;
}
}
return stray;
};
PLOT_CHECK(title_stray(layout.y_title_area, crosshair.y_axis_badge) == 0u);
PLOT_CHECK(title_stray(layout.x_title_area, crosshair.x_axis_badge) == 0u);
plot::u8 host[4] = {};
PLOT_CHECK(ctx.read_pixel(4u, kH - 4u, host));
PLOT_CHECK(host[0] > 100 && host[0] < 160 && host[1] > 100 && host[2] > 100);
@@ -6133,6 +6188,21 @@ PLOT_TEST(crosshair_ink_stays_inside_the_axes) {
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, magenta_pixel) ==
predicate_pixels(frame, kW, axes, magenta_pixel));
// The same plot with the badges off: the layout places none, and the strips go back to
// being clean of the crosshair's colour -- the flag is the whole difference between the
// two frames, and this leg is what makes it a setting rather than a behaviour.
p.set_crosshair_axis_labels(false);
p.layout();
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
const std::vector<plot::u8> off_frame = read_frame(ctx);
PLOT_CHECK(p.layout().crosshair.y_axis_badge.w == 0.0f);
PLOT_CHECK(p.layout().crosshair.x_axis_badge.w == 0.0f);
PLOT_CHECK(predicate_pixels(off_frame, kW, layout.y_label_area, crosshair_pixel) == 0u);
PLOT_CHECK(predicate_pixels(off_frame, kW, layout.x_label_area, crosshair_pixel) == 0u);
ctx.destroy();
}
@@ -6278,6 +6348,13 @@ PLOT_TEST(crosshair_dash_is_configurable_and_zero_off_means_solid) {
// 4 on / 4 off over the axes' width: many dashes, not one line.
const plot::u32 dashed = runs(4.0f);
PLOT_CHECK(dashed > 4u);
// The y axis badge is **centred** on the line it names, the way the x badge is centred on
// its column -- its middle is the line's own screen row. A badge hung from the line (top
// edge on it) is half a badge off, which is exactly what this reads for.
const plot::Rect badge = p.layout().crosshair.y_axis_badge;
PLOT_CHECK(badge.w > 0.0f);
PLOT_CHECK_NEAR(badge.y + 0.5f * badge.h, cyp, 0.6f);
// Off == 0 is solid: one run, the whole width, no gap anywhere.
PLOT_CHECK(runs(0.0f) == 1u);
// A longer gap is a longer period, so fewer dashes over the same width.