feat: a repaired invalid span is shaded faintly, not solidly
This commit is contained in:
1 parent
ebc67bd318
commit
1fc97d818f
7 files changed
+152
-25
No files matched your search
@@ -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:
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
#pragma once
|
||||
#include <span>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#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;
|
||||
}
|
||||
|
||||
/// 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<Rect> 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<InvalidSpanLayout> 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<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
|
||||
/// 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<MeasureSlot> measures_;
|
||||
std::vector<Annotation> annotations_;
|
||||
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()`
|
||||
/// 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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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*.
|
||||
|
||||
+5
-5
@@ -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<const std::pair<f64, f64>> spans) {
|
||||
void Plot::set_invalid_spans(std::span<const InvalidSpan> 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<f64, f64>& 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<f32>(tx.to_screen_x(a));
|
||||
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
|
||||
// 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});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<plot::f64, plot::f64> 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<plot::f64, plot::f64> 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<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);
|
||||
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<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();
|
||||
}
|
||||
Reference in new issue
Block a user