feat(render): mark the samples of a window sparse enough to see them
`Style::marker_below_samples` (50 by default, `0` for never) draws a `marker_px` dot on every *raw* sample when the visible window holds at most that many, in the trace's own colour. Zoomed in far enough to see individual samples, the polyline between them stops being the shape a reader is looking at -- a handful of points across a whole panel ARE the picture -- and a dot makes each one a place rather than a bend in a line. Level 0 only: above it the geometry is pyramid buckets, which are not points anyone can find. Test: a_sparse_window_marks_its_samples -- the same flat series at ten samples and at a hundred. Ten gives exactly ten dots, evenly spaced (their centres are checked against the panel's own step, so "they are the samples" is asserted rather than "something was painted"); a hundred is past the threshold and gives none. The dots *group* into runs of columns (a marker is `marker_px` wide), which is how the test reads one marker per sample -- counting columns read three.
This commit is contained in:
1 parent
fba8d985cf
commit
7dbe4e7183
5 files changed
+121
No files matched your search
@@ -29,6 +29,8 @@ run touched a GPU, so read it as an upper bound and a record of the software pat
|
||||
|
||||
## Features
|
||||
|
||||
- sample **markers** when a window is sparse enough that its samples are the shape
|
||||
(`Style::marker_below_samples`, 50 by default, `marker_px` across, in the trace's own colour);
|
||||
- grid with independent major/minor line classes (`Style::grid_major_visible`,
|
||||
`grid_minor_visible`), each with its own colour, width and (for the minors) dash — the
|
||||
subdivisions default to thinner, dashed and alpha-blended, so they read as texture under the
|
||||
|
||||
@@ -56,6 +56,8 @@ Logical metrics (scaled by `device()`):
|
||||
|---|---|---|
|
||||
| `axis_font_px` | `12.0` | logical em the axis text is drawn at |
|
||||
| `grid_label_pad` | `4.0` | gap between a tick label and the axes, and the inset inside a title strip |
|
||||
| `marker_below_samples` | `50` | a dot per raw sample when the window holds at most this many (`0` = never) |
|
||||
| `marker_px` | `3.0` | logical size of that dot |
|
||||
| `grid_major_width` | `1.0` | width of a major grid line |
|
||||
| `grid_minor_width` | `0.75` | width of a minor grid line (sub-pixel on purpose) |
|
||||
| `grid_minor_dash_on` | `3.0` | minor grid dash: ink length in logical pixels |
|
||||
|
||||
@@ -107,6 +107,13 @@ struct Style {
|
||||
/// skips it before the first segment is staged, so a dense plot's minor grid
|
||||
/// costs nothing rather than being drawn transparent (spec §11). The colours
|
||||
/// above are already per class.
|
||||
/// When a trace's window holds at most this many raw samples, each one is also drawn as a dot
|
||||
/// `marker_px` logical pixels across, in the trace's own colour. `0` draws none. Zoomed in far
|
||||
/// enough to see individual samples -- a handful of them across a whole panel -- the polyline
|
||||
/// *between* them stops being the shape, and the samples themselves are what a reader is looking
|
||||
/// at; a marker makes each one a place rather than a bend in a line.
|
||||
f32 marker_below_samples = 50.0f;
|
||||
f32 marker_px = 3.0f;
|
||||
f32 grid_major_width = 1.0f; ///< logical width of a major (labelled-tick) line.
|
||||
/// Logical width of a minor (subdivision) line: **thinner than the major hairline**, and
|
||||
/// sub-pixel on purpose. At 1x that is expressed as *coverage* -- the anti-aliased ramp
|
||||
|
||||
@@ -1028,6 +1028,29 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
|
||||
if (gather_.size() < 4u) continue; // fewer than two points is no segment
|
||||
|
||||
stats_.visible_samples += visible;
|
||||
// Markers for a window sparse enough that its samples are the shape: one dot per raw sample,
|
||||
// in the trace's own colour. Level 0 only -- above it the geometry is *buckets*, which are not
|
||||
// points a reader can find. Drawn after the polyline they sit on, so a dot is never half
|
||||
// covered by its own line, and inside the axes only (a marker is a place, and a place outside
|
||||
// the frame is not one).
|
||||
if (level == 0u && style_.marker_below_samples > 0.0f && visible > 0u &&
|
||||
visible <= static_cast<u64>(style_.marker_below_samples)) {
|
||||
const auto& xt = plot.x_transform(trace.axis_x);
|
||||
const auto& yt = plot.y_transform(trace.axis_y);
|
||||
const f32 half = 0.5f * style_.device(style_.marker_px);
|
||||
for (u32 k = 0; k + 1 < gather_.size(); k += 2) {
|
||||
const f64 x_abs = series.x0() + static_cast<f64>(gather_[k]);
|
||||
const f64 y_abs = static_cast<f64>(gather_[k + 1]);
|
||||
if (!std::isfinite(x_abs) || !std::isfinite(y_abs)) continue;
|
||||
const f32 sx = xt.to_screen_x(x_abs);
|
||||
const f32 sy = yt.to_screen_y(y_abs);
|
||||
if (!(sx >= layout.axes.x && sx <= layout.axes.right() && sy >= layout.axes.y &&
|
||||
sy <= layout.axes.bottom())) {
|
||||
continue;
|
||||
}
|
||||
fill_pixels(Rect{sx - half, sy - half, 2.0f * half, 2.0f * half}, trace.color);
|
||||
}
|
||||
}
|
||||
if (level == 0u) {
|
||||
++stats_.raw_traces;
|
||||
} else {
|
||||
|
||||
@@ -1383,6 +1383,93 @@ PLOT_TEST(a_cursor_stroke_can_be_thinner_and_dashed) {
|
||||
ctx.destroy();
|
||||
}
|
||||
|
||||
/// A window sparse enough that its samples *are* the shape gets a marker on each one. Ten samples
|
||||
/// across a panel are places a reader counts, and the polyline between them is not the shape any
|
||||
/// more; a hundred are a curve and the markers would be noise, which is what the threshold is for.
|
||||
/// The test reads the vertical run of trace-coloured pixels per column: a marker is `marker_px`
|
||||
/// across, so at a sample's own column the run is taller than the line, and at a midpoint it is
|
||||
/// the line alone. The dense case proves the second half -- same series shape, no column taller
|
||||
/// than the stroke.
|
||||
PLOT_TEST(a_sparse_window_marks_its_samples) {
|
||||
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_CHECK(r.style().marker_below_samples >= 50.0f); // the default is the spec's 50
|
||||
|
||||
const auto run_at = [&](plot::Plot& p, const plot::Rect& a) {
|
||||
const plot::u32 first_row = static_cast<plot::u32>(a.y);
|
||||
const plot::u32 rows = static_cast<plot::u32>(a.h);
|
||||
std::vector<plot::u32> tall;
|
||||
for (plot::u32 cx = static_cast<plot::u32>(a.x);
|
||||
cx < static_cast<plot::u32>(a.x + a.w); ++cx) {
|
||||
plot::u32 best = 0;
|
||||
plot::u32 run = 0;
|
||||
for (plot::u32 yy = 0; yy < rows; ++yy) {
|
||||
plot::u8 px[4] = {};
|
||||
PLOT_CHECK(ctx.read_pixel(cx, ctx.height - 1u - (first_row + yy), px));
|
||||
if (green_pixel(px)) {
|
||||
++run;
|
||||
best = std::max(best, run);
|
||||
} else {
|
||||
run = 0;
|
||||
}
|
||||
}
|
||||
if (best >= 3u) tall.push_back(cx);
|
||||
}
|
||||
(void)p;
|
||||
return tall;
|
||||
};
|
||||
// The dots *group*: a marker is `marker_px` wide, so it spans several adjacent columns. What
|
||||
// identifies one is a run of tall columns, exactly as a cursor's stroke spans two -- the first
|
||||
// version of this test counted columns and read three markers per sample.
|
||||
const auto groups = [](const std::vector<plot::u32>& cols) {
|
||||
std::vector<plot::u32> centres;
|
||||
for (std::size_t i = 0; i < cols.size();) {
|
||||
std::size_t j = i;
|
||||
while (j + 1 < cols.size() && cols[j + 1] == cols[j] + 1u) ++j;
|
||||
centres.push_back((cols[i] + cols[j]) / 2u);
|
||||
i = j + 1;
|
||||
}
|
||||
return centres;
|
||||
};
|
||||
|
||||
// Ten samples, a flat line: every sample is a place, and none of them is a bend.
|
||||
const auto sparse = [&](plot::u32 samples, const char* what) {
|
||||
std::vector<plot::f32> y(samples, 0.25f);
|
||||
const plot::data::ArraySeries series(y, 0.0, 10.0 / static_cast<plot::f64>(samples));
|
||||
plot::Plot p;
|
||||
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
const plot::u32 ax = p.add_x_axis({"t", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false});
|
||||
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||
p.add_trace({&series, plot::Color::hex(0x00FF00), 1.5f, ax, ay});
|
||||
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 std::vector<plot::u32> tall = run_at(p, p.layout().axes);
|
||||
const std::vector<plot::u32> centres = groups(tall);
|
||||
std::printf("[markers] %s: %zu columns, %zu groups, visible=%llu\n", what, tall.size(),
|
||||
centres.size(), static_cast<unsigned long long>(r.stats().visible_samples));
|
||||
return centres;
|
||||
};
|
||||
|
||||
const std::vector<plot::u32> ten = sparse(10u, "10 samples");
|
||||
PLOT_CHECK(ten.size() == 10u); // one marker per sample
|
||||
// ... and at the samples: ten of them across the panel, evenly spaced. This is the assertion
|
||||
// that says the dots are *the samples* and not something the style happened to paint.
|
||||
const double step = static_cast<double>(ten.back() - ten.front()) / 9.0;
|
||||
for (std::size_t i = 1; i < ten.size(); ++i) {
|
||||
PLOT_CHECK_NEAR(static_cast<double>(ten[i] - ten[i - 1]), step, 2.0);
|
||||
}
|
||||
// A hundred samples: the same shape, past the threshold, and no group is a dot.
|
||||
const std::vector<plot::u32> hundred = sparse(100u, "100 samples");
|
||||
PLOT_CHECK(hundred.size() <= 5u);
|
||||
ctx.destroy();
|
||||
}
|
||||
|
||||
PLOT_TEST(grid_hairlines_land_on_whole_pixels) {
|
||||
plottest::HeadlessContext ctx(kW, kH);
|
||||
plot::Status st;
|
||||
|
||||
Reference in new issue
Block a user