From ebc67bd318e3da25ab15189edced407ea1a92c01 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Thu, 8 Oct 2026 20:11:16 +0200 Subject: [PATCH] feat: a span declared invalid is shaded behind the data it is missing from --- include/plot/plot/plot.hpp | 26 +++++ include/plot/render/renderer.hpp | 6 ++ include/plot/render/style.hpp | 7 ++ src/plot/plot.cpp | 40 ++++++++ src/render/renderer.cpp | 9 ++ tests/test_render_smoke.cpp | 162 +++++++++++++++++++++++++++++++ 6 files changed, 250 insertions(+) diff --git a/include/plot/plot/plot.hpp b/include/plot/plot/plot.hpp index 066aaa9..3956310 100644 --- a/include/plot/plot/plot.hpp +++ b/include/plot/plot/plot.hpp @@ -2,6 +2,8 @@ // SPDX-FileCopyrightText: 2026 Martino Ferrari #pragma once +#include +#include #include #include @@ -105,6 +107,13 @@ struct PlotLayout { /// controller's hit test reads the very same rects, so where a region is drawn and /// where a pointer can grab it cannot drift apart. std::vector regions; + /// Every invalid span's shading, in `Plot::invalid_spans()` order and clipped to the axes' + /// columns. Measured here for the regions' first reason -- the frame draws exactly these + /// numbers -- and reserving nothing, so a hole cannot move the strips the labels were + /// measured into. A span entirely outside the window contributes no rect at all, so this is + /// not index-aligned with `invalid_spans()` and nothing needs it to be: there is no hit test + /// on a band and no label beside it. + std::vector invalid_spans; /// The crosshair's geometry, when one is active: the two lines' stroke boxes, the /// readout panel and one row per visible trace with the value of the sample nearest the /// pointer. Placed here rather than derived by the renderer, exactly as the legend and @@ -525,6 +534,21 @@ class Plot { void set_x_note(std::string text); const std::string& x_note() const noexcept { return x_note_; } + /// Spans of the primary x axis the host has declared invalid -- in udpscope's case a reserved + /// hole: samples the stream's counter says exist and that have not been delivered. Each pair + /// is `(x_begin, x_end)` in **data** coordinates, `Region`'s convention, and is shaded behind + /// the traces and over the grid. + /// + /// Deliberately *not* a `Region`: a region is interactive -- draggable bounds, hot zones, a + /// label, a cached statistic, a controller hit test -- and a plot can carry dozens of holes at + /// once, which would put a hundred-odd grab targets in the way of every pointer event for + /// marks nobody can usefully drag. This is passive shading and nothing else. + /// + /// The span is copied, so the caller's storage need not outlive the call. Spans need not be + /// sorted and may overlap; an empty call clears the shading. + void set_invalid_spans(std::span> spans); + std::span> invalid_spans() const noexcept { return invalid_spans_; } + /// The measurement for `id`. Re-evaluated only when a cursor moved, when the /// measure was added, or when the series underneath it changed: a different /// series, a longer one, or a sliding window whose sample count stayed the same @@ -852,6 +876,7 @@ class Plot { /// region's pixel geometry is derived -- the controller hit-tests what this /// measured instead of mapping the bounds through the transform again. void build_regions(); + void build_invalid_spans(); /// Exact statistics of `region` over `series`, or an invalid result when the /// interval covers no sample. The one place the region arithmetic lives. static RegionStats evaluate_region_stats(const Region& region, const data::Series& series); @@ -899,6 +924,7 @@ class Plot { std::vector measures_; std::vector annotations_; std::vector regions_; + std::vector> invalid_spans_; /// The (region, trace) statistics cache, filled on demand by `region_stats()` /// and so mutable: a lookup on a const Plot may cache, exactly as `measures_` /// does. Entries are created for the pairs a caller actually asks about, never diff --git a/include/plot/render/renderer.hpp b/include/plot/render/renderer.hpp index 973786c..8f8ea8f 100644 --- a/include/plot/render/renderer.hpp +++ b/include/plot/render/renderer.hpp @@ -513,6 +513,12 @@ class Renderer { void draw_plot(Plot& plot, const PlotLayout& layout); void draw_grid(const Plot& plot); + /// `Plot::invalid_spans()`, as `PlotLayout::invalid_spans` measured them: one fill per span in + /// `style_.invalid_fill`. Over the grid and under everything else inside the axes, so a hole + /// reads as a property of the plot's background -- the grid shows through it and a trace that + /// does have data in those columns is drawn on top, which is what distinguishes a reserved + /// hole from a trace that has gone quiet. + void draw_invalid_spans(const PlotLayout& layout); void draw_axis_lines(const Plot& plot); /// The chrome helpers take the frame's layout: `Plot::layout()` recomputes the /// ticks, the strips and the grid, so it is computed once, in `draw()`, and handed diff --git a/include/plot/render/style.hpp b/include/plot/render/style.hpp index ef13dfc..a8ca242 100644 --- a/include/plot/render/style.hpp +++ b/include/plot/render/style.hpp @@ -55,6 +55,13 @@ struct Style { Color axis_text = Color::hex(0xC8D0DC); Color cursor = Color::hex(0xFFB020); Color measure_fill = Color::hex(0xFFB020, 0.15f); + /// The shading over a span the host has declared invalid (`Plot::set_invalid_spans()`): the + /// axis furniture's own neutral grey at a low alpha, on the same model as `measure_fill` + /// above. Neutral and not a trace colour on purpose -- a band is the *absence* of data, and + /// one in a hue a trace could have would read as a signal rather than as a gap. The alpha is + /// low enough that the grid under it stays legible, which is what makes the band read as a + /// region of the plot rather than as a panel laid over it. + Color invalid_fill = Color::hex(0x8A94A6, 0.18f); /// The crosshair's colour: its two lines and the readout panel's border (spec §15). Markers are /// deliberately *not* this colour -- they carry each trace's own, because a marker says *which* /// trace and the line says *where*. diff --git a/src/plot/plot.cpp b/src/plot/plot.cpp index b50ef79..7d82114 100644 --- a/src/plot/plot.cpp +++ b/src/plot/plot.cpp @@ -344,6 +344,10 @@ void Plot::set_y_axis_origin_from_range(u32 axis, bool on) { void Plot::set_x_note(std::string text) { x_note_ = std::move(text); } +void Plot::set_invalid_spans(std::span> spans) { + invalid_spans_.assign(spans.begin(), spans.end()); +} + f64 Plot::x_axis_origin(u32 axis) const { return axis < x_axes_.size() ? x_axes_[axis].origin : 0.0; } @@ -1091,6 +1095,7 @@ const PlotLayout& Plot::layout() { // measured from the *placed* live windows, which is what makes a region's data // interval map to the pixel columns the frame will actually use. build_regions(); + build_invalid_spans(); build_legend(); // The crosshair last: its panel is fitted into `axes` and its lines are held inside it, and // its rows are the sample values nearest a data x -- so it wants the transforms the @@ -1358,6 +1363,41 @@ void Plot::build_regions() { } } +void Plot::build_invalid_spans() { + layout_.invalid_spans.clear(); + if (invalid_spans_.empty()) return; + + const Rect& axes = layout_.axes; + // The primary x axis' transform, taken here for `build_regions()`' reason: after the live + // windows have been placed and the strips have settled, so these are the columns the frame + // draws with and a band is anchored to the data by construction rather than by a second + // mapping that would have to be kept in step with this one. + const Transform tx = x_transform(0u); + // A hole can be narrower than a column -- one missing sample in a window of hundreds of + // thousands -- and a fill rounded to zero width would draw nothing, losing exactly the case + // the band is most needed for. One device pixel is the floor. + const f32 min_w = std::max(1.0f, style_.device(1.0f)); + + for (const std::pair& span : invalid_spans_) { + const f64 a = std::min(span.first, span.second); + const f64 b = std::max(span.first, span.second); + if (!std::isfinite(a) || !std::isfinite(b)) continue; + const f32 lo = static_cast(tx.to_screen_x(a)); + const f32 hi = static_cast(tx.to_screen_x(b)); + // Scrolled out of the window: nothing to shade. Tested before the floor is applied, so a + // span off the left edge is dropped rather than pinned to it as a one-pixel band that + // accuses the samples at the window's start. + if (hi < axes.x || lo > axes.right()) continue; + const f32 first = std::clamp(lo, axes.x, axes.right()); + const f32 last = std::clamp(hi, axes.x, axes.right()); + const f32 w = std::min(std::max(last - first, min_w), axes.w); + // Widening a band at the right edge would run it over the axis line, so the floor is taken + // out of the left instead: the span's own edge is kept wherever there is room for it. + const f32 x = std::clamp(first, axes.x, axes.right() - w); + layout_.invalid_spans.push_back(Rect{x, axes.y, w, axes.h}); + } +} + // --- crosshair (spec §15) -------------------------------------------------- CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept { diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index f2fca64..fd05e25 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -516,6 +516,9 @@ void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) { // against the line passes would not change a pixel.) set_clip(layout.axes); draw_grid(plot); + // The holes over the grid and under the data: a band is chrome of the *background*, not an + // overlay, so everything a frame draws inside the axes is drawn on top of it. + draw_invalid_spans(layout); clear_clip(); draw_axis_lines(plot); set_clip(layout.axes); @@ -1728,6 +1731,12 @@ void Renderer::draw_cursor_label(const Plot& plot, const PlotLayout& layout, con } } +void Renderer::draw_invalid_spans(const PlotLayout& layout) { + for (const Rect& band : layout.invalid_spans) { + if (band.w > 0.0f) fill_pixels(band, style_.invalid_fill); + } +} + void Renderer::draw_regions(const Plot& plot, const PlotLayout& layout) { const HotHandles hot = plot.hot_handles(); const f32 edge_w = style_.device(style_.line_width_px); diff --git a/tests/test_render_smoke.cpp b/tests/test_render_smoke.cpp index b66c4a2..1bb70b3 100644 --- a/tests/test_render_smoke.cpp +++ b/tests/test_render_smoke.cpp @@ -7212,3 +7212,165 @@ PLOT_TEST(a_sparse_trace_is_graded_by_slope_like_a_dense_one) { PLOT_CHECK(std::abs(dense.riser - sparse.riser) < 24); PLOT_CHECK(!r.last_frame_had_gl_error()); } + +/// An invalid span is the opposite of the x note: the note is chrome that a pan must not move, +/// and a band marks a *region of the data*, so it must pan and zoom with it. A band that stayed +/// put under a pan would accuse the wrong samples of being missing, which is worse than drawing +/// nothing. It also must reserve no layout space -- the label strips were measured before any +/// band existed, and a hole must not be able to move them. +/// +/// Measured as a footprint for the x note's reason: the band's rect is deliberately not readable +/// back, so the only way to see it is the pixels a frame *with* it differs from the same frame +/// without one, at the same view. Diffing at a fixed view is also what makes the measurement +/// robust: whatever the trace does under a pan cancels between the two frames, so the bounding +/// box is the band and nothing else. +PLOT_TEST(an_invalid_span_is_shaded_and_moves_with_the_data) { + 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::data::ArraySeries s(sine_samples(), 0.0, 1.0); + plot::Plot p; + p.set_viewport({0, 0, static_cast(kW), static_cast(kH)}); + const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false}); + const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); + PLOT_CHECK(p.add_trace({&s, plot::Color::hex(0x00E070, 1.0f), 1.5f, ax, ay, "signal"}) == 0u); + + // The bounding box of the pixels the band, and only the band, is responsible for, plus the + // axes rect the frame was laid out into so the caller can check it did not move. + const auto footprint = [&](const plot::Range& view, std::pair span, + plot::Rect& axes_out, const char* dump) { + p.set_x_range(ax, view); + const std::pair one[1] = {span}; + p.set_invalid_spans(one); + r.begin_frame(kW, kH, kFrameRect); + r.draw(p); + r.end_frame(); + PLOT_CHECK(!r.last_frame_had_gl_error()); + dump_if_requested(r, dump); + const std::vector with = read_frame(ctx); + axes_out = p.layout().axes; + p.set_invalid_spans({}); + r.begin_frame(kW, kH, kFrameRect); + r.draw(p); + r.end_frame(); + PLOT_CHECK(!r.last_frame_had_gl_error()); + const std::vector without = read_frame(ctx); + plot::u32 lo_x = kW, lo_y = kH, hi_x = 0, hi_y = 0, differing = 0; + for (plot::u32 y = 0; y < kH; ++y) { + for (plot::u32 x = 0; x < kW; ++x) { + const plot::usize at = (plot::usize(y) * kW + x) * 4u; + if (with[at] == without[at] && with[at + 1u] == without[at + 1u] && + with[at + 2u] == without[at + 2u] && with[at + 3u] == without[at + 3u]) { + continue; + } + ++differing; + lo_x = std::min(lo_x, x); + lo_y = std::min(lo_y, y); + hi_x = std::max(hi_x, x); + hi_y = std::max(hi_y, y); + } + } + // A comparison of two frames is only evidence if the band was in one of them. + PLOT_CHECK(differing > 0u); + return plot::Rect{static_cast(lo_x), static_cast(lo_y), + static_cast(hi_x - lo_x + 1u), + static_cast(hi_y - lo_y + 1u)}; + }; + + // A band over the middle third of a 640-wide window. + plot::Rect axes_a{0.0f, 0.0f, 0.0f, 0.0f}; + const plot::Rect none_box = footprint({0.0, 640.0}, {640.0 / 3.0, 1280.0 / 3.0}, axes_a, + "invalid_span.ppm"); + // Inside the axes on every side -- a hole is not allowed to paint over a label strip -- and + // the full height of the data area, because the whole column has no data. + PLOT_CHECK(none_box.x >= axes_a.x && none_box.right() <= axes_a.right() + 1.0f); + PLOT_CHECK(none_box.y >= axes_a.y && none_box.bottom() <= axes_a.bottom() + 1.0f); + PLOT_CHECK_NEAR(none_box.h, axes_a.h, 2.0); + // The middle third: a third of the width, starting a third of the way across. Two pixels of + // slack for the fill's fractional edges. + PLOT_CHECK_NEAR(none_box.x - axes_a.x, axes_a.w / 3.0f, 2.0); + PLOT_CHECK_NEAR(none_box.w, axes_a.w / 3.0f, 2.0); + + // The same span under a pan of exactly 64 device pixels' worth of data. The band is anchored + // in the data, so it must move by 64 pixels -- the x note, measured the same way, does not + // move at all, which is the distinction this pair of cases draws. + const plot::f64 per_px = 640.0 / plot::f64(axes_a.w); + const plot::f64 dx = 64.0 * per_px; + plot::Rect axes_b{0.0f, 0.0f, 0.0f, 0.0f}; + const plot::Rect panned = footprint({dx, 640.0 + dx}, {640.0 / 3.0, 1280.0 / 3.0}, axes_b, + "invalid_span_panned.ppm"); + PLOT_CHECK_NEAR(panned.x, none_box.x - 64.0f, 2.0); + PLOT_CHECK_NEAR(panned.w, none_box.w, 2.0); + // The layout is identical across both: the band reserved nothing, so the strips the labels + // were measured into are where they were before any band existed. + PLOT_CHECK_NEAR(axes_b.x, axes_a.x, 1e-4); + PLOT_CHECK_NEAR(axes_b.y, axes_a.y, 1e-4); + PLOT_CHECK_NEAR(axes_b.w, axes_a.w, 1e-4); + PLOT_CHECK_NEAR(axes_b.h, axes_a.h, 1e-4); + + std::printf("[invalid span] band %.0f,%.0f %.0fx%.0f panned %.0f,%.0f %.0fx%.0f " + "(shift %.1f px) in axes %.0f,%.0f %.0fx%.0f\n", + none_box.x, none_box.y, none_box.w, none_box.h, panned.x, panned.y, panned.w, + panned.h, none_box.x - panned.x, axes_a.x, axes_a.y, axes_a.w, axes_a.h); + ctx.destroy(); +} + +/// A hole narrower than a pixel is still a report. A reserved span of one sample at a window +/// width of hundreds of thousands maps to a fraction of a column, and a fill rounded to zero +/// width would draw nothing -- the one case where the feature is most needed is the one where +/// naive rounding silently drops it. The band therefore has a one-device-pixel floor. +PLOT_TEST(a_sub_pixel_invalid_span_is_still_shaded) { + 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::data::ArraySeries s(sine_samples(), 0.0, 1.0); + plot::Plot p; + p.set_viewport({0, 0, static_cast(kW), static_cast(kH)}); + const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false}); + const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); + PLOT_CHECK(p.add_trace({&s, plot::Color::hex(0x00E070, 1.0f), 1.5f, ax, ay, "signal"}) == 0u); + + // One sample's worth of hole in a 200 000-wide window: well under a hundredth of a column. + p.set_x_range(ax, {0.0, 200000.0}); + const std::pair one[1] = {{100000.0, 100000.0}}; + p.set_invalid_spans(one); + r.begin_frame(kW, kH, kFrameRect); + r.draw(p); + r.end_frame(); + PLOT_CHECK(!r.last_frame_had_gl_error()); + dump_if_requested(r, "invalid_span_subpixel.ppm"); + const std::vector with = read_frame(ctx); + p.set_invalid_spans({}); + r.begin_frame(kW, kH, kFrameRect); + r.draw(p); + r.end_frame(); + PLOT_CHECK(!r.last_frame_had_gl_error()); + const std::vector without = read_frame(ctx); + + plot::u32 columns = 0, differing = 0; + for (plot::u32 x = 0; x < kW; ++x) { + bool any = false; + for (plot::u32 y = 0; y < kH; ++y) { + const plot::usize at = (plot::usize(y) * kW + x) * 4u; + if (with[at] != without[at] || with[at + 1u] != without[at + 1u] || + with[at + 2u] != without[at + 2u] || with[at + 3u] != without[at + 3u]) { + any = true; + ++differing; + } + } + if (any) ++columns; + } + // Drawn, and narrow: the floor is one device pixel, not a visible band widened to be seen. + PLOT_CHECK(differing > 0u); + PLOT_CHECK(columns >= 1u && columns <= 2u); + std::printf("[invalid span] sub-pixel hole lit %u column(s), %u pixels\n", columns, differing); + ctx.destroy(); +}