feat(cursor): a cursor's stroke is its own -- width and dash
Cursors took `Style::line_width_px` and were always solid, so a host had no way to place a *readout mark* on the data (a measured level, a peak, a pulse's edge) without it reading as another instrument framing the plot. `set_cursor_stroke(id, width, dash)` gives it its own, the same shape as `set_cursor_color()`: per cursor, because one plot holds both kinds. `width <= 0` means the style's default for that cursor (`handle_width_px` while hovered or grabbed, `line_width_px` otherwise), and a hover can only make the stroke *thicker* -- the cue that the pointer has hold of something must not be the thing that hides it. `dash.off <= 0` is solid, the convention a trace's, the crosshair's and the grid's dash already share. Test: a_cursor_stroke_can_be_thinner_and_dashed -- a default cursor and a thin dashed one in the same frame. The dashed column alternates ink and gap (153/151 at the 4/4 the test asks for) while the solid one has no gaps; the ink laid down over each stroke's whole run of columns is less for the thin one. That last assertion is deliberately *not* a single-column peak: a cursor is a data position and is not snapped, so the solid stroke's ink split across two columns and read *lighter* than the thin one -- which is how this test first failed.
This commit is contained in:
1 parent
c4c0a49466
commit
fba8d985cf
6 files changed
+154
-5
No files matched your search
@@ -104,6 +104,18 @@ same width a dense minor grid reads as a second set of ticks. The dash is a frag
|
|||||||
discard, not extra geometry: the vertices are the line either way. Set
|
discard, not extra geometry: the vertices are the line either way. Set
|
||||||
`grid_minor_dash_off = 0` for the old solid subdivision.
|
`grid_minor_dash_off = 0` for the old solid subdivision.
|
||||||
|
|
||||||
|
## A cursor's stroke
|
||||||
|
|
||||||
|
`set_cursor_color()` gives a cursor its own colour and `set_cursor_stroke()` its own width and
|
||||||
|
dash (`Cursor::width`, `Cursor::dash`). Per cursor rather than per plot, because a host uses
|
||||||
|
cursors for two different jobs: the instruments that frame a reading — a marker at an edge, a pull
|
||||||
|
handle — and the *readout marks* a computation places on the data (a measured level, a peak, a
|
||||||
|
pulse's edges). Those want different weights. `width <= 0` means the style's default for that
|
||||||
|
cursor (`handle_width_px` while it is hovered or grabbed, `line_width_px` otherwise), and a hover
|
||||||
|
can only make the stroke *thicker*: the cue that the pointer has hold of something must not be the
|
||||||
|
thing that hides it. `dash.off <= 0` (the default) is solid — the same convention as a trace's,
|
||||||
|
the crosshair's and the grid's.
|
||||||
|
|
||||||
## The crosshair's dash
|
## The crosshair's dash
|
||||||
|
|
||||||
`crosshair_dash_on`/`crosshair_dash_off` are the fragment shader's discard pattern, so no
|
`crosshair_dash_on`/`crosshair_dash_off` are the fragment shader's discard pattern, so no
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
#include <plot/core/color.hpp>
|
#include <plot/core/color.hpp>
|
||||||
#include <plot/core/types.hpp>
|
#include <plot/core/types.hpp>
|
||||||
|
#include <plot/math/vec2.hpp>
|
||||||
|
|
||||||
namespace plot {
|
namespace plot {
|
||||||
|
|
||||||
@@ -92,6 +93,19 @@ struct Cursor {
|
|||||||
/// hit radius it would have taken is left to whatever is behind it. Drawn exactly as a
|
/// hit radius it would have taken is left to whatever is behind it. Drawn exactly as a
|
||||||
/// visible one is: this is about the gesture, not the ink.
|
/// visible one is: this is about the gesture, not the ink.
|
||||||
bool selectable = true;
|
bool selectable = true;
|
||||||
|
/// The stroke's logical width; `<= 0` means the style's default for this cursor
|
||||||
|
/// (`Style::handle_width_px` while it is hovered or grabbed, `Style::line_width_px`
|
||||||
|
/// otherwise). Per cursor, like the colour, because a host uses cursors for two different
|
||||||
|
/// jobs: the instruments that frame a reading (a marker at an edge, a pull handle) and the
|
||||||
|
/// *readout marks* a computation places on the data (a measured level, a peak). Those want
|
||||||
|
/// different weights, and a hover can only make the stroke thicker, never thinner -- the cue
|
||||||
|
/// that the pointer has hold of something must not be the thing that hides it.
|
||||||
|
f32 width = 0.0f;
|
||||||
|
/// The dash, logical pixels: ink length and gap. `off <= 0` (the default) is a solid stroke --
|
||||||
|
/// the same convention as a trace's, the crosshair's and the grid's. A dashed readout mark is
|
||||||
|
/// distinguishable by *form* from the solid chrome around it, which is what an instrument that
|
||||||
|
/// reports a number should be.
|
||||||
|
Vec2 dash{};
|
||||||
|
|
||||||
/// True when this is a horizontal ruler (spans the axes' width, reads its `y`). The one
|
/// True when this is a horizontal ruler (spans the axes' width, reads its `y`). The one
|
||||||
/// place the orientation becomes a branch, so every consumer takes the same decision and
|
/// place the orientation becomes a branch, so every consumer takes the same decision and
|
||||||
|
|||||||
@@ -484,6 +484,10 @@ class Plot {
|
|||||||
/// be told apart from its neighbours, and its colour is how it says which readout it
|
/// be told apart from its neighbours, and its colour is how it says which readout it
|
||||||
/// belongs to. An id that names no cursor is ignored, like every other setter here.
|
/// belongs to. An id that names no cursor is ignored, like every other setter here.
|
||||||
void set_cursor_color(u32 id, Color color);
|
void set_cursor_color(u32 id, Color color);
|
||||||
|
|
||||||
|
/// 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);
|
||||||
/// Makes a cursor unselectable (see `Cursor::selectable`): the pointer cannot hover or
|
/// Makes a cursor unselectable (see `Cursor::selectable`): the pointer cannot hover or
|
||||||
/// grab it, which is what a host wants for a marker it draws as a *readout*. An id that
|
/// grab it, which is what a host wants for a marker it draws as a *readout*. An id that
|
||||||
/// names no cursor is ignored, like every other setter here.
|
/// names no cursor is ignored, like every other setter here.
|
||||||
|
|||||||
@@ -311,6 +311,12 @@ void Plot::set_cursor_color(u32 id, Color color) {
|
|||||||
cursors_[id].color = color;
|
cursors_[id].color = color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Plot::set_cursor_stroke(u32 id, f32 width, Vec2 dash) {
|
||||||
|
if (id >= cursors_.size()) return;
|
||||||
|
cursors_[id].width = width;
|
||||||
|
cursors_[id].dash = dash;
|
||||||
|
}
|
||||||
|
|
||||||
void Plot::set_cursor_selectable(u32 id, bool selectable) {
|
void Plot::set_cursor_selectable(u32 id, bool selectable) {
|
||||||
if (id >= cursors_.size()) return;
|
if (id >= cursors_.size()) return;
|
||||||
cursors_[id].selectable = selectable;
|
cursors_[id].selectable = selectable;
|
||||||
|
|||||||
@@ -1117,7 +1117,12 @@ void Renderer::draw_cursors(const Plot& plot, const PlotLayout& layout) {
|
|||||||
// belongs to the plot, and `to_screen_y()` of a range maximum can undershoot `axes.y` by
|
// belongs to the plot, and `to_screen_y()` of a range maximum can undershoot `axes.y` by
|
||||||
// a floating-point ulp, which a bare `<` would mistake for "outside". Only the ones
|
// a floating-point ulp, which a bare `<` would mistake for "outside". Only the ones
|
||||||
// beyond that margin are skipped.
|
// beyond that margin are skipped.
|
||||||
const f32 half = 0.5f * style_.device(on ? style_.handle_width_px : style_.line_width_px);
|
// The cursor's own width when it has one, else the style's; a hover or a grab can only make
|
||||||
|
// it thicker (the cue that the pointer has hold of something).
|
||||||
|
const f32 own = cursor.width > 0.0f ? style_.device(cursor.width)
|
||||||
|
: style_.device(style_.line_width_px);
|
||||||
|
const f32 width = on ? std::max(own, style_.device(style_.handle_width_px)) : own;
|
||||||
|
const f32 half = 0.5f * width;
|
||||||
if (horizontal ? (at < layout.axes.y - half || at > layout.axes.bottom() + half)
|
if (horizontal ? (at < layout.axes.y - half || at > layout.axes.bottom() + half)
|
||||||
: (at < layout.axes.x - half || at > layout.axes.right() + half)) {
|
: (at < layout.axes.x - half || at > layout.axes.right() + half)) {
|
||||||
continue;
|
continue;
|
||||||
@@ -1141,15 +1146,13 @@ void Renderer::draw_cursors(const Plot& plot, const PlotLayout& layout) {
|
|||||||
stage_segment(x_range.min, x_range.min, y_drawn, x_range.max, y_drawn, log_x,
|
stage_segment(x_range.min, x_range.min, y_drawn, x_range.max, y_drawn, log_x,
|
||||||
plot.y_axis(cursor.axis_y).log);
|
plot.y_axis(cursor.axis_y).log);
|
||||||
flush_lines(mapping(plot, 0, cursor.axis_y, x_range.min),
|
flush_lines(mapping(plot, 0, cursor.axis_y, x_range.min),
|
||||||
on ? style_.cursor : cursor.color,
|
on ? style_.cursor : cursor.color, width, cursor.dash);
|
||||||
style_.device(on ? style_.handle_width_px : style_.line_width_px));
|
|
||||||
} else {
|
} else {
|
||||||
const f64 x_drawn = plot.x_transform(cursor.axis_x).to_data_x(static_cast<f64>(drawn));
|
const f64 x_drawn = plot.x_transform(cursor.axis_x).to_data_x(static_cast<f64>(drawn));
|
||||||
stage_segment(x_drawn, x_drawn, y_range.min, x_drawn, y_range.max,
|
stage_segment(x_drawn, x_drawn, y_range.min, x_drawn, y_range.max,
|
||||||
plot.x_axis(cursor.axis_x).log, log_y);
|
plot.x_axis(cursor.axis_x).log, log_y);
|
||||||
flush_lines(mapping(plot, cursor.axis_x, 0, x_drawn),
|
flush_lines(mapping(plot, cursor.axis_x, 0, x_drawn),
|
||||||
on ? style_.cursor : cursor.color,
|
on ? style_.cursor : cursor.color, width, cursor.dash);
|
||||||
style_.device(on ? style_.handle_width_px : style_.line_width_px));
|
|
||||||
}
|
}
|
||||||
draw_cursor_label(plot, layout, cursor, drawn);
|
draw_cursor_label(plot, layout, cursor, drawn);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1273,6 +1273,116 @@ PLOT_TEST(the_minor_grid_is_thinner_dashed_and_dimmer_than_the_major) {
|
|||||||
ctx.destroy();
|
ctx.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A cursor's stroke is its own: a host that places *readout marks* on the data -- a measured
|
||||||
|
/// level, a peak, a pulse's edges -- wants them lighter and broken, so they read as annotations
|
||||||
|
/// over the data rather than as more chrome around it. Two cursors in one frame, one default and
|
||||||
|
/// one given a thin dashed stroke, so the comparison is in the same pixels: the dashed column
|
||||||
|
/// alternates ink and gap down its length while the solid one does not, and its ink sits closer
|
||||||
|
/// to the background, which at 1x is what a sub-pixel logical width *is*.
|
||||||
|
PLOT_TEST(a_cursor_stroke_can_be_thinner_and_dashed) {
|
||||||
|
plottest::HeadlessContext ctx(kW, kH);
|
||||||
|
plot::Status st;
|
||||||
|
PLOT_CHECK(ctx.create(&st));
|
||||||
|
PLOT_CHECK(plot::gl::load(nullptr));
|
||||||
|
plot::Renderer r;
|
||||||
|
PLOT_CHECK(r.init(&st));
|
||||||
|
|
||||||
|
plot::Plot p;
|
||||||
|
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||||
|
p.add_x_axis({"t", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false});
|
||||||
|
p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||||
|
// Green: no other element of this frame is green-dominant (the grid is grey-dark and the axis
|
||||||
|
// line is chalk), so the predicate below can only find these two cursors.
|
||||||
|
const plot::Color ink = plot::Color::hex(0x00FF00);
|
||||||
|
p.add_cursor(3.0, ink);
|
||||||
|
const plot::u32 thin = p.add_cursor(7.0, ink);
|
||||||
|
p.set_cursor_snap(thin, plot::SnapMode::Free);
|
||||||
|
p.set_cursor_stroke(thin, 0.75f, plot::Vec2{3.0f, 3.0f});
|
||||||
|
|
||||||
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||||
|
r.draw(p);
|
||||||
|
r.end_frame();
|
||||||
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||||
|
|
||||||
|
const plot::Rect& a = p.layout().axes;
|
||||||
|
const plot::Color bg = r.style().background;
|
||||||
|
const auto px_at = [&](plot::u32 cx, plot::u32 cy, plot::u8 px[4]) {
|
||||||
|
return ctx.read_pixel(cx, ctx.height - 1u - cy, px);
|
||||||
|
};
|
||||||
|
const auto bg_distance = [&](const plot::u8 px[4]) {
|
||||||
|
const auto d = [](plot::u8 v, plot::f32 want) {
|
||||||
|
return std::abs(static_cast<int>(v) - static_cast<int>(want * 255.0f + 0.5f));
|
||||||
|
};
|
||||||
|
return d(px[0], bg.r) + d(px[1], bg.g) + d(px[2], bg.b);
|
||||||
|
};
|
||||||
|
// The two cursor columns, as the *centre of each run* of green columns: a stroke with an
|
||||||
|
// anti-aliased edge covers two or three columns, so taking the first two hits found one cursor
|
||||||
|
// twice (238 and 239) and never looked at the other -- which is how this test first reported
|
||||||
|
// "neither is dashed" with both columns' ink identical.
|
||||||
|
plot::u32 cols[2] = {0u, 0u};
|
||||||
|
plot::u32 run_from[2] = {0u, 0u};
|
||||||
|
plot::u32 run_to[2] = {0u, 0u};
|
||||||
|
int found = 0;
|
||||||
|
bool in_run = false;
|
||||||
|
plot::u32 run_start = 0;
|
||||||
|
const auto close_run = [&](plot::u32 end_column) {
|
||||||
|
if (found >= 2) return;
|
||||||
|
cols[found] = (run_start + end_column) / 2u;
|
||||||
|
run_from[found] = run_start;
|
||||||
|
run_to[found] = end_column;
|
||||||
|
++found;
|
||||||
|
};
|
||||||
|
for (plot::u32 cx = static_cast<plot::u32>(a.x); cx < static_cast<plot::u32>(a.x + a.w); ++cx) {
|
||||||
|
bool green = false;
|
||||||
|
for (plot::u32 row = static_cast<plot::u32>(a.y) + 4u;
|
||||||
|
row < static_cast<plot::u32>(a.y + a.h) - 4u && !green; ++row) {
|
||||||
|
plot::u8 px[4] = {};
|
||||||
|
PLOT_CHECK(px_at(cx, row, px));
|
||||||
|
green = green_pixel(px);
|
||||||
|
}
|
||||||
|
if (green && !in_run) {
|
||||||
|
in_run = true;
|
||||||
|
run_start = cx;
|
||||||
|
} else if (!green && in_run) {
|
||||||
|
in_run = false;
|
||||||
|
close_run(cx - 1u);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (in_run) close_run(static_cast<plot::u32>(a.x + a.w) - 1u);
|
||||||
|
PLOT_CHECK(found == 2);
|
||||||
|
plot::u32 ink_rows[2] = {0u, 0u};
|
||||||
|
plot::u32 gap_rows[2] = {0u, 0u};
|
||||||
|
// The ink each stroke lays down, summed over its whole run of columns: a cursor is a *data*
|
||||||
|
// position and so is deliberately not snapped, so which columns (and how brightly) a stroke
|
||||||
|
// covers depends on where its coordinate falls between pixels. Comparing one column's peak made
|
||||||
|
// the *solid* stroke look lighter than the thin one, because its ink happened to split across
|
||||||
|
// two columns. What is asked here is the thing the style states: less ink per unit length.
|
||||||
|
long long total_ink[2] = {0, 0};
|
||||||
|
for (int c = 0; c < 2; ++c) {
|
||||||
|
for (plot::u32 cx = run_from[c]; cx <= run_to[c]; ++cx) {
|
||||||
|
for (plot::u32 row = static_cast<plot::u32>(a.y) + 4u;
|
||||||
|
row < static_cast<plot::u32>(a.y + a.h) - 4u; ++row) {
|
||||||
|
plot::u8 px[4] = {};
|
||||||
|
PLOT_CHECK(px_at(cx, row, px));
|
||||||
|
if (green_pixel(px)) {
|
||||||
|
if (cx == cols[c]) ++ink_rows[c];
|
||||||
|
total_ink[c] += bg_distance(px);
|
||||||
|
} else if (pixel_is(px, bg) && cx == cols[c]) {
|
||||||
|
++gap_rows[c];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::printf("[cursor-stroke] solid x=%u [%u..%u] ink=%u gaps=%u total=%lld; "
|
||||||
|
"thin x=%u [%u..%u] ink=%u gaps=%u total=%lld\n",
|
||||||
|
cols[0], run_from[0], run_to[0], ink_rows[0], gap_rows[0], total_ink[0], cols[1],
|
||||||
|
run_from[1], run_to[1], ink_rows[1], gap_rows[1], total_ink[1]);
|
||||||
|
PLOT_CHECK(gap_rows[0] == 0u); // the default stroke is continuous
|
||||||
|
PLOT_CHECK(ink_rows[1] > 0u && gap_rows[1] > 0u); // the custom one is dashed
|
||||||
|
PLOT_CHECK(total_ink[1] < total_ink[0]); // and lays down less ink
|
||||||
|
ctx.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
PLOT_TEST(grid_hairlines_land_on_whole_pixels) {
|
PLOT_TEST(grid_hairlines_land_on_whole_pixels) {
|
||||||
plottest::HeadlessContext ctx(kW, kH);
|
plottest::HeadlessContext ctx(kW, kH);
|
||||||
plot::Status st;
|
plot::Status st;
|
||||||
|
|||||||
Reference in new issue
Block a user