Compare commits

...
4 Commits
12 changed files with 536 additions and 0 deletions

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:
+13
View File
@@ -76,6 +76,19 @@ class MinMaxPyramid {
/// whose samples were all non-finite is stored as an empty aggregate /// whose samples were all non-finite is stored as an empty aggregate
/// (count 0, zero sums) that every reader treats as absent. /// (count 0, zero sums) that every reader treats as absent.
void append(f32 v); void append(f32 v);
/// Folds a backfilled sample into every aggregate that covers absolute index
/// `absolute_sample`.
///
/// **Precondition: the sample at that index was appended non-finite.** That is what makes
/// this purely additive -- min/max widen, sum/sumsq accumulate and count rises by one, at
/// every covering level -- so no bucket is ever recomputed and the cost is O(levels)
/// regardless of how wide the repaired span is. Amending a *finite* sample would need the
/// old value removed from sum/sumsq and could not restore min/max at all, which is why
/// this is a one-way NaN-to-value operation and not a general write.
///
/// An index outside `[window_base(), window_end())` is a no-op: the window slides on the
/// consumer thread, so a caller cannot prove the slot is still held before asking.
void amend(u64 absolute_sample, f32 v);
/// Slides the retained window's leading edge to absolute sample index /// Slides the retained window's leading edge to absolute sample index
/// `absolute_sample`, dropping every aggregate that covered a sample before it. /// `absolute_sample`, dropping every aggregate that covered a sample before it.
/// Monotonic, and a no-op only when `absolute_sample <= window_base()`: an empty /// Monotonic, and a no-op only when `absolute_sample <= window_base()`: an empty
+18
View File
@@ -64,6 +64,24 @@ class RingBuffer {
for (u64 i = 0; i < n; ++i) push(vs[i]); for (u64 i = 0; i < n; ++i) push(vs[i]);
} }
/// Producer thread only: rewrites the slot at absolute index `absolute`, without pushing.
/// Returns false when that index is not retained -- already overwritten, or not yet
/// produced -- in which case nothing is written.
///
/// This is for *backfill*: a slot pushed as a placeholder and corrected when the data that
/// belongs there arrives late. It is the same race class as `push()` -- one relaxed store
/// into a slot a consumer may be reading, and T is lock-free by the class's static_assert,
/// so a reader sees the old value or the new one and never a torn one. It deliberately does
/// not touch `produced_`: the count fixes which indices a snapshot reads, and an amend
/// changes a value inside that window rather than extending it.
bool amend(u64 absolute, T v) {
const u64 p = produced_.load(std::memory_order_relaxed);
if (absolute >= p) return false;
if (p > cap_ && absolute < p - cap_) return false;
buf_[static_cast<usize>(absolute & mask_)].store(v, std::memory_order_relaxed);
return true;
}
/// Consumer: retained element count, saturating at capacity(). /// Consumer: retained element count, saturating at capacity().
u64 size() const noexcept { u64 size() const noexcept {
const u64 p = produced_.load(std::memory_order_acquire); const u64 p = produced_.load(std::memory_order_acquire);
+56
View File
@@ -2,6 +2,7 @@
// SPDX-FileCopyrightText: 2026 Martino Ferrari // SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <span>
#include <vector> #include <vector>
#include <plot/core/rect.hpp> #include <plot/core/rect.hpp>
@@ -49,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
@@ -105,6 +128,15 @@ struct PlotLayout {
/// controller's hit test reads the very same rects, so where a region is drawn and /// 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. /// where a pointer can grab it cannot drift apart.
std::vector<RegionLayout> regions; 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. 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 /// 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
@@ -525,6 +557,25 @@ class Plot {
void set_x_note(std::string text); void set_x_note(std::string text);
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
/// 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
/// 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.
///
/// 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
/// series, a longer one, or a sliding window whose sample count stayed the same /// series, a longer one, or a sliding window whose sample count stayed the same
@@ -852,6 +903,10 @@ 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();
/// 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.
static RegionStats evaluate_region_stats(const Region& region, const data::Series& series); static RegionStats evaluate_region_stats(const Region& region, const data::Series& series);
@@ -899,6 +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<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
+7
View File
@@ -513,6 +513,13 @@ 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 `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); void draw_axis_lines(const Plot& plot);
/// The chrome helpers take the frame's layout: `Plot::layout()` recomputes the /// 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 /// ticks, the strips and the grid, so it is computed once, in `draw()`, and handed
+14
View File
@@ -55,6 +55,20 @@ struct Style {
Color axis_text = Color::hex(0xC8D0DC); Color axis_text = Color::hex(0xC8D0DC);
Color cursor = Color::hex(0xFFB020); Color cursor = Color::hex(0xFFB020);
Color measure_fill = Color::hex(0xFFB020, 0.15f); 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 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*.
+37
View File
@@ -179,6 +179,43 @@ void MinMaxPyramid::append(f32 v) {
} }
} }
void MinMaxPyramid::amend(u64 absolute_sample, f32 v) {
if (!std::isfinite(v)) return;
if (absolute_sample < base_ || absolute_sample >= count_) return;
const f64 vd = f64(v);
const auto fold = [&](Entry& e) {
// An empty bucket's `mm` is meaningless (see the Entry note), so the first finite
// sample *sets* the extremes instead of widening from a zero that was never a sample.
if (e.count == 0) {
e.mm.min = v;
e.mm.max = v;
} else {
e.mm.min = std::min(e.mm.min, v);
e.mm.max = std::max(e.mm.max, v);
}
e.sum += vd;
e.sumsq += vd * vd;
++e.count;
};
// The in-progress level-0 bucket, when the index lies inside it. It is not an entry yet,
// so the loop below cannot reach it; leaving it out would lose the amendment for a hole
// whose tail is still filling, which is exactly the hole a prompt backfill arrives into.
if (count_ % kBucket != 0 && absolute_sample >= (count_ / kBucket) * kBucket) {
fold(pending_);
}
// Every level independently: min/max/sum/sumsq/count are each associative, so folding the
// value into a parent gives the same aggregate as re-merging its children would.
for (u32 l = 0; l < static_cast<u32>(levels_.size()); ++l) {
const u64 i = absolute_sample / level_span(l);
if (i < levels_[l].base) continue;
if (i - levels_[l].base >= static_cast<u64>(levels_[l].entries.size())) continue;
fold(levels_[l].entries[static_cast<usize>(i - levels_[l].base)]);
}
}
void MinMaxPyramid::drop_before(u64 absolute_sample) { void MinMaxPyramid::drop_before(u64 absolute_sample) {
if (absolute_sample <= base_) return; if (absolute_sample <= base_) return;
if (absolute_sample > count_) { if (absolute_sample > count_) {
+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_x_note(std::string text) { x_note_ = std::move(text); }
void Plot::set_invalid_spans(std::span<const InvalidSpan> spans) {
invalid_spans_.assign(spans.begin(), spans.end());
}
f64 Plot::x_axis_origin(u32 axis) const { f64 Plot::x_axis_origin(u32 axis) const {
return axis < x_axes_.size() ? x_axes_[axis].origin : 0.0; 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 // 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. // interval map to the pixel columns the frame will actually use.
build_regions(); build_regions();
build_invalid_spans();
build_legend(); build_legend();
// The crosshair last: its panel is fitted into `axes` and its lines are held inside it, and // 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 // 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 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));
// 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(InvalidSpanLayout{Rect{x, axes.y, w, axes.h}, span.repaired});
}
}
// --- crosshair (spec §15) -------------------------------------------------- // --- crosshair (spec §15) --------------------------------------------------
CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept { 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.) // against the line passes would not change a pixel.)
set_clip(layout.axes); set_clip(layout.axes);
draw_grid(plot); 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(); clear_clip();
draw_axis_lines(plot); draw_axis_lines(plot);
set_clip(layout.axes); 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 InvalidSpanLayout& band : layout.invalid_spans) {
fill_pixels(band.fill, band.repaired ? style_.invalid_repaired_fill : style_.invalid_fill);
}
}
void Renderer::draw_regions(const Plot& plot, const PlotLayout& layout) { void Renderer::draw_regions(const Plot& plot, const PlotLayout& layout) {
const HotHandles hot = plot.hot_handles(); const HotHandles hot = plot.hot_handles();
const f32 edge_w = style_.device(style_.line_width_px); const f32 edge_w = style_.device(style_.line_width_px);
+67
View File
@@ -6,6 +6,7 @@
#include <plot/data/detail/merge_count.hpp> #include <plot/data/detail/merge_count.hpp>
#include <plot/data/minmax_pyramid.hpp> #include <plot/data/minmax_pyramid.hpp>
#include <algorithm> #include <algorithm>
#include <limits>
#include <random> #include <random>
using plot::data::kBucket; using plot::data::kBucket;
@@ -342,3 +343,69 @@ PLOT_TEST(pyramid_level_cap_keeps_the_span_shift_defined) {
p.append(1.0f); p.append(1.0f);
PLOT_CHECK(p.level_count() >= 1); PLOT_CHECK(p.level_count() >= 1);
} }
PLOT_TEST(amend_folds_a_backfilled_sample_into_every_covering_level) {
// A hole is appended as NaN -- which the pyramid already stores as an empty aggregate --
// and amended later when the packet that owned it finally arrives. The amendment has to
// reach every level that covers the index, because the renderer reads whichever level the
// zoom picked: repairing level 0 alone would make a trace whole when zoomed in and still
// broken when zoomed out.
MinMaxPyramid p;
p.reserve(8 * kBucket);
for (u64 i = 0; i < 8 * kBucket; ++i) {
// One NaN, at an index comfortably inside the first bucket of every level.
p.append(i == 5 ? std::numeric_limits<f32>::quiet_NaN() : 1.0f);
}
// Before: every covering bucket aggregates kBucket-1 ones and knows nothing of index 5.
PLOT_CHECK(p.bucket_count(0, 0) == kBucket - 1);
PLOT_CHECK_NEAR(p.bucket(0, 0).max, 1.0);
p.amend(5, 7.0f);
// Level 0 gained the sample.
PLOT_CHECK(p.bucket_count(0, 0) == kBucket);
PLOT_CHECK_NEAR(p.bucket(0, 0).max, 7.0);
PLOT_CHECK_NEAR(p.bucket_sum(0, 0), f64(kBucket - 1) + 7.0);
PLOT_CHECK_NEAR(p.bucket_sumsq(0, 0), f64(kBucket - 1) + 49.0);
// And so did every coarser level that covers it.
for (u32 l = 1; l < p.level_count(); ++l) {
if (p.level_size(l) == 0) continue;
const u64 span = u64{kBucket} << l;
const u64 b = 5 / span;
if (b < p.level_base(l) || b - p.level_base(l) >= p.level_size(l)) continue;
PLOT_CHECK_NEAR(p.bucket(l, b).max, 7.0);
PLOT_CHECK(p.bucket_count(l, b) == span);
}
}
PLOT_TEST(amend_makes_an_all_nan_bucket_present_again) {
// A bucket whose every sample was non-finite is stored with count 0, which every reader
// treats as absent. One amendment has to make it present -- min and max become the value
// rather than widening from a meaningless zero, which is the case a plain fold gets wrong.
MinMaxPyramid p;
p.reserve(4 * kBucket);
const f32 nan = std::numeric_limits<f32>::quiet_NaN();
for (u64 i = 0; i < 2 * kBucket; ++i) p.append(i < kBucket ? nan : 1.0f);
PLOT_CHECK(p.bucket_count(0, 0) == 0);
p.amend(3, -2.0f);
PLOT_CHECK(p.bucket_count(0, 0) == 1);
PLOT_CHECK_NEAR(p.bucket(0, 0).min, -2.0);
PLOT_CHECK_NEAR(p.bucket(0, 0).max, -2.0);
}
PLOT_TEST(amend_outside_the_window_is_a_no_op) {
// An index the window has already dropped, or one not yet appended, has nowhere to go.
// Silently ignoring it is what lets the caller amend without first proving the ring still
// holds the slot -- a race it cannot win, because the window slides on the other thread.
MinMaxPyramid p;
p.reserve(4 * kBucket);
for (u64 i = 0; i < 4 * kBucket; ++i) p.append(1.0f);
p.drop_before(2 * kBucket);
const u64 before = p.bucket_count(0, 2);
p.amend(5, 99.0f); // dropped
p.amend(100 * kBucket, 99.0f); // never appended
PLOT_CHECK(p.bucket_count(0, 2) == before);
PLOT_CHECK_NEAR(p.bucket(0, 2).max, 1.0);
}
+249
View File
@@ -7212,3 +7212,252 @@ PLOT_TEST(a_sparse_trace_is_graded_by_slope_like_a_dense_one) {
PLOT_CHECK(std::abs(dense.riser - sparse.riser) < 24); PLOT_CHECK(std::abs(dense.riser - sparse.riser) < 24);
PLOT_CHECK(!r.last_frame_had_gl_error()); 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, plot::InvalidSpan span,
plot::Rect& axes_out, const char* dump) {
p.set_x_range(ax, view);
const plot::InvalidSpan 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, 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.
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, 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
// 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 plot::InvalidSpan one[1] = {{100000.0, 100000.0, false}};
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();
}
/// 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();
}
+24
View File
@@ -35,3 +35,27 @@ PLOT_TEST(ring_capacity_rounds_up_to_a_power_of_two) {
PLOT_CHECK_NEAR(r[0], 872.0); // oldest retained PLOT_CHECK_NEAR(r[0], 872.0); // oldest retained
PLOT_CHECK_NEAR(r[127], 999.0); // newest PLOT_CHECK_NEAR(r[127], 999.0); // newest
} }
PLOT_TEST(ring_amend_rewrites_a_retained_slot) {
// Backfill: a slot pushed as a placeholder is rewritten once the data that belongs there
// arrives. The index is absolute, because that is the only coordinate a late packet can
// compute -- a retained-window index would have moved by the time it got here.
plot::data::RingBuffer<f32> r(8);
for (int i = 0; i < 6; ++i) r.push(f32(i));
PLOT_CHECK(r.amend(3, 42.0f));
PLOT_CHECK_NEAR(r[3], 42.0);
PLOT_CHECK(r.produced() == 6); // an amend is not a push
}
PLOT_TEST(ring_amend_refuses_an_index_the_ring_no_longer_holds) {
// The window slides on the producer, so a late amend may arrive after its slot was
// overwritten. Writing it anyway would corrupt whatever sample now owns that slot, so the
// bounds test is the whole safety of the operation and the caller reads the result.
plot::data::RingBuffer<f32> r(8);
for (int i = 0; i < 20; ++i) r.push(f32(i));
PLOT_CHECK(r.base() == 12);
PLOT_CHECK(!r.amend(3, 42.0f)); // aged out
PLOT_CHECK(!r.amend(20, 42.0f)); // never produced
PLOT_CHECK_NEAR(r[0], 12.0); // and nothing moved
PLOT_CHECK_NEAR(r[7], 19.0);
}