From 7dbe4e71839da65dff9f1c8fe26ed49170b12a9d Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sat, 3 Oct 2026 15:39:49 +0200 Subject: [PATCH] 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. --- README.md | 2 + docs/styling.md | 2 + include/plot/render/style.hpp | 7 +++ src/render/renderer.cpp | 23 +++++++++ tests/test_render_smoke.cpp | 87 +++++++++++++++++++++++++++++++++++ 5 files changed, 121 insertions(+) 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;