Compare commits
4
Commits
37e414bcc5
...
1fc97d818f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fc97d818f | ||
|
|
ebc67bd318 | ||
|
|
1ae72d4903 | ||
|
|
2ebf2be571 |
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:
|
||||
|
||||
@@ -76,6 +76,19 @@ class MinMaxPyramid {
|
||||
/// whose samples were all non-finite is stored as an empty aggregate
|
||||
/// (count 0, zero sums) that every reader treats as absent.
|
||||
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
|
||||
/// `absolute_sample`, dropping every aggregate that covered a sample before it.
|
||||
/// Monotonic, and a no-op only when `absolute_sample <= window_base()`: an empty
|
||||
|
||||
@@ -64,6 +64,24 @@ class RingBuffer {
|
||||
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().
|
||||
u64 size() const noexcept {
|
||||
const u64 p = produced_.load(std::memory_order_acquire);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
#include <span>
|
||||
#include <vector>
|
||||
|
||||
#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;
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -105,6 +128,15 @@ 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. 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
|
||||
@@ -525,6 +557,25 @@ 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 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
|
||||
/// 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 +903,10 @@ 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.
|
||||
static RegionStats evaluate_region_stats(const Region& region, const data::Series& series);
|
||||
@@ -899,6 +954,7 @@ class Plot {
|
||||
std::vector<MeasureSlot> measures_;
|
||||
std::vector<Annotation> annotations_;
|
||||
std::vector<Region> regions_;
|
||||
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,6 +513,13 @@ 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` 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);
|
||||
/// 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
|
||||
|
||||
@@ -55,6 +55,20 @@ 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 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*.
|
||||
|
||||
@@ -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) {
|
||||
if (absolute_sample <= base_) return;
|
||||
if (absolute_sample > count_) {
|
||||
|
||||
@@ -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 InvalidSpan> 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 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) --------------------------------------------------
|
||||
|
||||
CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept {
|
||||
|
||||
@@ -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 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) {
|
||||
const HotHandles hot = plot.hot_handles();
|
||||
const f32 edge_w = style_.device(style_.line_width_px);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <plot/data/detail/merge_count.hpp>
|
||||
#include <plot/data/minmax_pyramid.hpp>
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
#include <random>
|
||||
|
||||
using plot::data::kBucket;
|
||||
@@ -342,3 +343,69 @@ PLOT_TEST(pyramid_level_cap_keeps_the_span_shift_defined) {
|
||||
p.append(1.0f);
|
||||
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);
|
||||
}
|
||||
@@ -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(!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();
|
||||
}
|
||||
@@ -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[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);
|
||||
}
|
||||
Reference in new issue
Block a user