From 1fc97d818f5707bb05f870b2afa12b3d48912ba7 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Thu, 8 Oct 2026 22:59:07 +0200 Subject: [PATCH] feat: a repaired invalid span is shaded faintly, not solidly --- docs/styling.md | 2 + include/plot/plot/plot.hpp | 46 ++++++++++++--- include/plot/render/renderer.hpp | 9 +-- include/plot/render/style.hpp | 7 +++ src/plot/plot.cpp | 10 ++-- src/render/renderer.cpp | 4 +- tests/test_render_smoke.cpp | 99 ++++++++++++++++++++++++++++++-- 7 files changed, 152 insertions(+), 25 deletions(-) diff --git a/docs/styling.md b/docs/styling.md index b19052a..913a796 100644 --- a/docs/styling.md +++ b/docs/styling.md @@ -42,6 +42,8 @@ Colours (neither logical nor device — nothing to scale): | `axis_text` | `0xC8D0DC` | tick labels and axis titles | | `cursor` | `0xFFB020` | cursor markers; also the hovered/grab highlight | | `measure_fill` | `0xFFB020` at α 0.15 | the measurement band | +| `invalid_fill` | `0x8A94A6` at α 0.18 | a span the host declared invalid (`Plot::set_invalid_spans()`) | +| `invalid_repaired_fill` | `0x8A94A6` at α 0.08 | the same span once the host has marked it repaired | | `crosshair` | `0xE6EBF2` | crosshair lines and the readout panel's border (hue-neutral by design; trace markers keep each trace's own colour) | The scale: diff --git a/include/plot/plot/plot.hpp b/include/plot/plot/plot.hpp index 3956310..60e041a 100644 --- a/include/plot/plot/plot.hpp +++ b/include/plot/plot/plot.hpp @@ -3,7 +3,6 @@ #pragma once #include -#include #include #include @@ -51,6 +50,28 @@ constexpr bool x_strip_has_title(XStrip mode) noexcept { return mode == XStrip::Both || mode == XStrip::TitleOnly; } +/// One span of the primary x axis the host has declared invalid, in **data** coordinates -- +/// `Region`'s convention. In udpscope's case a reserved hole: samples the stream's counter says +/// exist and that have not been delivered. +/// +/// `repaired` says the host has since filled the span's samples in. Such a span is **still +/// reported**, because the shading is a record of what the window was missing, but it is drawn in +/// the fainter of the two tints -- so a reader can distinguish a gap that is still empty from one +/// that has since been repaired, and from a span that has left the list entirely. +struct InvalidSpan { + f64 x0 = 0.0; + f64 x1 = 0.0; + bool repaired = false; +}; + +/// One invalid span's measured shading: the rect, clipped to the axes' columns, and the state that +/// chooses its tint. A struct and not a bare `Rect` for `RegionLayout`'s reason -- the renderer +/// draws exactly what the layout measured and derives nothing of its own. +struct InvalidSpanLayout { + Rect fill; + bool repaired = false; +}; + /// Pixel geometry of a plot's chrome, all of it in the same pixel space as /// `Rect` (origin top-left) and therefore in **device** pixels: everything here is /// what the renderer draws with, already converted from the logical metrics in @@ -112,8 +133,10 @@ struct PlotLayout { /// 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; + /// on a band and no label beside it. Each entry carries the span's `repaired` state alongside + /// its rect, so the tint is chosen where the geometry is measured rather than looked up again + /// at draw time. + 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 @@ -535,8 +558,8 @@ class Plot { 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 + /// hole: samples the stream's counter says exist and that have not been delivered. Each span's + /// `x0`/`x1` are 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 @@ -546,8 +569,12 @@ class Plot { /// /// 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_; } + /// + /// A span whose `repaired` flag is set is shaded more faintly (`Style::invalid_repaired_fill` + /// against `Style::invalid_fill`). Clearing the shading is the host's decision and nothing + /// here retires a span on its own: a repaired span is drawn until the host stops sending it. + 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 @@ -876,6 +903,9 @@ 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(); + /// Measures every invalid span into `layout_.invalid_spans`: its interval clipped to the axes' + /// columns, with a one-device-pixel floor, and the state that picks its tint. Here and not in + /// the renderer for `build_regions()`' reason -- one x mapping, the frame's own. 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. @@ -924,7 +954,7 @@ class Plot { std::vector measures_; std::vector annotations_; std::vector regions_; - std::vector> invalid_spans_; + 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 8f8ea8f..a222c36 100644 --- a/include/plot/render/renderer.hpp +++ b/include/plot/render/renderer.hpp @@ -513,10 +513,11 @@ 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 + /// `Plot::invalid_spans()`, as `PlotLayout::invalid_spans` measured them: one fill per span, + /// in `style_.invalid_fill` or — for a span the host has marked repaired — + /// `style_.invalid_repaired_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); diff --git a/include/plot/render/style.hpp b/include/plot/render/style.hpp index a8ca242..71a67c9 100644 --- a/include/plot/render/style.hpp +++ b/include/plot/render/style.hpp @@ -62,6 +62,13 @@ struct Style { /// 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 same shading for a span whose samples have since arrived and been written in, at well + /// under half the alpha. The gap is still reported -- the window was drawn without that data -- + /// but a reader can tell a hole that is still empty from one that has been filled. Fainter by + /// alpha alone and not by hue, so the two read as two depths of one mark rather than as two + /// different kinds of thing; and still above the threshold where the difference from *no* band + /// is visible, which is the floor this alpha is chosen against. + Color invalid_repaired_fill = Color::hex(0x8A94A6, 0.08f); /// 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 7d82114..9bf1d7c 100644 --- a/src/plot/plot.cpp +++ b/src/plot/plot.cpp @@ -344,7 +344,7 @@ 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) { +void Plot::set_invalid_spans(std::span spans) { invalid_spans_.assign(spans.begin(), spans.end()); } @@ -1378,9 +1378,9 @@ void Plot::build_invalid_spans() { // 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); + for (const InvalidSpan& span : invalid_spans_) { + const f64 a = std::min(span.x0, span.x1); + const f64 b = std::max(span.x0, span.x1); 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)); @@ -1394,7 +1394,7 @@ void Plot::build_invalid_spans() { // 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}); + layout_.invalid_spans.push_back(InvalidSpanLayout{Rect{x, axes.y, w, axes.h}, span.repaired}); } } diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index fd05e25..dcb6735 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -1732,8 +1732,8 @@ 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); + for (const InvalidSpanLayout& band : layout.invalid_spans) { + fill_pixels(band.fill, band.repaired ? style_.invalid_repaired_fill : style_.invalid_fill); } } diff --git a/tests/test_render_smoke.cpp b/tests/test_render_smoke.cpp index 1bb70b3..e6d42d2 100644 --- a/tests/test_render_smoke.cpp +++ b/tests/test_render_smoke.cpp @@ -7241,10 +7241,10 @@ PLOT_TEST(an_invalid_span_is_shaded_and_moves_with_the_data) { // 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, + const auto footprint = [&](const plot::Range& view, plot::InvalidSpan span, plot::Rect& axes_out, const char* dump) { p.set_x_range(ax, view); - const std::pair one[1] = {span}; + const plot::InvalidSpan one[1] = {span}; p.set_invalid_spans(one); r.begin_frame(kW, kH, kFrameRect); r.draw(p); @@ -7283,7 +7283,7 @@ PLOT_TEST(an_invalid_span_is_shaded_and_moves_with_the_data) { // 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, + const plot::Rect none_box = footprint({0.0, 640.0}, {640.0 / 3.0, 1280.0 / 3.0, false}, 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. @@ -7301,8 +7301,8 @@ PLOT_TEST(an_invalid_span_is_shaded_and_moves_with_the_data) { 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"); + const plot::Rect panned = footprint({dx, 640.0 + dx}, {640.0 / 3.0, 1280.0 / 3.0, false}, + 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 @@ -7340,7 +7340,7 @@ PLOT_TEST(a_sub_pixel_invalid_span_is_still_shaded) { // 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}}; + const plot::InvalidSpan one[1] = {{100000.0, 100000.0, false}}; p.set_invalid_spans(one); r.begin_frame(kW, kH, kFrameRect); r.draw(p); @@ -7374,3 +7374,90 @@ PLOT_TEST(a_sub_pixel_invalid_span_is_still_shaded) { std::printf("[invalid span] sub-pixel hole lit %u column(s), %u pixels\n", columns, differing); ctx.destroy(); } + +/// The band carries three states and not two: a hole still missing its samples is shaded solidly, +/// a hole whose late packets have all arrived is shaded faintly, and a hole whose slots have aged +/// out of the host's buffer is not shaded at all. The faint tier is what keeps the report honest +/// -- the window *was* drawn without that data -- while telling a reader it is no longer missing. +/// +/// Measured as ink rather than as a sampled pixel: at a fixed view the trace, the grid and the +/// labels are byte-identical across all three frames, so every differing byte is the band's own +/// and a sum of absolute differences measures the tint and nothing else. The alternative -- read +/// one pixel inside the band -- would be measuring whatever the trace happens to do there. +PLOT_TEST(a_repaired_invalid_span_is_fainter_than_a_missing_one) { + 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); + p.set_x_range(ax, {0.0, 640.0}); + + // One band over the middle third, at one fixed view, in each of the three states. + const auto frame = [&](bool shaded, bool repaired, const char* dump) { + const plot::InvalidSpan one[1] = {{640.0 / 3.0, 1280.0 / 3.0, repaired}}; + if (shaded) { + p.set_invalid_spans(one); + } else { + p.set_invalid_spans({}); + } + r.begin_frame(kW, kH, kFrameRect); + r.draw(p); + r.end_frame(); + PLOT_CHECK(!r.last_frame_had_gl_error()); + if (dump != nullptr) dump_if_requested(r, dump); + return read_frame(ctx); + }; + + const std::vector none = frame(false, false, nullptr); + const std::vector missing = frame(true, false, "invalid_span_missing.ppm"); + const std::vector repaired = frame(true, true, "invalid_span_repaired.ppm"); + + // Total ink a frame laid down over the unshaded one, and the columns it covers. + const auto ink = [&](const std::vector& with, plot::u32& lo_x, plot::u32& hi_x) { + unsigned long long total = 0; + lo_x = kW; + hi_x = 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; + unsigned d = 0; + for (plot::usize c = 0; c < 4u; ++c) { + const unsigned a = with[at + c]; + const unsigned b = none[at + c]; + d += a > b ? a - b : b - a; + } + if (d == 0u) continue; + total += d; + lo_x = std::min(lo_x, x); + hi_x = std::max(hi_x, x); + } + } + return total; + }; + + plot::u32 m_lo = 0, m_hi = 0, r_lo = 0, r_hi = 0; + const unsigned long long missing_ink = ink(missing, m_lo, m_hi); + const unsigned long long repaired_ink = ink(repaired, r_lo, r_hi); + + // A repaired hole is still drawn. A tint that vanished would be the "retire the band on repair" + // behaviour this feature deliberately does not have: the window was missing that data when it + // was drawn, and the band is the record of it. + PLOT_CHECK(repaired_ink > 0ull); + // And the solid one is unmistakably heavier. The defaults are alpha 0.18 against 0.08, better + // than two to one, so half again is a wide margin and not a tuned threshold. + PLOT_CHECK(missing_ink > repaired_ink + repaired_ink / 2ull); + // Same pixels either way: the state chooses the tint and nothing about the geometry. + PLOT_CHECK(m_lo == r_lo && m_hi == r_hi); + std::printf("[invalid span] missing ink %llu over columns %u..%u, repaired ink %llu over " + "%u..%u\n", + missing_ink, m_lo, m_hi, repaired_ink, r_lo, r_hi); + ctx.destroy(); +}