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.
This commit is contained in:
Martino Ferrari committed 2026-11-05 17:58:49 +01:00
1 parent 2cb31cbb91
commit 1d5b286611
9 files changed
+469 -49

No files matched your search

+38
View File
@@ -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<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);
+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.