feat: a repaired invalid span is shaded faintly, not solidly

This commit is contained in:
Martino Ferrari committed 2026-10-08 22:59:07 +02:00
1 parent ebc67bd318
commit 1fc97d818f
7 files changed
+152 -25

No files matched your search

+2
View File
@@ -42,6 +42,8 @@ Colours (neither logical nor device — nothing to scale):
| `axis_text` | `0xC8D0DC` | tick labels and axis titles | | `axis_text` | `0xC8D0DC` | tick labels and axis titles |
| `cursor` | `0xFFB020` | cursor markers; also the hovered/grab highlight | | `cursor` | `0xFFB020` | cursor markers; also the hovered/grab highlight |
| `measure_fill` | `0xFFB020` at α 0.15 | the measurement band | | `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) | | `crosshair` | `0xE6EBF2` | crosshair lines and the readout panel's border (hue-neutral by design; trace markers keep each trace's own colour) |
The scale: The scale:
+38 -8
View File
@@ -3,7 +3,6 @@
#pragma once #pragma once
#include <span> #include <span>
#include <utility>
#include <vector> #include <vector>
#include <plot/core/rect.hpp> #include <plot/core/rect.hpp>
@@ -51,6 +50,28 @@ constexpr bool x_strip_has_title(XStrip mode) noexcept {
return mode == XStrip::Both || mode == XStrip::TitleOnly; 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 /// 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 /// `Rect` (origin top-left) and therefore in **device** pixels: everything here is
/// what the renderer draws with, already converted from the logical metrics in /// 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 /// 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 /// 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 /// 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. /// on a band and no label beside it. Each entry carries the span's `repaired` state alongside
std::vector<Rect> invalid_spans; /// its rect, so the tint is chosen where the geometry is measured rather than looked up again
/// at draw time.
std::vector<InvalidSpanLayout> invalid_spans;
/// The crosshair's geometry, when one is active: the two lines' stroke boxes, the /// 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 /// 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 /// 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_; } 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 /// 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 /// hole: samples the stream's counter says exist and that have not been delivered. Each span's
/// is `(x_begin, x_end)` in **data** coordinates, `Region`'s convention, and is shaded behind /// `x0`/`x1` are in **data** coordinates, `Region`'s convention, and is shaded behind
/// the traces and over the grid. /// the traces and over the grid.
/// ///
/// Deliberately *not* a `Region`: a region is interactive -- draggable bounds, hot zones, a /// 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 /// 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. /// sorted and may overlap; an empty call clears the shading.
void set_invalid_spans(std::span<const std::pair<f64, f64>> spans); ///
std::span<const std::pair<f64, f64>> 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<const InvalidSpan> spans);
std::span<const InvalidSpan> invalid_spans() const noexcept { return invalid_spans_; }
/// The measurement for `id`. Re-evaluated only when a cursor moved, when the /// 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 /// 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 /// region's pixel geometry is derived -- the controller hit-tests what this
/// measured instead of mapping the bounds through the transform again. /// measured instead of mapping the bounds through the transform again.
void build_regions(); 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(); void build_invalid_spans();
/// Exact statistics of `region` over `series`, or an invalid result when the /// Exact statistics of `region` over `series`, or an invalid result when the
/// interval covers no sample. The one place the region arithmetic lives. /// interval covers no sample. The one place the region arithmetic lives.
@@ -924,7 +954,7 @@ class Plot {
std::vector<MeasureSlot> measures_; std::vector<MeasureSlot> measures_;
std::vector<Annotation> annotations_; std::vector<Annotation> annotations_;
std::vector<Region> regions_; std::vector<Region> regions_;
std::vector<std::pair<f64, f64>> invalid_spans_; std::vector<InvalidSpan> invalid_spans_;
/// The (region, trace) statistics cache, filled on demand by `region_stats()` /// 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_` /// 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 /// does. Entries are created for the pairs a caller actually asks about, never
+5 -4
View File
@@ -513,10 +513,11 @@ class Renderer {
void draw_plot(Plot& plot, const PlotLayout& layout); void draw_plot(Plot& plot, const PlotLayout& layout);
void draw_grid(const Plot& plot); void draw_grid(const Plot& plot);
/// `Plot::invalid_spans()`, as `PlotLayout::invalid_spans` measured them: one fill per span in /// `Plot::invalid_spans()`, as `PlotLayout::invalid_spans` measured them: one fill per span,
/// `style_.invalid_fill`. Over the grid and under everything else inside the axes, so a hole /// in `style_.invalid_fill` or — for a span the host has marked repaired —
/// reads as a property of the plot's background -- the grid shows through it and a trace that /// `style_.invalid_repaired_fill`. Over the grid and under everything else inside the axes, so
/// does have data in those columns is drawn on top, which is what distinguishes a reserved /// 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. /// hole from a trace that has gone quiet.
void draw_invalid_spans(const PlotLayout& layout); void draw_invalid_spans(const PlotLayout& layout);
void draw_axis_lines(const Plot& plot); void draw_axis_lines(const Plot& plot);
+7
View File
@@ -62,6 +62,13 @@ struct Style {
/// low enough that the grid under it stays legible, which is what makes the band read as a /// 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. /// region of the plot rather than as a panel laid over it.
Color invalid_fill = Color::hex(0x8A94A6, 0.18f); 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 /// 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* /// deliberately *not* this colour -- they carry each trace's own, because a marker says *which*
/// trace and the line says *where*. /// trace and the line says *where*.
+5 -5
View File
@@ -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_x_note(std::string text) { x_note_ = std::move(text); }
void Plot::set_invalid_spans(std::span<const std::pair<f64, f64>> spans) { void Plot::set_invalid_spans(std::span<const InvalidSpan> spans) {
invalid_spans_.assign(spans.begin(), spans.end()); 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. // the band is most needed for. One device pixel is the floor.
const f32 min_w = std::max(1.0f, style_.device(1.0f)); const f32 min_w = std::max(1.0f, style_.device(1.0f));
for (const std::pair<f64, f64>& span : invalid_spans_) { for (const InvalidSpan& span : invalid_spans_) {
const f64 a = std::min(span.first, span.second); const f64 a = std::min(span.x0, span.x1);
const f64 b = std::max(span.first, span.second); const f64 b = std::max(span.x0, span.x1);
if (!std::isfinite(a) || !std::isfinite(b)) continue; if (!std::isfinite(a) || !std::isfinite(b)) continue;
const f32 lo = static_cast<f32>(tx.to_screen_x(a)); const f32 lo = static_cast<f32>(tx.to_screen_x(a));
const f32 hi = static_cast<f32>(tx.to_screen_x(b)); const f32 hi = static_cast<f32>(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 // 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. // 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); 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});
} }
} }
+2 -2
View File
@@ -1732,8 +1732,8 @@ void Renderer::draw_cursor_label(const Plot& plot, const PlotLayout& layout, con
} }
void Renderer::draw_invalid_spans(const PlotLayout& layout) { void Renderer::draw_invalid_spans(const PlotLayout& layout) {
for (const Rect& band : layout.invalid_spans) { for (const InvalidSpanLayout& band : layout.invalid_spans) {
if (band.w > 0.0f) fill_pixels(band, style_.invalid_fill); fill_pixels(band.fill, band.repaired ? style_.invalid_repaired_fill : style_.invalid_fill);
} }
} }
+93 -6
View File
@@ -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 // 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. // 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<plot::f64, plot::f64> span, const auto footprint = [&](const plot::Range& view, plot::InvalidSpan span,
plot::Rect& axes_out, const char* dump) { plot::Rect& axes_out, const char* dump) {
p.set_x_range(ax, view); p.set_x_range(ax, view);
const std::pair<plot::f64, plot::f64> one[1] = {span}; const plot::InvalidSpan one[1] = {span};
p.set_invalid_spans(one); p.set_invalid_spans(one);
r.begin_frame(kW, kH, kFrameRect); r.begin_frame(kW, kH, kFrameRect);
r.draw(p); 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. // A band over the middle third of a 640-wide window.
plot::Rect axes_a{0.0f, 0.0f, 0.0f, 0.0f}; 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"); "invalid_span.ppm");
// Inside the axes on every side -- a hole is not allowed to paint over a label strip -- and // 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. // 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 per_px = 640.0 / plot::f64(axes_a.w);
const plot::f64 dx = 64.0 * per_px; const plot::f64 dx = 64.0 * per_px;
plot::Rect axes_b{0.0f, 0.0f, 0.0f, 0.0f}; 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, const plot::Rect panned = footprint({dx, 640.0 + dx}, {640.0 / 3.0, 1280.0 / 3.0, false},
"invalid_span_panned.ppm"); axes_b, "invalid_span_panned.ppm");
PLOT_CHECK_NEAR(panned.x, none_box.x - 64.0f, 2.0); PLOT_CHECK_NEAR(panned.x, none_box.x - 64.0f, 2.0);
PLOT_CHECK_NEAR(panned.w, none_box.w, 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 // 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. // 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}); p.set_x_range(ax, {0.0, 200000.0});
const std::pair<plot::f64, plot::f64> one[1] = {{100000.0, 100000.0}}; const plot::InvalidSpan one[1] = {{100000.0, 100000.0, false}};
p.set_invalid_spans(one); p.set_invalid_spans(one);
r.begin_frame(kW, kH, kFrameRect); r.begin_frame(kW, kH, kFrameRect);
r.draw(p); 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); std::printf("[invalid span] sub-pixel hole lit %u column(s), %u pixels\n", columns, differing);
ctx.destroy(); 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<plot::f32>(kW), static_cast<plot::f32>(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<plot::u8> none = frame(false, false, nullptr);
const std::vector<plot::u8> missing = frame(true, false, "invalid_span_missing.ppm");
const std::vector<plot::u8> 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<plot::u8>& 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();
}