feat(plot): the figure crosshair marks the hovered row, and the stroke is thin, dashed and configurable

The figure sync learns which row the pointer is in and that row's local y, so the
shared x goes to every row and the horizontal line only to the hovered one -- a
crosshair that finally marks a row where its y means something. The stroke gains
Style::crosshair_width/_dash_on/_dash_off (logical; 0.75 / 4 / 4), a per-Crosshair
override, and a fragment-shader dash on v_along (off == 0 is solid), anchored to the
axes edge each line is staged from so the phase does not crawl.
This commit is contained in:
Martino Ferrari committed 2026-09-28 20:29:09 +02:00
1 parent eee7c34dd5
commit f8a364e963
3 files changed
+506

No files matched your search

+371
View File
@@ -4907,3 +4907,374 @@ PLOT_TEST(crosshair_ink_stays_inside_the_axes) {
ctx.destroy();
}
// ------------------------------------------------- the crosshair's style (spec §17)
namespace {
/// The `mod(v_along, on + off)` pattern is a statement about a *line's length*, so the helpers below
/// read the frame one dimensionally: a boolean row (which columns hold ink) whose maximal `true`
/// runs are the dashes.
bool column_has_ink(const std::vector<plot::u8>& frame, plot::u32 fb_w, plot::i32 x, plot::i32 y0,
plot::i32 y1, bool (*pred)(const plot::u8[4])) {
if (x < 0 || x >= static_cast<plot::i32>(fb_w)) return false;
for (plot::i32 y = std::max<plot::i32>(0, y0); y < y1; ++y) {
if (pred(&frame[(plot::usize(y) * fb_w + plot::usize(x)) * 4u])) return true;
}
return false;
}
/// How many maximal `true` runs a boolean row holds: one for a solid line, and about `w / (on + off)`
/// for a dash of period `on + off` across a width `w`.
plot::u32 run_count(const std::vector<bool>& on) {
plot::u32 runs = 0;
for (plot::usize i = 0; i < on.size(); ++i) {
if (on[i] && (i == 0u || !on[i - 1u])) ++runs;
}
return runs;
}
/// The boolean row `[x0, x1)` in which each entry is whether that column holds any `pred` pixel in
/// rows `[y0, y1)`, plus a one-column count of the rows it is true in.
std::vector<bool> ink_row(const std::vector<plot::u8>& frame, plot::u32 fb_w, plot::i32 x0,
plot::i32 x1, plot::i32 y0, plot::i32 y1,
bool (*pred)(const plot::u8[4])) {
std::vector<bool> on;
on.reserve(static_cast<plot::usize>(std::max(0, x1 - x0)));
for (plot::i32 x = x0; x < x1; ++x) {
on.push_back(column_has_ink(frame, fb_w, x, y0, y1, pred));
}
return on;
}
/// The framebuffer column of `[x0, x1)` holding the most pixels `pred` accepts in rows `[y0, y1)`,
/// and that count -- how a case finds the one column a vertical stroke lives in without guessing
/// where a transform put it.
plot::i32 densest_column(const std::vector<plot::u8>& frame, plot::u32 fb_w, plot::i32 x0,
plot::i32 x1, plot::i32 y0, plot::i32 y1,
bool (*pred)(const plot::u8[4]), plot::u32* out_count) {
plot::i32 best = -1;
plot::u32 best_n = 0;
for (plot::i32 x = x0; x < x1; ++x) {
plot::u32 n = 0;
for (plot::i32 y = std::max<plot::i32>(0, y0); y < y1; ++y) {
if (pred(&frame[(plot::usize(y) * fb_w + plot::usize(x)) * 4u])) ++n;
}
if (n > best_n) {
best_n = n;
best = x;
}
}
if (out_count != nullptr) *out_count = best_n;
return best;
}
/// The grid *majors* (0x2A3038 over the 0x101418 background), which no other element of these
/// frames reaches: the minors are 0x1C2126 (too dark for the bound), the crosshair is near-white,
/// a trace is saturated and the cursor is the orange accent.
bool grid_pixel(const plot::u8 px[4]) {
const plot::u8 lo = std::min(px[0], std::min(px[1], px[2]));
const plot::u8 hi = std::max(px[0], std::max(px[1], px[2]));
return px[0] >= 30 && px[0] <= 90 && hi <= 100 && static_cast<plot::u8>(hi - lo) <= 30;
}
/// The orange cursor accent 0xFFB020, at full coverage.
bool cursor_pixel(const plot::u8 px[4]) {
return px[0] > 200 && px[1] > 120 && px[1] < px[0] && px[2] < 90;
}
plot::u32 crosshair_ink_columns(const std::vector<plot::u8>& frame, plot::u32 fb_w,
const plot::Rect& axes) {
const plot::i32 row0 = static_cast<plot::i32>(axes.y) + static_cast<plot::i32>(axes.h) / 2;
plot::u32 cols = 0;
for (plot::i32 x = static_cast<plot::i32>(axes.x); x < static_cast<plot::i32>(axes.right()); ++x) {
if (column_has_ink(frame, fb_w, x, row0, row0 + 2, crosshair_pixel)) ++cols;
}
return cols;
}
} // namespace
/// Spec §17, the style: the crosshair is drawn **dashed** by default -- a passive hover instrument
/// reads as chrome rather than as data -- and the dash is a fragment-shader effect on `v_along`, so
/// nothing about the geometry changes. This case reads the pattern off the pixels: a dashed stroke
/// is many runs along its length, a solid one is a single run, and a longer gap is a longer period,
/// so **fewer** dashes over the same width. `dash_off == 0` is what makes it solid.
PLOT_TEST(crosshair_dash_is_configurable_and_zero_off_means_solid) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
const plot::Rect vp{20.0f, 20.0f, 600.0f, 320.0f};
plot::Plot p;
p.set_viewport(vp);
const plot::u32 ax = p.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
// A warm-up frame, so the strips have settled and `axes` below is a *drawn* frame's rect.
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
const plot::Rect axes = p.layout().axes;
const plot::Transform tx = p.x_transform(ax);
const plot::Transform ty = p.y_transform(0u);
// The horizontal line mid-axes, on a *pixel* centre so a 1 device px stroke lands on exactly one
// framebuffer row: the dash is what this case varies, and a fractional coverage would make the
// row's ink ragged for reasons that are not the dash.
const f32 cxp = std::floor(axes.x) + 0.5f + 60.0f;
const f32 cyp = std::floor(axes.y) + 0.5f + 40.0f;
p.set_crosshair(tx.to_data_x(static_cast<f64>(cxp)), ty.to_data_y(static_cast<f64>(cyp)), true);
const auto frame_with = [&](f32 off) {
// Width 1 device px: the 0.75 default's sub-pixel coverage the `crosshair_pixel` predicate (a
// full-coverage colour) would not count uniformly, and the dash is this case's subject.
p.set_crosshair_style({1.0f, 4.0f, off});
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
return read_frame(ctx);
};
const plot::i32 row = static_cast<plot::i32>(cyp);
const plot::i32 x0 = static_cast<plot::i32>(axes.x) + 1;
const plot::i32 x1 = static_cast<plot::i32>(axes.right()) - 1;
const auto runs = [&](f32 off) {
return run_count(ink_row(frame_with(off), kW, x0, x1, row - 1, row + 2, crosshair_pixel));
};
// 4 on / 4 off over the axes' width: many dashes, not one line.
const plot::u32 dashed = runs(4.0f);
PLOT_CHECK(dashed > 4u);
// 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.
const plot::u32 sparse = runs(12.0f);
PLOT_CHECK(sparse > 1u);
PLOT_CHECK(sparse < dashed);
ctx.destroy();
}
/// Spec §17, the stroke: "a **thinner** stroke (~0.75 logical px rather than a trace's line
/// width)". The claim is asserted twice -- as the numbers the layout resolves (what the renderer
/// hands the shader) and as the ink those numbers produce on the real driver -- so a change that
/// kept the number but stopped drawing at it, or the reverse, fails here.
PLOT_TEST(crosshair_is_thinner_than_a_trace) {
const plot::render::Style style;
const plot::Trace trace;
PLOT_CHECK(style.crosshair_width > 0.0f);
PLOT_CHECK(style.crosshair_width < trace.width); // 0.75 < a trace's 1.5
// The resolved stroke: the style's value through `device()`, doubled at `ui_scale` 2, and the
// crosshair's own override where it has one.
plot::Plot p;
p.set_viewport(plot::Rect{0.0f, 0.0f, 600.0f, 320.0f});
p.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
p.set_crosshair_style({-1.0f, 1.0e6f, 0.0f}); // width from the style; solid, for measurement
p.set_crosshair(1.0, true); // vertical-only: one line to measure
p.set_ui_scale(1.0f);
PLOT_CHECK_NEAR(p.layout().crosshair.stroke, style.crosshair_width, 1e-6);
p.set_ui_scale(2.0f);
PLOT_CHECK_NEAR(p.layout().crosshair.stroke, 2.0 * style.crosshair_width, 1e-6);
p.set_crosshair_style({1.25f, 1.0e6f, 0.0f});
PLOT_CHECK_NEAR(p.layout().crosshair.stroke, 2.5, 1e-6);
// ... and drawn: the ink width follows the width and the scale.
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
const plot::Rect vp{20.0f, 20.0f, 600.0f, 320.0f};
plot::Plot q;
q.set_viewport(vp);
const plot::u32 qx = q.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false});
q.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
r.style().crosshair_width = style.crosshair_width;
r.begin_frame(kW, kH, vp);
r.draw(q);
r.end_frame();
const plot::Rect axes = q.layout().axes;
q.set_crosshair_style({3.0f, 1.0e6f, 0.0f}); // solid, explicit width
q.set_crosshair(q.x_transform(qx).to_data_x(static_cast<f64>(std::floor(axes.x) + 120.5f)), true);
const auto ink_columns = [&](f32 scale) {
r.style().ui_scale = scale;
r.begin_frame(kW, kH, vp);
r.draw(q);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
const std::vector<plot::u8> frame = read_frame(ctx);
return crosshair_ink_columns(frame, kW, q.layout().axes);
};
const plot::u32 wide_1x = ink_columns(1.0f);
const plot::u32 wide_2x = ink_columns(2.0f);
PLOT_CHECK(wide_1x > 0u);
PLOT_CHECK(wide_2x > wide_1x); // `ui_scale` 2 doubles every metric, the stroke included
q.set_crosshair_style({1.0f, 1.0e6f, 0.0f});
const plot::u32 thin_1x = ink_columns(1.0f);
PLOT_CHECK(thin_1x >= 1u);
PLOT_CHECK(thin_1x < wide_1x); // a thinner stroke is thinner ink
ctx.destroy();
}
/// Spec §17's shared-pipeline guard: the dash is a *per-draw* uniform and only the crosshair sets
/// it, so every other line -- grid, traces, axis lines, regions, cursors, the legend border -- draws
/// exactly as before. The crosshair is drawn in the middle of the interior passes (after the grid,
/// the axis lines and the traces; before the regions, the cursors and the legend), so an unreset or
/// leaked dash would show up on an element drawn *after* it -- and this case draws one of each kind
/// around it and reads the pixels.
PLOT_TEST(grid_traces_and_cursors_are_unaffected_by_the_dash_uniform) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
const plot::Rect vp{20.0f, 20.0f, 600.0f, 320.0f};
// --- the grid and the cursor, with a dashed crosshair drawn between them ----------------
plot::Plot p;
p.set_viewport(vp);
const plot::u32 ax = p.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
const plot::u32 cur = p.add_cursor(37.0, plot::Color::hex(0xFFB020));
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
const plot::Rect axes = p.layout().axes;
const plot::Transform tx = p.x_transform(ax);
const plot::Transform ty = p.y_transform(0u);
// A dashed crosshair mid-axes, deliberately *between* ticks in x so its vertical line does not
// land on a grid column and muddy the grid reading below.
p.set_crosshair(tx.to_data_x(static_cast<f64>(std::floor(axes.x) + 130.5f)),
ty.to_data_y(static_cast<f64>(std::floor(axes.y) + 50.5f)), true);
p.set_crosshair_style({1.0f, 4.0f, 4.0f}); // a real dash: what a leak would carry
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> frame = read_frame(ctx);
// The cursor is drawn *after* the crosshair, so it is the leak guard that matters: a dash uniform
// that survived the crosshair's flush would break its full-height stroke into a 4/4 pattern.
plot::u32 cursor_rows = 0;
const plot::i32 cursor_col = densest_column(frame, kW, static_cast<plot::i32>(axes.x),
static_cast<plot::i32>(axes.right()),
static_cast<plot::i32>(axes.y),
static_cast<plot::i32>(axes.bottom()), cursor_pixel,
&cursor_rows);
PLOT_CHECK(cursor_col >= 0);
PLOT_CHECK(cursor_rows >= static_cast<plot::u32>(axes.h) * 3u / 4u); // a solid, full-height line
PLOT_CHECK(run_count(ink_row(frame, kW, cursor_col, cursor_col + 1, static_cast<plot::i32>(axes.y),
static_cast<plot::i32>(axes.bottom()), cursor_pixel)) == 1u);
// The grid, drawn *before* the crosshair: its columns are solid but for the one row the crosshair's
// own horizontal line crosses them at (the crosshair overdraws it), so at most one gap per column.
plot::u32 grid_rows = 0;
const plot::i32 grid_col = densest_column(frame, kW, static_cast<plot::i32>(axes.x),
static_cast<plot::i32>(axes.right()),
static_cast<plot::i32>(axes.y),
static_cast<plot::i32>(axes.bottom()), grid_pixel,
&grid_rows);
PLOT_CHECK(grid_col >= 0);
PLOT_CHECK(grid_rows >= static_cast<plot::u32>(axes.h) * 3u / 4u);
PLOT_CHECK(run_count(ink_row(frame, kW, grid_col, grid_col + 1, static_cast<plot::i32>(axes.y),
static_cast<plot::i32>(axes.bottom()), grid_pixel)) <= 2u);
(void)cur;
// --- the trace, with the same dashed crosshair over it ----------------------------------
plot::Plot q;
q.set_viewport(vp);
q.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false});
q.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
std::vector<plot::f32> ramp(1000u);
for (plot::u32 i = 0; i < ramp.size(); ++i) ramp[i] = static_cast<plot::f32>(i) * 0.1f;
plot::data::ArraySeries series(ramp, 0.0, 0.1);
// Unnamed: a named trace would draw a legend panel over the axes' top-right corner, which is
// where this rising trace spends its last columns -- a backplate hiding data for a reason that
// is not the dash.
q.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u, ""});
r.begin_frame(kW, kH, vp);
r.draw(q);
r.end_frame();
const plot::Rect q_axes = q.layout().axes;
q.set_crosshair(q.x_transform(0u).to_data_x(static_cast<f64>(std::floor(q_axes.x) + 130.5f)),
q.y_transform(0u).to_data_y(static_cast<f64>(std::floor(q_axes.y) + 50.5f)),
true);
q.set_crosshair_style({1.0f, 4.0f, 4.0f});
// No readout panel: it is a backplate drawn *over* the data, so it would cover trace columns for
// reasons that have nothing to do with the dash this case is about.
q.set_crosshair_readout(false);
r.begin_frame(kW, kH, vp);
r.draw(q);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
// Every column of the axes holds trace ink: the polyline rises across the whole width and a dash
// leaked into the trace pipeline would leave the columns in its gaps empty. Four columns of slack
// is where the crosshair's own thin vertical line overdraws the trace.
const plot::u32 covered = covered_columns(ctx, q_axes, static_cast<plot::u32>(q_axes.w));
PLOT_CHECK(covered >= static_cast<plot::u32>(q_axes.w) - 4u);
ctx.destroy();
}
/// Spec §17's phase requirement: a dash whose phase depended on the axis transform would crawl as
/// the view panned and look like noise. It is anchored to the segment start -- and both crosshair
/// lines are staged from an *axes edge* (the vertical from `y_range.min`, the horizontal from
/// `x_range.min`), so the phase is measured from a fixed edge of the rect. The observable form of
/// "stable" is that the same state renders the same bytes twice: no per-frame counter, no
/// time source, no layout drift in the phase.
PLOT_TEST(dash_phase_is_stable_across_frames) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
const plot::Rect vp{20.0f, 20.0f, 600.0f, 320.0f};
plot::Plot p;
p.set_viewport(vp);
const plot::u32 ax = p.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
std::vector<plot::f32> ramp(1000u);
for (plot::u32 i = 0; i < ramp.size(); ++i) ramp[i] = static_cast<plot::f32>(i) * 0.1f;
plot::data::ArraySeries series(ramp, 0.0, 0.1);
p.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u, "ramp"});
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
const plot::Rect axes = p.layout().axes;
p.set_crosshair(p.x_transform(ax).to_data_x(static_cast<f64>(std::floor(axes.x) + 130.5f)),
p.y_transform(0u).to_data_y(static_cast<f64>(std::floor(axes.y) + 50.5f)), true);
p.set_crosshair_style({1.0f, 4.0f, 4.0f});
const auto render = [&]() {
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
return read_frame(ctx);
};
const std::vector<plot::u8> first = render();
const std::vector<plot::u8> second = render();
PLOT_CHECK(first.size() == second.size());
PLOT_CHECK(first == second);
ctx.destroy();
}