From 1d5b2866119b52b9452770568b3a8a367fc28faf Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Thu, 5 Nov 2026 17:58:49 +0100 Subject: [PATCH] crosshair: axis badges, clustered cursor labels, and the shared-y horizontal line - 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. --- include/plot/plot/crosshair.hpp | 19 +++ include/plot/plot/figure.hpp | 9 +- include/plot/plot/plot.hpp | 16 +++ include/plot/render/renderer.hpp | 55 +++++++-- src/plot/figure.cpp | 15 +++ src/plot/plot.cpp | 65 ++++++++++ src/render/renderer.cpp | 202 +++++++++++++++++++++++++++---- tests/test_figure.cpp | 38 ++++++ tests/test_render_smoke.cpp | 99 +++++++++++++-- 9 files changed, 469 insertions(+), 49 deletions(-) diff --git a/include/plot/plot/crosshair.hpp b/include/plot/plot/crosshair.hpp index 203a450..579658f 100644 --- a/include/plot/plot/crosshair.hpp +++ b/include/plot/plot/crosshair.hpp @@ -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. diff --git a/include/plot/plot/figure.hpp b/include/plot/plot/figure.hpp index 8511b51..a60bd15 100644 --- a/include/plot/plot/figure.hpp +++ b/include/plot/plot/figure.hpp @@ -247,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. /// diff --git a/include/plot/plot/plot.hpp b/include/plot/plot/plot.hpp index df0b38c..2d7cd0b 100644 --- a/include/plot/plot/plot.hpp +++ b/include/plot/plot/plot.hpp @@ -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) diff --git a/include/plot/render/renderer.hpp b/include/plot/render/renderer.hpp index 0389653..1b28f6d 100644 --- a/include/plot/render/renderer.hpp +++ b/include/plot/render/renderer.hpp @@ -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; @@ -621,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& 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& 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 @@ -656,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 @@ -687,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 diff --git a/src/plot/figure.cpp b/src/plot/figure.cpp index f9f3bd7..22c704c 100644 --- a/src/plot/figure.cpp +++ b/src/plot/figure.cpp @@ -819,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() { diff --git a/src/plot/plot.cpp b/src/plot/plot.cpp index 4d7ad01..3749afa 100644 --- a/src/plot/plot.cpp +++ b/src/plot/plot.cpp @@ -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; @@ -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(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(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 diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index 4ed710b..407795e 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -565,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 { @@ -1675,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 labels; for (u32 i = 0; i < plot.cursor_count(); ++i) { const Cursor& cursor = plot.cursor(i); @@ -1730,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& 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& 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 placed; + placed.reserve(labels.size()); + std::vector 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 bounds; + std::vector> groups; + std::vector 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); + } } } @@ -2023,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 @@ -2053,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 @@ -2122,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 @@ -2226,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; diff --git a/tests/test_figure.cpp b/tests/test_figure.cpp index cbe0608..b58e8f8 100644 --- a/tests/test_figure.cpp +++ b/tests/test_figure.cpp @@ -1292,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(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); diff --git a/tests/test_render_smoke.cpp b/tests/test_render_smoke.cpp index 0e65842..a57edbc 100644 --- a/tests/test_render_smoke.cpp +++ b/tests/test_render_smoke.cpp @@ -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(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(ink.top) <= expected_row + 1.0f); PLOT_CHECK(static_cast(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(strip.y); + y < static_cast(strip.bottom()); ++y) { + for (plot::i32 x = static_cast(strip.x); + x < static_cast(strip.right()); ++x) { + const plot::f32 fx = static_cast(x); + const plot::f32 fy = static_cast(y); + const plot::u8* pixel = + &frame[(static_cast(y) * kW + static_cast(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 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.