feat: a span declared invalid is shaded behind the data it is missing from

This commit is contained in:
Martino Ferrari committed 2026-10-08 20:11:16 +02:00
1 parent 1ae72d4903
commit ebc67bd318
6 files changed
+250

No files matched your search

+26
View File
@@ -2,6 +2,8 @@
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <span>
#include <utility>
#include <vector>
#include <plot/core/rect.hpp>
@@ -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<RegionLayout> 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<Rect> 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<const std::pair<f64, f64>> spans);
std::span<const std::pair<f64, f64>> 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<MeasureSlot> measures_;
std::vector<Annotation> annotations_;
std::vector<Region> regions_;
std::vector<std::pair<f64, f64>> 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
+6
View File
@@ -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
+7
View File
@@ -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*.
+40
View File
@@ -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<const std::pair<f64, f64>> 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<f64, f64>& 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<f32>(tx.to_screen_x(a));
const f32 hi = static_cast<f32>(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 {
+9
View File
@@ -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);
+162
View File
@@ -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<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);
// 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<plot::f64, plot::f64> span,
plot::Rect& axes_out, const char* dump) {
p.set_x_range(ax, view);
const std::pair<plot::f64, plot::f64> 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<plot::u8> 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<plot::u8> 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<plot::f32>(lo_x), static_cast<plot::f32>(lo_y),
static_cast<plot::f32>(hi_x - lo_x + 1u),
static_cast<plot::f32>(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<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);
// 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<plot::f64, plot::f64> 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<plot::u8> 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<plot::u8> 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();
}