diff --git a/README.md b/README.md index 1c55eaa..065aaf3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/styling.md b/docs/styling.md index 5784b05..b19052a 100644 --- a/docs/styling.md +++ b/docs/styling.md @@ -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 | diff --git a/include/plot/render/style.hpp b/include/plot/render/style.hpp index 7f46fca..2387e0e 100644 --- a/include/plot/render/style.hpp +++ b/include/plot/render/style.hpp @@ -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 diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index 03c8547..c604950 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -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(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(gather_[k]); + const f64 y_abs = static_cast(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 { diff --git a/tests/test_render_smoke.cpp b/tests/test_render_smoke.cpp index 1ad59b0..3975780 100644 --- a/tests/test_render_smoke.cpp +++ b/tests/test_render_smoke.cpp @@ -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(a.y); + const plot::u32 rows = static_cast(a.h); + std::vector tall; + for (plot::u32 cx = static_cast(a.x); + cx < static_cast(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& cols) { + std::vector 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 y(samples, 0.25f); + const plot::data::ArraySeries series(y, 0.0, 10.0 / static_cast(samples)); + plot::Plot p; + p.set_viewport({0, 0, static_cast(kW), static_cast(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(kW), static_cast(kH)}); + r.draw(p); + r.end_frame(); + PLOT_CHECK(!r.last_frame_had_gl_error()); + const std::vector tall = run_at(p, p.layout().axes); + const std::vector centres = groups(tall); + std::printf("[markers] %s: %zu columns, %zu groups, visible=%llu\n", what, tall.size(), + centres.size(), static_cast(r.stats().visible_samples)); + return centres; + }; + + const std::vector 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(ten.back() - ten.front()) / 9.0; + for (std::size_t i = 1; i < ten.size(); ++i) { + PLOT_CHECK_NEAR(static_cast(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 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;