Compare commits

...
8 Commits
Author SHA1 Message Date
Martino Ferrari 9790becb33 chore: ignore an in-tree configure's output
CI / debug (tests + all adapters) (push) Canceled after 0s
CI / coverage (floor + measured table) (push) Canceled after 0s
CI / asan (AddressSanitizer) (push) Canceled after 0s
`build/` only covers the directory the presets use, so `cmake -S . -B .` left
CMakeFiles/, Makefile, cmake_install.cmake and the generated include/plot/version.hpp
untracked in the repository root -- which shows up as dirty content in the parent
project's status whenever the submodule is checked out.
2026-10-12 16:49:06 +02:00
Martino Ferrari 2f7cdccdde docs: name and link the project this library is built for
The README listed the four host adapters and never said what any of them was for. The GTK4
one exists for udpscope, whose URL is now here, and the library's interaction layer --
cursors, measurement marks, invalid-span shading -- was shaped by what that host needed.
The source and test notes that say "in udpscope's case" have a pointer to it now.
2026-10-12 16:45:23 +02:00
Martino Ferrari be1f31a04f feat: an open box zoom draws the frame the release will apply
The controller has always tracked its selection; nothing drew it, so a Ctrl or
Shift drag framed a range with no feedback until the release.

The preview is the area the release will change, not what the pointer swept:
Ctrl frames the x range only, so a band across the plot is what it will zoom,
where the sliver a straight horizontal drag sweeps is only the gesture. The
plot carries the rect a host hands it -- it learns nothing about pointers --
and the renderer draws a low-alpha fill under a light border over everything
else inside the axes.
2026-10-11 11:07:55 +02:00
Martino Ferrari 1fc97d818f feat: a repaired invalid span is shaded faintly, not solidly 2026-10-08 22:59:07 +02:00
Martino Ferrari ebc67bd318 feat: a span declared invalid is shaded behind the data it is missing from 2026-10-08 20:11:16 +02:00
Martino Ferrari 1ae72d4903 feat: a retained slot can be backfilled without pushing 2026-10-08 19:32:47 +02:00
Martino Ferrari 2ebf2be571 feat: a non-finite aggregate can be amended additively at every level 2026-10-08 19:18:51 +02:00
Martino FerrariandClaude Opus 4.6 37e414bcc5 feat: grade a sparse window's polyline by slope, so phosphor does not switch off on zoom
Intensity grading was gated on the column reduction, which is gated on a
density whose only job is killing the moire beat. Dwell has nothing to do
with that, so narrowing a window past the threshold turned the whole
phosphor model off and a discontinuity went from a third of a flat top's
ink to all of it in one pixel of zoom.

stage_trace_slopes() grades each segment by its own slope instead, with a
measure that agrees with the dense path at the boundary, and reports
itself as FrameStats::slope_traces -- disjoint from column_traces, so a
host asserting graded_traces == column_traces reads the same as before.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-08 16:56:10 +02:00
24 changed files with 1270 additions and 12 deletions

No files matched your search

+12
View File
@@ -3,3 +3,15 @@ build/
# Not this project's and not generated by it: a Solarus game-engine message that # Not this project's and not generated by it: a Solarus game-engine message that
# happens to sit in the repo root. # happens to sit in the repo root.
error.txt error.txt
# An *in-tree* configure writes these beside the sources. `build/` above only covers the
# directory the presets use, and `cmake -S . -B .` leaves the whole of CMake's output in the
# repository root -- which then reads as an untracked submodule to whoever has it checked out.
CMakeFiles/
CMakeCache.txt
cmake_install.cmake
Makefile
CTestTestfile.cmake
CPackConfig.cmake
# Configured from `include/plot/version.hpp.in`, so it is output and not a source file.
include/plot/version.hpp
+16 -4
View File
@@ -14,6 +14,12 @@ built on top of it.
| Qt6 | `plot::qt6` | `adapters/qt6/` | | Qt6 | `plot::qt6` | `adapters/qt6/` |
| GTK4 | `plot::gtk4` | `adapters/gtk4/` | | GTK4 | `plot::gtk4` | `adapters/gtk4/` |
The GTK4 adapter exists for, and is exercised by, a real consumer: **udpscope**, an oscilloscope-style viewer
for a UDPStreamer source, at <https://git.ferrarinas.it/martino/udpscope>. It embeds the core through
`plot::gtk4`, and the library's interaction layer — cursors, measurement marks, the shading of a span a host
has declared invalid — has been shaped by what that host needed. The notes in `src/`, `docs/` and `tests/`
that say "in udpscope's case" are those places.
The visible x range is mapped onto sample indices through the series' own x The visible x range is mapped onto sample indices through the series' own x
(`Series::neighbours()`, a binary search over the real stamps), never through the (`Series::neighbours()`, a binary search over the real stamps), never through the
`(x - x0()) / dx()` grid: `dx()` is a *nominal* spacing, and a series whose x is a streamed `(x - x0()) / dx()` grid: `dx()` is a *nominal* spacing, and a series whose x is a streamed
@@ -48,7 +54,10 @@ run touched a GPU, so read it as an upper bound and a record of the software pat
signal sits, faint where it only passes through, the way a digital-phosphor scope reads. It is signal sits, faint where it only passes through, the way a digital-phosphor scope reads. It is
what puts back the information the reduction throws away — a column's min and max are the same what puts back the information the reduction throws away — a column's min and max are the same
whether its samples fill the span or pile at its two ends — so the gather is asked at a level whether its samples fill the span or pile at its two ends — so the gather is asked at a level
finer than the pixel grid and the distribution read from those sub-buckets; finer than the pixel grid and the distribution read from those sub-buckets. Below the density at
which columns are reduced the same switch grades the **polyline** by each segment's slope
(`FrameStats::slope_traces`), so the look does not change as a window narrows past the
threshold;
- sample **markers** when a window is sparse enough that its samples are the shape - sample **markers** when a window is sparse enough that its samples are the shape
(`Style::marker_below_samples`, 50 by default, `marker_px` across, in the trace's own colour); (`Style::marker_below_samples`, 50 by default, `marker_px` across, in the trace's own colour);
- grid with independent major/minor line classes (`Style::grid_major_visible`, - grid with independent major/minor line classes (`Style::grid_major_visible`,
@@ -78,7 +87,10 @@ run touched a GPU, so read it as an upper bound and a record of the software pat
- input parity across hosts: pinch-zoom and pan gestures on Qt6 and GTK4 drive the same - input parity across hosts: pinch-zoom and pan gestures on Qt6 and GTK4 drive the same
controller paths as mouse and wheel (touch parity), with keyboard panning controller paths as mouse and wheel (touch parity), with keyboard panning
(arrow keys), Escape cancelling a drag or box-zoom, double-click re-fitting the axes, (arrow keys), Escape cancelling a drag or box-zoom, double-click re-fitting the axes,
and modifier-masked box zoom (Shift = both axes, Ctrl = x only, Alt = y only); and modifier-masked box zoom (Shift = both axes, Ctrl = x only, Alt = y only), the area being
chosen framed as the drag runs -- a low-alpha fill under a light border, widened along the axis
the release will *not* zoom, so Ctrl's frame is the band it is about to apply rather than the
sliver a straight horizontal drag sweeps;
- DPI-consistent sizing: metrics are logical units converted once through - DPI-consistent sizing: metrics are logical units converted once through
`Style::ui_scale`; `Style::ui_scale`;
- an anti-aliased embedded monospaced font, plus an optional runtime TrueType backend - an anti-aliased embedded monospaced font, plus an optional runtime TrueType backend
@@ -184,12 +196,12 @@ documentation.
```sh ```sh
cmake --preset linux-debug cmake --preset linux-debug
cmake --build build/debug cmake --build build/debug
ctest --test-dir build/debug --output-on-failure # 350 cases, 0 failed ctest --test-dir build/debug --output-on-failure # 377 cases, 0 failed
tools/coverage.sh # configure+build+run+capture+floor check tools/coverage.sh # configure+build+run+capture+floor check
``` ```
The `linux-debug` suite runs **350 cases**; the `linux-coverage` preset builds **333** of The `linux-debug` suite runs **377 cases**; the `linux-coverage` preset builds **360** of
them, because its adapters are off and 17 cases need an adapter target (11 in them, because its adapters are off and 17 cases need an adapter target (11 in
`test_imgui_widget.cpp`, 6 in `test_glfw_host.cpp`). Coverage is enforced by `test_imgui_widget.cpp`, 6 in `test_glfw_host.cpp`). Coverage is enforced by
`tools/coverage.sh` against `tools/coverage_floor.py`: **80%** per file under `src/` and `tools/coverage.sh` against `tools/coverage_floor.py`: **80%** per file under `src/` and
+18
View File
@@ -344,6 +344,21 @@ u32 PlotArea::pointer_row() const noexcept {
return row_in(row_rects_, pointer_.y); return row_in(row_rects_, pointer_.y);
} }
void PlotArea::sync_selection_frames() noexcept {
const auto frame_for = [](input::Controller& c) -> Rect {
return c.drag_mode() == input::DragMode::BoxZoom ? c.selection_preview() : Rect{};
};
if (figure_ != nullptr) {
for (u32 i = 0; i < row_controllers_.size(); ++i) {
// An empty rect on the rows that are not choosing: `Rect` has no equality to compare
// with, and a store of the same value is what keeps this path free of anything else.
figure_->plot(i).set_selection_rect(frame_for(*row_controllers_[i]));
}
return;
}
plot_->set_selection_rect(frame_for(controller_));
}
void PlotArea::sync_figure_crosshair() noexcept { void PlotArea::sync_figure_crosshair() noexcept {
if (row_rects_.empty() || row_controllers_.empty()) return; if (row_rects_.empty() || row_controllers_.empty()) return;
const u32 row_count = static_cast<u32>(row_rects_.size()); const u32 row_count = static_cast<u32>(row_rects_.size());
@@ -534,6 +549,9 @@ void PlotArea::render() noexcept {
// `::resize` ratio, not `gtk_widget_get_scale_factor()` -- the latter is the // `::resize` ratio, not `gtk_widget_get_scale_factor()` -- the latter is the
// ceiling under fractional scaling and would over-scale the chrome by up to 60%. // ceiling under fractional scaling and would over-scale the chrome by up to 60%.
renderer_->style().ui_scale = mapping_.factor; renderer_->style().ui_scale = mapping_.factor;
// After the scale and before the draw: the preview reads the row's layout, and a layout measured
// with the previous frame's scale is not the one being drawn.
sync_selection_frames();
renderer_->begin_frame(static_cast<u32>(viewport.w), static_cast<u32>(viewport.h), viewport); renderer_->begin_frame(static_cast<u32>(viewport.w), static_cast<u32>(viewport.h), viewport);
if (figure_ != nullptr) { if (figure_ != nullptr) {
renderer_->draw(*figure_); renderer_->draw(*figure_);
+11
View File
@@ -397,6 +397,17 @@ class PlotArea {
/// Spreads one row's crosshair over the whole figure (see "Figure mode"). Run once /// Spreads one row's crosshair over the whole figure (see "Figure mode"). Run once
/// per frame, from the render callback, after this frame's row rects are cached. /// per frame, from the render callback, after this frame's row rects are cached.
void sync_figure_crosshair() noexcept; void sync_figure_crosshair() noexcept;
/// Hands each row's plot the box-zoom frame its controller is showing, or takes it off
/// the ones whose gesture is over. Run once per frame from the render callback, like
/// the crosshair's spread and for a related reason: a frame has to go away when a drag
/// ends by *any* route -- the release, Escape, a row rebuilt mid-gesture -- and a
/// per-event hook would have to be repeated at every one of them, which is how a frame
/// ends up stuck on screen after a cancelled zoom.
///
/// Reads `selection_preview()`, which needs the row's layout, only for a controller
/// actually in `BoxZoom`: the cost is paid while a range is being chosen and never
/// otherwise.
void sync_selection_frames() noexcept;
/// `figure_->set_viewport(px)`, one `layout()` into the rect cache, and each row's /// `figure_->set_viewport(px)`, one `layout()` into the rect cache, and each row's
/// own rect to its controller. The figure-mode half of `refresh_viewport()` and of /// own rect to its controller. The figure-mode half of `refresh_viewport()` and of
/// `render()`, which must not drift apart. /// `render()`, which must not drift apart.
+10 -3
View File
@@ -41,11 +41,18 @@ ctest --test-dir build/debug --output-on-failure
ctest registers four tests: `plot_tests` (the test binary), the two header/umbrella build ctest registers four tests: `plot_tests` (the test binary), the two header/umbrella build
checks (`plot_headers_self_contained`, `plot_umbrella_covers_public_headers`) and checks (`plot_headers_self_contained`, `plot_umbrella_covers_public_headers`) and
`plot_umbrella_check`. The `plot_tests` binary itself runs **350 cases** in the `linux-debug` `plot_umbrella_check`. The `plot_tests` binary itself runs **377 cases** in the `linux-debug`
preset; its harness prints one line per case and a final `350 cases, 0 failed` summary line. preset; its harness prints one line per case and a final `377 cases, 0 failed` summary line.
The case counts per file are the `PLOT_TEST(...)` declarations under `tests/` — recount them The case counts per file are the `PLOT_TEST(...)` declarations under `tests/` — recount them
(`grep -c '^PLOT_TEST(' tests/test_*.cpp`) after adding or removing cases. (`grep -c '^PLOT_TEST(' tests/test_*.cpp`) after adding or removing cases.
`PLOT_TEST_FILTER=<substring>` runs only the cases whose name contains that substring, and
is for working *on* a case rather than for checking the library: most of the render cases
create their own EGL context and read pixels back, so the whole binary is far slower than
the one case being edited and its one line of measured output scrolls past 376 others. The
summary line still counts the whole suite, so a filtered run says `377 cases, 0 failed`
having run one — it is never evidence that the suite passes. Unset, every case runs.
## Install layout ## Install layout
`cmake --install` follows `GNUInstallDirs`, so every path honours the standard cache `cmake --install` follows `GNUInstallDirs`, so every path honours the standard cache
@@ -119,7 +126,7 @@ per-file table and the floor verdict live in [docs/coverage.md](coverage.md), wh
`tools/coverage.sh` rewrites from the very run that measured them. Re-run the command for the `tools/coverage.sh` rewrites from the very run that measured them. Re-run the command for the
live numbers. live numbers.
**One thing to know about the coverage preset:** it builds **333 of the 350 cases**. The 17 **One thing to know about the coverage preset:** it builds **360 of the 377 cases**. The 17
that are missing are the adapter cases that need an adapter target to exist — 11 in that are missing are the adapter cases that need an adapter target to exist — 11 in
`test_imgui_widget.cpp` and 6 in `test_glfw_host.cpp` — and the coverage preset turns the `test_imgui_widget.cpp` and 6 in `test_glfw_host.cpp` — and the coverage preset turns the
adapters off. The toolkit-free adapter seams still run there: `test_qt_pointer.cpp` (5 cases) adapters off. The toolkit-free adapter seams still run there: `test_qt_pointer.cpp` (5 cases)
+52 -1
View File
@@ -251,7 +251,18 @@ Five things carry it, and each was arrived at by being wrong first:
geometry; only `TextInstance` carries `a_colour`), so a per-segment alpha is inexpressible and geometry; only `TextInstance` carries `a_colour`), so a per-segment alpha is inexpressible and
the tiers are what make the gradient drawable at all. `intensity_floor` keeps the faintest tier the tiers are what make the gradient drawable at all. `intensity_floor` keeps the faintest tier
visible — a fast riser is one sub-bucket deep everywhere and must not be dimmed out of visible — a fast riser is one sub-bucket deep everywhere and must not be dimmed out of
existence — and `intensity_gamma` lifts the faint end. A connector between two disjoint columns existence — and `intensity_gamma` lifts the faint end. The floor only holds if the riser's rows
**reach** a tier: the "lit at all" cutoff that discards a column's empty rows is
`min(0.01 × peak, 0.5 × w_min)` over the smallest deposit any of that column's own segments
made, and not the first term alone. A column holding a flat level *and* a riser has its peak set
by the level — one unit in one row — while the riser spreads a unit over 300, which is three
orders of magnitude under `0.01 × peak`, so the riser was discarded before the floor could apply
to it. Measured on a 2098-sample square wave at 105 samples a column: **2 916 of the ungraded
path's 35 884 riser rows**, 106 of 116 riser columns gutted entirely, with a 311-row dark run in
a 312-row axis; the ten survivors were the phase that put a riser in a column of its own, which
is why it read as a riser *flickering* under a pan rather than as a missing edge. With the second
term it is 35 572 rows and 115 of 116 columns, for 48 extra segments
(`a_graded_column_keeps_a_riser_it_shares_with_a_flat_level`). A connector between two disjoint columns
is emitted at the **faintest** tier: it is inferred geometry, not a place the signal was is emitted at the **faintest** tier: it is inferred geometry, not a place the signal was
measured to dwell. measured to dwell.
- **Grading may not undo the reduction.** `level_for()` answers 0 — raw samples — as soon as the - **Grading may not undo the reduction.** `level_for()` answers 0 — raw samples — as soon as the
@@ -276,6 +287,46 @@ flat path is on the pyramid is what exercises the floor (`raw=0 lod=1 … seg=63
visible=200001`); the test's first window sits at 105 samples a pixel, where both paths are raw visible=200001`); the test's first window sits at 105 samples a pixel, where both paths are raw
and the floor is never reached. and the floor is never reached.
## A sparse polyline is graded by slope
The column reduction stops at `dense_threshold_spp` (1.5 samples a device pixel), below which the
trace is a polyline. Grading used to stop there with it, because it was staged *inside* the
reduction — and that coupling was a defect, not a design. The reduction's gate is a density, and
its only purpose is removing the moiré beat between the bucket pitch and the pixel pitch. Dwell
has nothing to do with that beat, so zooming past the threshold turned the phosphor model off in
one step: the same square-wave edge measured **71 of 191** at 3.4 samples a pixel and **191 of
191** at 1.0, a 2.7× jump across one pixel of zoom, which reads as a discontinuity *lighting up*
the moment you look at it closely. The thing a reader most needs told apart from a real signal
excursion is a transition, and it was being drawn at full ink exactly when it filled the screen.
`Renderer::stage_trace_slopes()` grades the polyline on the same principle, differing only where
it must:
- **The measure is the segment's own slope**, `1 / (1 + |Δy_screen|)`, which is dwell per row for
a single segment. The `1 +` is not a guard against a division: a flat segment — span 0, the
commonest case in a sparse window — must be the *brightest* tier, and this makes the measure
dimensionless and **absolute**. There is no per-column peak to normalise against down here, and
that is an improvement: a sparse frame's tiers mean the same thing from frame to frame, so
panning does not reshade the trace.
- **No gather mode is excluded.** The reduction is `Envelope`-only, because turning a bucket's
min/max into a band answers a different question from the one a `Nearest` or `Mean` host asked.
A segment's slope is a property of the polyline itself, so `grade` is not reused as the gate —
only `intensity_grading` and `intensity_steps > 1` are.
- **The geometry is bit-for-bit the ungraded polyline's**, staged exactly as `stage_xy()` stages
it, with the same `snap = false`; only the batch a segment lands in differs. A segment with one
non-finite end is dropped rather than clamped, which is the conclusion `add_polyline_xy()`
reaches too.
- **`FrameStats::slope_traces` counts it, and it is disjoint from `column_traces`.** A window is
either dense enough to reduce onto the pixel grid or it is drawn as a polyline, never both — so
a host asserting `graded_traces == column_traces` keeps that reading unchanged.
`a_sparse_trace_is_graded_by_slope_like_a_dense_one` is the guard: one window either side of the
threshold over the same transition at the same place. The level reads **191 either way** (a flat
segment dwells in its row, so it is the top tier and full ink) and the riser reads **71 either
way**, against the 191 the sparse frame gave before. Across the whole sweep from 101 down to 0.1
samples a pixel the riser holds 71 and the level holds 191, so there is no step at the threshold
left to see.
## One sample or a statistic of many ## One sample or a statistic of many
Every number the library reports is one the samples actually hold. What differs between Every number the library reports is one the samples actually hold. What differs between
+10
View File
@@ -192,6 +192,16 @@ without knowing what a pointer is. `hovered_cursor()`, `grabbed_cursor()`, `hove
`grabbed_region()` and `region_grab(u32)` expose the same answers to a host (a resize cursor, a `grabbed_region()` and `region_grab(u32)` expose the same answers to a host (a resize cursor, a
tooltip, a status line). tooltip, a status line).
The frame an open box zoom is drawing is published the same way and for the same reason
(`Plot::set_selection_rect()`), and the plot still learns nothing about pointers: the host reads the
rect off the controller -- `Controller::selection_preview()` -- on every frame of the drag and takes it
off the plot when the gesture ends. The preview is deliberately not what the pointer swept. Shift
frames both axes and its preview is the swept box; Ctrl frames x only, so its preview is widened to the
full height of the axes, and Alt's to the full width. The swept sliver is the *gesture*; the widened
rect is what the *release* will apply, which is the one worth showing while it is still only a
gesture. Nothing is previewed until the drag has swept every axis the release will act on: an empty
rect widened is a band the width of the plot, which would claim a range that does not exist yet.
## Live follow ## Live follow
An x axis has a follow state, `XFollow::Off` (the default) or `XFollow::Live` An x axis has a follow state, `XFollow::Off` (the default) or `XFollow::Live`
+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);
+12
View File
@@ -229,6 +229,18 @@ class Controller {
DragMode drag_mode() const noexcept { return mode_; } DragMode drag_mode() const noexcept { return mode_; }
/// The current box-zoom selection in pixels; meaningful while BoxZoom. /// The current box-zoom selection in pixels; meaningful while BoxZoom.
Rect selection_rect() const noexcept { return selection_; } Rect selection_rect() const noexcept { return selection_; }
/// The rectangle a host should *draw* for the open box zoom: the swept area, widened to the full
/// axes along whichever axis the modifier's mask will not zoom. Ctrl frames the x range only, so its
/// preview is the band that spans the plot -- which is what the release will zoom -- where the area
/// a purely horizontal drag sweeps is a sliver a few pixels tall. Shift (and any other combo) frames
/// both, and gets the swept box itself.
///
/// Meaningful while `BoxZoom`, like `selection_rect()`; the zero rect otherwise.
///
/// Defined in the implementation: widening the preview to the axes needs the plot's layout, and
/// `Plot` is only forward-declared here.
Rect selection_preview() const noexcept;
/// The cursor under the pointer -- a vertical marker or a horizontal ruler, whichever /// The cursor under the pointer -- a vertical marker or a horizontal ruler, whichever
/// line the pointer is nearer -- or `kInvalid` when the pointer is outside the plot area /// line the pointer is nearer -- or `kInvalid` when the pointer is outside the plot area
/// or no cursor is within a few pixels. /// or no cursor is within a few pixels.
+83
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
@@ -112,6 +144,13 @@ struct PlotLayout {
/// clamped against the rect it was measured for, so nothing of the readout can be drawn /// clamped against the rect it was measured for, so nothing of the readout can be drawn
/// over a label strip or into a figure's neighbouring row. /// over a label strip or into a figure's neighbouring row.
CrosshairLayout crosshair; CrosshairLayout crosshair;
/// The box-zoom frame, when a modified drag has one open: the area the gesture is framing, in
/// viewport pixels, already widened along whichever axis the modifier will *not* zoom (see
/// `input::Controller::selection_preview()`, which is where that is decided). Here rather than
/// derived by the renderer, for the regions' reason -- the frame draws exactly these numbers -- and
/// left as the zero rect whenever no gesture is open, which is what both the fill and the four
/// border segments read as "draw nothing".
Rect selection;
}; };
/// What a pointer is on, as of the last pointer event: the cursor marker under it, the /// What a pointer is on, as of the last pointer event: the cursor marker under it, the
@@ -525,6 +564,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
@@ -702,6 +760,21 @@ class Plot {
/// calls this when the pointer leaves the axes. /// calls this when the pointer leaves the axes.
void clear_crosshair() noexcept { crosshair_.active = false; } void clear_crosshair() noexcept { crosshair_.active = false; }
/// The box-zoom frame the host is showing, in **viewport pixels**, or the zero rect for none.
///
/// Set by the host from `input::Controller::selection_preview()` while a modified left drag is
/// choosing a range, and taken off when it ends. It is the *host's* job and not the plot's because
/// the plot has no controller and learns nothing about pointer, button or modifier (see
/// `plot::input`): this is the receiving end of that boundary, exactly as `set_hot_handles()` is.
/// Drawn as a frame -- a slightly opaque fill under a light border -- over everything else inside
/// the axes, so the area being framed stays legible against the traces it covers.
void set_selection_rect(Rect rect) noexcept { selection_ = rect; }
/// Takes the frame off, leaving the same state as before any drag: the next one starts from nothing.
void clear_selection_rect() noexcept { selection_ = Rect{}; }
/// The frame as set -- what `layout()` measures into `PlotLayout::selection`, and what a host or a
/// test reads back without paying a layout.
Rect selection_rect() const noexcept { return selection_; }
/// Switches the readout panel off -- or back on -- without moving the crosshair: the lines and /// Switches the readout panel off -- or back on -- without moving the crosshair: the lines and
/// the per-trace markers stay, the panel is not measured and no row is listed. A host that wants /// the per-trace markers stay, the panel is not measured and no row is listed. A host that wants
/// the pointer's column marked but not a panel over its data sets this once; it is deliberately /// the pointer's column marked but not a panel over its data sets this once; it is deliberately
@@ -852,6 +925,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 +976,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
@@ -913,6 +991,11 @@ class Plot {
/// The crosshair's state (see Crosshair): at most one per plot, placed by /// The crosshair's state (see Crosshair): at most one per plot, placed by
/// `set_crosshair()` and read back by `crosshair()` and `layout()`. /// `set_crosshair()` and read back by `crosshair()` and `layout()`.
Crosshair crosshair_{}; Crosshair crosshair_{};
/// The host's box-zoom frame (`set_selection_rect()`): viewport pixels, the zero rect when no
/// modified drag is choosing a range. State rather than a `PlotLayout` field alone because a host
/// sets it on every drag motion and reads it back; `layout()` only carries it through.
Rect selection_{};
/// The samples `crosshair_value()` reads a span through: `Series::gather()` appends /// The samples `crosshair_value()` reads a span through: `Series::gather()` appends
/// `(x, y)` pairs and never clears, so the lookup clears this and refills it with the at /// `(x, y)` pairs and never clears, so the lookup clears this and refills it with the at
/// most two samples a span covers. Sized to hold them from construction -- four floats is /// most two samples a span covers. Sized to hold them from construction -- four floats is
+42
View File
@@ -38,6 +38,12 @@ struct FrameStats {
/// (`Style::intensity_grading`). A subset of `column_traces`: grading is a way of /// (`Style::intensity_grading`). A subset of `column_traces`: grading is a way of
/// drawing the reduction, not a third path. /// drawing the reduction, not a third path.
u32 graded_traces = 0; u32 graded_traces = 0;
/// Traces drawn as a polyline whose *segments* were graded by slope -- the sparse
/// half of the same phosphor model (`Renderer::stage_trace_slopes`). **Disjoint from
/// `column_traces`**, and therefore from `graded_traces`: a window is either dense
/// enough to reduce onto the pixel grid or it is drawn as a polyline, never both. A
/// host asserting `graded_traces == column_traces` keeps that reading unchanged.
u32 slope_traces = 0;
u64 visible_samples = 0; ///< samples the gathered windows covered, summed u64 visible_samples = 0; ///< samples the gathered windows covered, summed
u32 staged_segments = 0; ///< line segments staged, all colour groups u32 staged_segments = 0; ///< line segments staged, all colour groups
u32 staged_markers = 0; ///< marker shapes staged over the traces u32 staged_markers = 0; ///< marker shapes staged over the traces
@@ -507,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
@@ -539,6 +552,30 @@ class Renderer {
/// from its own min and max (see `Style::intensity_grading`). /// from its own min and max (see `Style::intensity_grading`).
u32 stage_trace_density(const Plot& plot, const PlotLayout& layout, const Trace& trace, u32 stage_trace_density(const Plot& plot, const PlotLayout& layout, const Trace& trace,
f64 x_origin, u32 points); f64 x_origin, u32 points);
/// The **sparse** window's polyline, graded by the same measure: each segment is
/// staged into the step its own *slope* earns, so a near-vertical segment between two
/// adjacent samples -- a discontinuity, which the signal crossed without dwelling
/// anywhere in it -- is faint and a near-flat one is bright.
///
/// It exists because grading used to be gated on the column reduction, and that
/// reduction is gated on a *density* (two gathered points per pixel column) whose only
/// purpose is removing the moire beat. Dwell has nothing to do with that density, so
/// zooming in past it switched the whole phosphor model off and drew an edge as
/// brightly as a flat top -- the edge got brighter the further in you zoomed.
///
/// The measure agrees with `stage_trace_density()`'s at the boundary but is **absolute
/// rather than per column**: below two points a column there is no column peak to
/// normalise against, so a segment's dwell is `1 / (1 + rows it spans)` outright. A
/// flat segment earns the top step and one crossing the whole axis earns
/// `Style::intensity_floor`, which is what a riser's column already gets on the dense
/// side.
///
/// Geometry is staged exactly as `stage_xy()` stages it -- in the pre-affine space,
/// relative to `x_origin` -- so only *which batch* a segment lands in differs from the
/// ungraded polyline, and the caller flushes with the trace's own `Mapping` and
/// `snap = false`. Returns the segments staged across all steps (0 stages nothing).
u32 stage_trace_slopes(const Plot& plot, const Trace& trace, const f32* xy, u32 points,
f64 x_origin, bool log_x, bool log_y);
/// The cursors, both orientations: one stroke per cursor, placed at the coordinate its own /// The cursors, both orientations: one stroke per cursor, placed at the coordinate its own
/// orientation owns (`Plot::cursor_screen_coordinate()`) and spanning the other axis' whole /// orientation owns (`Plot::cursor_screen_coordinate()`) and spanning the other axis' whole
/// range, in its own colour -- or in the accent at handle width while it is hovered or /// range, in its own colour -- or in the accent at handle width while it is hovered or
@@ -617,6 +654,11 @@ class Renderer {
/// pointer is on: a readout the legend covered would be the feature failing at its one job. /// pointer is on: a readout the legend covered would be the feature failing at its one job.
void draw_crosshair_readout(const Plot& plot, const PlotLayout& layout); void draw_crosshair_readout(const Plot& plot, const PlotLayout& layout);
/// The host's box-zoom frame (`PlotLayout::selection`): a fill at `selection_fill` under a border at
/// `selection_edge`, or nothing at all when nothing was swept. Drawn last of the interior passes, so
/// the area being framed is legible over the traces, the regions and the readout alike.
void draw_selection_frame(const PlotLayout& layout);
/// One marker shape (`Marker`) at a viewport-pixel centre: `Cross` two /// One marker shape (`Marker`) at a viewport-pixel centre: `Cross` two
/// segments, `Circle` an 8-gon, `Square` four. Staged into the line batch in /// segments, `Circle` an 8-gon, `Square` four. Staged into the line batch in
/// pixel space, at `size_px` half-extent, so a marker's size is device pixels /// pixel space, at `size_px` half-extent, so a marker's size is device pixels
+30
View File
@@ -55,6 +55,28 @@ 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 box-zoom selection's frame, drawn while a modified left drag is choosing a range: a light
/// neutral near full alpha for the border and the same neutral at a low one for the fill. Neutral
/// and not a trace colour, on `invalid_fill`'s argument -- the frame says "this is the area", and a
/// tint a trace could have would read as data. The fill's alpha is chosen against the same floor:
/// low enough that what is under the frame stays readable, because the point is to show what is
/// being selected, not to hide it.
Color selection_fill = Color::hex(0xC8D0DC, 0.12f);
Color selection_edge = Color::hex(0xC8D0DC, 0.85f);
/// 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*.
@@ -202,6 +224,14 @@ struct Style {
/// bright mark. Counting a row at +1 per segment that touches it instead -- the /// bright mark. Counting a row at +1 per segment that touches it instead -- the
/// obvious thing -- grades a sine's fast middle as brightly as its rails, which /// obvious thing -- grades a sine's fast middle as brightly as its rails, which
/// is the pattern inverted. /// is the pattern inverted.
///
/// Below `dense_threshold_spp`, where there are no columns to reduce, this grades
/// the **polyline** by each segment's own slope instead (`FrameStats::slope_traces`
/// against `graded_traces`) -- the same measure for a sparse window, where a
/// segment *is* the sub-column. One switch covers both because the alternative was
/// a defect: gated on the reduction, the model switched off as a window narrowed
/// past the threshold, and an edge drawn at a third of a flat top's ink jumped to
/// all of it in one pixel of zoom.
bool intensity_grading = false; bool intensity_grading = false;
/// Sub-buckets per pixel column the gather is asked for when grading: the level /// Sub-buckets per pixel column the gather is asked for when grading: the level
+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_) {
+25
View File
@@ -751,6 +751,31 @@ void Controller::pan(const PointerEvent& e) {
} }
} }
Rect Controller::selection_preview() const noexcept {
// Nothing to preview off a box zoom: the mask bits outlive the gesture -- `end_drag()` clears the
// selection, not them -- so widening without this guard would hand out a full-band rect for a drag
// that finished minutes ago.
if (mode_ != DragMode::BoxZoom) return Rect{};
// And nothing has been framed until the drag has swept every axis the release will apply: at the
// press instant the selection is an empty rect at the pointer, and the *widened* form of that is a
// band the width of the plot, which is a claim about a range that does not exist yet. Ctrl's other
// axis is deliberately not required: a drag with no vertical movement at all is a band across the
// plot, which is exactly what Ctrl is for.
if ((box_zoom_x_ && !(selection_.w > 0.0f)) || (box_zoom_y_ && !(selection_.h > 0.0f))) return Rect{};
Rect preview = selection_;
if (box_zoom_x_ && box_zoom_y_) return preview;
const Rect& axes = plot_->layout().axes;
if (!box_zoom_x_) {
preview.x = axes.x;
preview.w = axes.w;
}
if (!box_zoom_y_) {
preview.y = axes.y;
preview.h = axes.h;
}
return preview;
}
void Controller::box_zoom_update(const PointerEvent& e) { void Controller::box_zoom_update(const PointerEvent& e) {
const f32 x = std::min(origin_x_, e.x); const f32 x = std::min(origin_x_, e.x);
const f32 y = std::min(origin_y_, e.y); const f32 y = std::min(origin_y_, e.y);
+44
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,11 +1095,16 @@ 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
// settled layout pins, exactly as the legend and the regions do. // settled layout pins, exactly as the legend and the regions do.
build_crosshair(); build_crosshair();
// The box-zoom frame is not measured, only carried: the host already has it in viewport pixels from
// the controller that produced it, and re-deriving it here would be a second rounding of the same
// rectangle -- and a second place for it to disagree with what the gesture is doing.
layout_.selection = selection_;
return layout_; return layout_;
} }
@@ -1358,6 +1367,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 {
+137 -4
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);
@@ -542,6 +545,9 @@ void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) {
// panel is drawn over the legend and every other overlay rather than under them, and its // panel is drawn over the legend and every other overlay rather than under them, and its
// text joins the frame's over flush with the tick labels and titles. // text joins the frame's over flush with the tick labels and titles.
draw_crosshair_readout(plot, layout); draw_crosshair_readout(plot, layout);
// The selection frame last of all: it is the gesture still in progress, and nothing inside the axes
// should be able to sit on top of the area being chosen.
draw_selection_frame(layout);
clear_clip(); clear_clip();
draw_tick_labels(plot, layout); draw_tick_labels(plot, layout);
draw_titles(plot, layout); draw_titles(plot, layout);
@@ -1096,6 +1102,9 @@ u32 Renderer::stage_trace_density(const Plot& plot, const PlotLayout& layout, co
f32 prev_lo = 0.0f; f32 prev_lo = 0.0f;
f32 prev_hi = 0.0f; f32 prev_hi = 0.0f;
bool joined = false; bool joined = false;
// The smallest deposit any segment made into the column being accumulated, which is
// what bounds the "lit at all" test below. Reset per column by `emit_column()`.
f32 w_min = std::numeric_limits<f32>::infinity();
// Reads one column's accumulator out as graded runs and clears it, so the next column // Reads one column's accumulator out as graded runs and clears it, so the next column
// starts empty. Two walks of it, which is nothing -- it is one column wide and in L1 -- // starts empty. Two walks of it, which is nothing -- it is one column wide and in L1 --
@@ -1124,10 +1133,25 @@ u32 Renderer::stage_trace_density(const Plot& plot, const PlotLayout& layout, co
} }
} }
const f32 inv = peak > 0.0f ? 1.0f / peak : 0.0f; const f32 inv = peak > 0.0f ? 1.0f / peak : 0.0f;
// What counts as covered at all. The deposits are fractions, so this cannot be an // What counts as covered at all: **a row one segment covered is lit**. The deposits
// integer test: it is a share of the column's own peak, small enough to keep a row a // are fractions, so this cannot be an integer test, and it cannot be a share of the
// single thin segment grazed and large enough not to light a row on rounding alone. // column's peak alone either -- that was this function's one real defect. A square
const f32 lit_floor = 0.01f * peak; // wave's riser is a single segment spread over the whole axis, so it deposits
// `1/rows` per row, while a flat level in the same column is one deposit of 1.0 per
// sub-bucket piled on one row: at 105 samples a column the peak is ~50 and the share
// floor is ~0.5, eighty times the riser's own 0.006. Every row of the edge was
// unlit, and the connector could not bridge it either -- the two levels are both lit,
// so the column's band already spans the gap and neither gap test fires. Measured on
// a true square at 104.8 spp over a 312-row axis: 106 of 116 riser columns drawn as
// two disconnected marks with up to 311 dark rows between them, the other 10 kept by
// the phase that put the riser in a column of its own -- which is why it read as an
// edge *flickering* rather than as one missing. `intensity_floor` guards a lit row's
// alpha and was documented as preventing exactly this; it never got the chance.
//
// So the share term is kept only for what it is good for -- suppressing residue from
// the running-sum reconstruction -- and is capped by half the smallest deposit the
// column actually received, which no real segment's contribution can fall under.
const f32 lit_floor = std::min(0.01f * peak, 0.5f * w_min);
f32 cover = 0.0f; f32 cover = 0.0f;
bool in_run = false; bool in_run = false;
u32 run_tier = 0; u32 run_tier = 0;
@@ -1175,6 +1199,7 @@ u32 Renderer::stage_trace_density(const Plot& plot, const PlotLayout& layout, co
} }
} }
open = false; open = false;
w_min = std::numeric_limits<f32>::infinity();
if (!any) { if (!any) {
joined = false; // nothing visible here: a hole, like an uncovered column joined = false; // nothing visible here: a hole, like an uncovered column
return; return;
@@ -1263,6 +1288,7 @@ u32 Renderer::stage_trace_density(const Plot& plot, const PlotLayout& layout, co
// Two writes whatever the height: the running sum over the column is then the // Two writes whatever the height: the running sum over the column is then the
// dwell at each row. // dwell at each row.
const f32 w = 1.0f / static_cast<f32>(r1 - r0 + 1u); const f32 w = 1.0f / static_cast<f32>(r1 - r0 + 1u);
if (w < w_min) w_min = w;
column_rows_[r0] += w; column_rows_[r0] += w;
column_rows_[r1 + 1u] -= w; column_rows_[r1 + 1u] -= w;
} }
@@ -1275,6 +1301,61 @@ u32 Renderer::stage_trace_density(const Plot& plot, const PlotLayout& layout, co
return staged; return staged;
} }
u32 Renderer::stage_trace_slopes(const Plot& plot, const Trace& trace, const f32* xy, u32 points,
f64 x_origin, bool log_x, bool log_y) {
if (points < 2u) return 0u;
// At least two, for the reason `stage_trace_density()` gives: a one-step grading is
// the whole trace drawn dimmer, which is not what a host asking for this wants.
const u32 steps = std::max(2u, style_.intensity_steps);
steps_.resize(steps);
for (u32 t = 0; t < steps; ++t) steps_[t].clear();
const Transform yt = plot.y_transform(trace.axis_y);
const f32 gamma = style_.intensity_gamma > 0.0f ? style_.intensity_gamma : 1.0f;
const f32 fsteps = static_cast<f32>(steps);
u32 staged = 0;
for (u32 i = 0; i + 1u < points; ++i) {
const f64 y0 = static_cast<f64>(xy[2u * i + 1u]);
const f64 y1 = static_cast<f64>(xy[2u * i + 3u]);
const f64 s0 = yt.to_screen_y(y0);
const f64 s1 = yt.to_screen_y(y1);
// A NaN sample, and a non-positive one on a log axis, have no position, and the
// *segment* is what is being staged -- so one unplaceable end drops it entirely
// rather than being clamped to somewhere the series says nothing about. The
// ungraded polyline reaches the same conclusion through `add_polyline_xy()`, which
// a non-finite vertex excludes from both of its segments.
if (!std::isfinite(s0) || !std::isfinite(s1)) continue;
// Dwell, the same quantity `stage_trace_density()` deposits and for the same
// reason: a segment is a stretch of the waveform, and the rows it spans are the
// rows the signal crossed *on its way* rather than sat in. `1 + span` so a flat
// segment -- span 0, the commonest case in a sparse window -- is 1 and not a
// division by zero, which also makes the measure dimensionless and absolute: no
// per-column peak exists to normalise against down here.
const f32 span = static_cast<f32>(std::fabs(s1 - s0));
const f32 dwell = 1.0f / (1.0f + span);
const u32 t = std::min(steps - 1u, static_cast<u32>(std::pow(dwell, gamma) * fsteps));
// Staged exactly as `stage_xy()` does, so the geometry is bit-for-bit the ungraded
// polyline's and only the batch differs. The linear case needs no mapping at all --
// the gather already emitted x relative to this origin.
if (!log_x && !log_y) {
steps_[t].add_segment(Vec2{xy[2u * i], xy[2u * i + 1u]},
Vec2{xy[2u * i + 2u], xy[2u * i + 3u]});
} else {
const f64 ox = space(x_origin, log_x);
steps_[t].add_segment(
Vec2{static_cast<f32>(space(static_cast<f64>(xy[2u * i]) + x_origin, log_x) - ox),
static_cast<f32>(space(y0, log_y))},
Vec2{static_cast<f32>(space(static_cast<f64>(xy[2u * i + 2u]) + x_origin, log_x) - ox),
static_cast<f32>(space(y1, log_y))});
}
++staged;
}
return staged;
}
void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) { void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
// Both dimensions: a zero-width rect selects no level, and a zero-height one // Both dimensions: a zero-width rect selects no level, and a zero-height one
// would flatten every trace onto a single row -- a frame that looks like a // would flatten every trace onto a single row -- a frame that looks like a
@@ -1497,11 +1578,36 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
} else if (staged != 0u) { } else if (staged != 0u) {
flush_lines(pixel_mapping(), trace.color, style_.device(trace.width), dash, true); flush_lines(pixel_mapping(), trace.color, style_.device(trace.width), dash, true);
++stats_.column_traces; ++stats_.column_traces;
} else {
// The sparse half of the phosphor model. `grade` is not reused as the gate: it
// requires `Envelope`, because the *column reduction* turns a bucket's min/max
// into a band and that answers a different question from the one a `Nearest` or
// `Mean` host asked. A segment's slope is a property of the polyline itself, so
// no gather mode is excluded here.
const u32 sparse =
style_.intensity_grading && style_.intensity_steps > 1u
? stage_trace_slopes(plot, trace, gather_.data(), points, x_range.min, log_x,
log_y)
: 0u;
if (sparse != 0u) {
// Faintest first, and `snap = false` -- the same order as the graded columns
// for the same compositing reason, but these segments sit at *data* positions
// and the snap uniform's rule applies to them in full.
for (u32 t = 0; t < steps_.size(); ++t) {
if (steps_[t].segment_count() == 0u) continue;
Color color = trace.color;
color.a *= style_.intensity_floor +
(1.0f - style_.intensity_floor) * static_cast<f32>(t) /
static_cast<f32>(steps_.size() - 1u);
flush_lines(steps_[t], map, color, style_.device(trace.width), dash, false);
}
++stats_.slope_traces;
} else { } else {
stage_xy(gather_.data(), points, x_range.min, log_x, log_y); stage_xy(gather_.data(), points, x_range.min, log_x, log_y);
flush_lines(map, trace.color, style_.device(trace.width), dash); flush_lines(map, trace.color, style_.device(trace.width), dash);
} }
} }
}
// Markers, staged as line segments through this same batch in *pixel* space // Markers, staged as line segments through this same batch in *pixel* space
// (so their size is device pixels whatever the data scale is). The stride is // (so their size is device pixels whatever the data scale is). The stride is
@@ -1628,6 +1734,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);
@@ -1851,6 +1963,27 @@ void Renderer::stage_marker(plot::Marker marker, Vec2 centre_px, f32 size_px) {
} }
} }
void Renderer::draw_selection_frame(const PlotLayout& layout) {
const Rect& frame = layout.selection;
// Two guards, not one: a drag swept flat in one direction has a real extent on one axis and none on
// the other, and a border on that is still the gesture's own answer -- the fill is what a zero
// extent has nothing of.
if (!(frame.w > 0.0f) && !(frame.h > 0.0f)) return;
if (frame.w > 0.0f && frame.h > 0.0f) fill_pixels(frame, style_.selection_fill);
// The border frames the pixels the fill covers and not the measured rect's own fractional edges:
// the readout panel's rounding and the readout panel's reason, since a hairline straddling a
// fractional edge is a frame bright on two sides and dim on the others.
const f32 left = std::ceil(frame.x - 0.5f) + 0.5f;
const f32 right = std::ceil(frame.right() - 0.5f) - 0.5f;
const f32 top = std::ceil(frame.y - 0.5f) + 0.5f;
const f32 bottom = std::ceil(frame.bottom() - 0.5f) - 0.5f;
lines_.add_segment({left, top}, {right, top});
lines_.add_segment({left, bottom}, {right, bottom});
lines_.add_segment({left, top}, {left, bottom});
lines_.add_segment({right, top}, {right, bottom});
flush_lines(pixel_mapping(), style_.selection_edge, style_.device(1.0f));
}
void Renderer::draw_readout_panel(const Rect& panel, Color border) { void Renderer::draw_readout_panel(const Rect& panel, Color border) {
if (!(panel.w > 0.0f) || !(panel.h > 0.0f)) return; if (!(panel.w > 0.0f) || !(panel.h > 0.0f)) return;
// The frame's own background at a partial alpha, which dims whatever it covers instead of erasing // The frame's own background at a partial alpha, which dims whatever it covers instead of erasing
+3
View File
@@ -3,6 +3,7 @@
#pragma once #pragma once
#include <cstdio> #include <cstdio>
#include <cstdlib>
#include <cmath> #include <cmath>
#include <chrono> #include <chrono>
#include <algorithm> #include <algorithm>
@@ -57,7 +58,9 @@ inline void check_near(double a, double b, const char* file, int line, const cha
inline int run_all() { inline int run_all() {
int failed = 0; int failed = 0;
const char* only = std::getenv("PLOT_TEST_FILTER");
for (auto& c : cases()) { for (auto& c : cases()) {
if (only != nullptr && std::string(c.name).find(only) == std::string::npos) continue;
try { try {
c.fn(); c.fn();
std::printf("[ ok ] %s\n", c.name); std::printf("[ ok ] %s\n", c.name);
+51
View File
@@ -2403,3 +2403,54 @@ PLOT_TEST(an_unselectable_cursor_is_neither_hovered_nor_grabbed) {
PLOT_CHECK_NEAR(p.cursor(readout).x, before, 1e-12); PLOT_CHECK_NEAR(p.cursor(readout).x, before, 1e-12);
PLOT_CHECK(c.grabbed_cursor() == normal); PLOT_CHECK(c.grabbed_cursor() == normal);
} }
PLOT_TEST(a_ctrl_box_zoom_previews_the_band_it_will_zoom) {
// What a host draws for an open box zoom is not always the area the pointer swept. Ctrl frames the
// x range only, so what its release will change is a *band* across the plot -- and a pointer dragged
// straight across sweeps a sliver a couple of pixels tall, which would be the frame showing the
// gesture instead of its effect. Shift frames both axes and gets the swept box itself; Alt is the
// other single-axis mask, and gets the other frame.
plot::Plot p;
p.set_viewport({0, 0, 800, 400});
p.add_x_axis({"t", {0.0, 800.0}, true, plot::AxisPlace::Bottom, false});
const plot::Rect axes = p.layout().axes;
constexpr plot::u32 kAlt = static_cast<plot::u32>(plot::input::Mod::Alt);
plot::input::Controller c(p);
c.set_viewport({0, 0, 800, 400});
c.on_pointer({100, 200, 0, 0, plot::input::Button::Left, kCtrl, true, true, false});
// Nothing framed until something was swept: the press alone is an empty rect at the pointer, and a
// full-width band derived from it would claim a range that does not exist yet.
PLOT_CHECK_NEAR(c.selection_preview().w, 0.0, 1e-6);
c.on_pointer({300, 202, 200, 2, plot::input::Button::Left, kCtrl, true, false, false});
const plot::Rect ctrl = c.selection_preview();
PLOT_CHECK_NEAR(ctrl.x, 100.0, 1e-6);
PLOT_CHECK_NEAR(ctrl.w, 200.0, 1e-6);
PLOT_CHECK_NEAR(ctrl.y, axes.y, 1e-6);
PLOT_CHECK_NEAR(ctrl.h, axes.h, 1e-6);
// The gesture's own geometry is untouched: the widening happens in the preview, so the range the
// release computes is still the one the pointer described.
PLOT_CHECK_NEAR(c.selection_rect().h, 2.0, 1e-6);
c.on_pointer({300, 202, 0, 0, plot::input::Button::Left, kCtrl, false, false, true});
c.on_pointer({100, 50, 0, 0, plot::input::Button::Left, kShift, true, true, false});
c.on_pointer({400, 150, 300, 100, plot::input::Button::Left, kShift, true, false, false});
const plot::Rect both = c.selection_preview();
PLOT_CHECK_NEAR(both.x, 100.0, 1e-6);
PLOT_CHECK_NEAR(both.y, 50.0, 1e-6);
PLOT_CHECK_NEAR(both.w, 300.0, 1e-6);
PLOT_CHECK_NEAR(both.h, 100.0, 1e-6);
c.on_pointer({400, 150, 0, 0, plot::input::Button::Left, kShift, false, false, true});
c.on_pointer({100, 50, 0, 0, plot::input::Button::Left, kAlt, true, true, false});
c.on_pointer({110, 150, 10, 100, plot::input::Button::Left, kAlt, true, false, false});
const plot::Rect alt = c.selection_preview();
PLOT_CHECK_NEAR(alt.x, axes.x, 1e-6);
PLOT_CHECK_NEAR(alt.w, axes.w, 1e-6);
PLOT_CHECK_NEAR(alt.y, 50.0, 1e-6);
PLOT_CHECK_NEAR(alt.h, 100.0, 1e-6);
c.on_pointer({110, 150, 0, 0, plot::input::Button::Left, kAlt, false, false, true});
// Closed: a frame on a finished gesture would claim the old selection is still open.
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None);
PLOT_CHECK_NEAR(c.selection_preview().w, 0.0, 1e-6);
}
+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);
}
+19
View File
@@ -2789,3 +2789,22 @@ PLOT_TEST(an_annotation_can_be_moved_by_id) {
p.set_annotation(42u, other); p.set_annotation(42u, other);
PLOT_CHECK_NEAR(p.annotation(id).x, 3.5, 1e-12); PLOT_CHECK_NEAR(p.annotation(id).x, 3.5, 1e-12);
} }
PLOT_TEST(the_selection_frame_is_carried_and_taken_off) {
// The box-zoom frame is the host's to set -- the plot has no controller and learns nothing about
// pointers -- so what it owes is to carry it: out through `layout()` unchanged, because it is
// already in viewport pixels and a second derivation would be a second rounding of the same
// rectangle, and gone when the host takes it off, because a frame left behind claims the selection
// is still open.
plot::Plot p;
p.set_viewport({0, 0, 800, 400});
PLOT_CHECK(p.layout().selection.w == 0.0f && p.layout().selection.h == 0.0f); // nothing swept
p.set_selection_rect({10.0f, 20.0f, 30.0f, 40.0f});
PLOT_CHECK_NEAR(p.selection_rect().x, 10.0, 1e-6);
PLOT_CHECK_NEAR(p.layout().selection.y, 20.0, 1e-6);
PLOT_CHECK_NEAR(p.layout().selection.w, 30.0, 1e-6);
PLOT_CHECK_NEAR(p.layout().selection.h, 40.0, 1e-6);
p.clear_selection_rect();
PLOT_CHECK_NEAR(p.selection_rect().w, 0.0, 1e-6);
PLOT_CHECK_NEAR(p.layout().selection.h, 0.0, 1e-6);
}
+534
View File
@@ -6985,3 +6985,537 @@ PLOT_TEST(figure_rows_draw_their_own_horizontal_rulers) {
} }
(void)layout; (void)layout;
} }
namespace {
/// A true square wave: `period` samples a cycle, half at +1 and half at -1, so every
/// transition is a single-sample discontinuity. That is the waveform the phosphor model
/// is hardest on -- a riser has no intermediate samples to deposit ink in, so whatever
/// the grading does to it is the whole of what a reader sees.
std::vector<plot::f32> square_samples(plot::u64 period) {
std::vector<plot::f32> y(1'000'000);
for (plot::u64 i = 0; i < y.size(); ++i) {
y[i] = (i % period) < period / 2u ? 1.0f : -1.0f;
}
return y;
}
} // namespace
/// A column the signal only *passed* through must still be a column it reached:
/// `intensity_floor` exists so that "a riser dimmed out of existence is a square wave
/// drawn as two disconnected lines", and the per-column grading used to break that
/// promise on exactly the waveform it names.
///
/// The cause was the "lit at all" cutoff inside the column reduction. Grading normalises
/// a column against its own peak and then discards sub-buckets under `0.01 * peak`. A
/// column holding both a flat level and a riser has a peak set by the level -- the level
/// deposits its whole unit into one row -- while the riser spreads a unit over 300 rows,
/// so every riser row lands three orders of magnitude under the cutoff and is dropped
/// before the floor can be applied to it. The floor never sees the riser; there is no
/// tier 0 to fall back to.
///
/// Measured over this frame's 116 riser columns: the graded path inked 2 916 of the
/// ungraded path's 35 884 rows (8%), gutting 106 of the 116 and leaving a 311-row dark
/// run in a 312-row axis. The ten survivors are the phase that puts a riser in a column
/// of its own, which is what made the defect read as a *flicker* under panning rather
/// than as a missing edge. The cutoff is now `min(0.01 * peak, 0.5 * w_min)`, so it can
/// never exclude a deposit some segment actually made, and the reading is 35 572 rows
/// (99.1%) with 115 of 116 columns intact.
PLOT_TEST(a_graded_column_keeps_a_riser_it_shares_with_a_flat_level) {
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));
// 2098 samples a cycle over 104.8 samples a column is about 20 columns a cycle, so a
// riser's column also holds a long stretch of the level it leaves -- the sharing this
// test is about -- rather than being a column of pure transition.
plot::data::ArraySeries s(square_samples(2098), 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", {0.0, 60'000.0}, false, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
// Columns whose band spans more than half the axis: on a square wave those are the
// transitions and nothing else, since a level's own band is a ribbon a few rows deep.
// "Tall" is the property at stake -- a riser drawn as a stub is as wrong as one not
// drawn at all -- so it is both how the columns are chosen and what is asserted of
// them, read from the ungraded frame and then re-read from the graded one.
const auto tall_columns = [&](const plot::Rect& a) {
const plot::u32 rows = static_cast<plot::u32>(a.h);
std::vector<plot::u32> out;
for (plot::u32 col = 0; col < static_cast<plot::u32>(a.w); ++col) {
plot::u32 lo = rows;
plot::u32 hi = 0;
for (plot::u32 row = 0; row < rows; ++row) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(a.x) + col,
ctx.height - 1u - (static_cast<plot::u32>(a.y) + row), px));
// 20 of the 191 a full stroke inks, which is under the faintest tier
// (`intensity_floor` 0.3) -- so a row this rejects is a row no tier drew.
if (static_cast<int>(px[2]) - static_cast<int>(px[0]) > 20) {
if (row < lo) lo = row;
hi = row;
}
}
if (lo < hi && hi - lo > rows / 2u) out.push_back(col);
}
return out;
};
// The axes rect is read *after* a draw: `Plot::layout()` sizes its label strips from
// the tick text at the style's font, so before the first frame it answers with the
// unmeasured estimate and the window would be off by 11 columns in x.
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(p);
r.end_frame();
dump_if_requested(r, "riser-ungraded.ppm");
const plot::Rect a = p.layout().axes;
const std::vector<plot::u32> flat = tall_columns(a);
PLOT_CHECK(r.stats().column_traces == 1 && r.stats().graded_traces == 0);
r.style().intensity_grading = true;
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(p);
r.end_frame();
dump_if_requested(r, "riser-graded.ppm");
const std::vector<plot::u32> lit = tall_columns(a);
PLOT_CHECK(r.stats().column_traces == 1 && r.stats().graded_traces == 1);
PLOT_CHECK(!r.last_frame_had_gl_error());
// How many of the ungraded frame's own riser columns survived the grading, which is
// the reading the ratio below is taken from. Both lists are ascending, so this is one
// pass.
plot::u32 kept = 0;
for (plot::usize i = 0, j = 0; i < flat.size() && j < lit.size();) {
if (flat[i] == lit[j]) {
++kept;
++i;
++j;
} else if (flat[i] < lit[j]) {
++i;
} else {
++j;
}
}
std::printf("[riser] axes=[%.1f %.1f %.1f %.1f] flat=%zu graded=%zu kept=%u\n", a.x, a.y, a.w,
a.h, flat.size(), lit.size(), kept);
// Enough columns to make the ratio mean something; 116 were measured.
PLOT_CHECK(flat.size() > 50u);
// And the grading kept all but one of them. The one is real and is not the defect: the
// ungraded path inks *two* full-height columns per transition -- a column's own min/max,
// the connectivity extension to its neighbour, and the gathered window's slack sample
// all land in x -- where the reduction inks one, which is also why 116 columns appear
// for 58 transitions. Before the fix this read 10 of 116.
PLOT_CHECK(kept * 100u >= static_cast<plot::u32>(flat.size()) * 95u);
}
/// Dwell dimming must not switch off when the window gets narrow. It used to, because
/// the grading was gated on the *column reduction*, and that reduction is gated on two
/// gathered points per pixel column -- a density whose only purpose is removing the
/// moire beat. Zooming past `dense_threshold_spp` therefore turned the whole phosphor
/// model off in one step, and a discontinuity that had been drawn at a third of the
/// level's ink was suddenly drawn at all of it: measured 71 of 191 at 3.4 samples a
/// pixel and 191 of 191 at 1.0, which is what a user reported as an edge that "lights
/// up" on zoom.
///
/// `Renderer::stage_trace_slopes()` grades the polyline by each segment's own slope, so
/// the model now spans both rasterisation paths. The riser reads 71 either side of the
/// threshold, and this test is that pair of readings.
PLOT_TEST(a_sparse_trace_is_graded_by_slope_like_a_dense_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(square_samples(2098), 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", {0.0, 60'000.0}, false, plot::AxisPlace::Bottom, false});
// -2..2 so the two levels sit a quarter in from each edge rather than on it: a level
// on the boundary row is half clipped away, and its ink would then read low for a
// reason that has nothing to do with grading.
const plot::u32 ay = p.add_y_axis({"v", {-2.0, 2.0}, false, plot::AxisPlace::Left, false});
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
r.style().intensity_grading = true;
struct Reading {
int riser = 0; ///< peak ink in the riser's own columns, at mid height
int flat = 0; ///< peak ink in a level's columns, well clear of the riser
};
// Both windows are centred on the transition at sample 1049, so the riser's column is
// known by construction rather than searched for -- a search would have to decide what
// a riser looks like, which is the very thing in question.
const auto measure = [&](double window) {
p.set_x_range(ax, {1049.0 - window / 2.0, 1049.0 + window / 2.0});
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(p);
r.end_frame();
const plot::Rect a = p.layout().axes;
const plot::u32 rows = static_cast<plot::u32>(a.h);
const plot::u32 cols = static_cast<plot::u32>(a.w);
const plot::u32 mid = cols / 2u;
Reading out;
for (plot::u32 col = 0; col < cols; ++col) {
for (plot::u32 row = 0; row < rows; ++row) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(a.x) + col,
ctx.height - 1u - (static_cast<plot::u32>(a.y) + row), px));
const int cover = static_cast<int>(px[2]) - static_cast<int>(px[0]);
if (col + 6u < mid || col > mid + 6u) {
// A level's own ink. The six-column guard is for the near-vertical stroke's
// own width plus the connector either side of it.
if (cover > out.flat) out.flat = cover;
} else if (row >= 3u * rows / 8u && row <= 5u * rows / 8u) {
// The riser's own ink: the middle quarter of the axis, which neither level
// reaches. Reading the full height instead answers 191 at every zoom, because
// the riser's column contains a level too.
if (cover > out.riser) out.riser = cover;
}
}
}
return out;
};
// 3.4 samples a pixel and 1.0, so one window either side of `dense_threshold_spp`
// (1.5) over the same waveform at the same place.
const Reading dense = measure(2000.0);
const plot::FrameStats dense_stats = r.stats();
const Reading sparse = measure(600.0);
const plot::FrameStats sparse_stats = r.stats();
std::printf("[slope] dense riser=%d flat=%d graded=%u slopes=%u | sparse riser=%d flat=%d "
"graded=%u slopes=%u\n",
dense.riser, dense.flat, dense_stats.graded_traces, dense_stats.slope_traces,
sparse.riser, sparse.flat, sparse_stats.graded_traces, sparse_stats.slope_traces);
// The two frames took the two paths, or the comparison is between a frame and itself.
PLOT_CHECK(dense_stats.graded_traces == 1 && dense_stats.slope_traces == 0);
PLOT_CHECK(sparse_stats.slope_traces == 1 && sparse_stats.graded_traces == 0);
// Neither path dims the level: a flat segment dwells in its row, so it is the top tier
// and full ink. Both read 191.
PLOT_CHECK(dense.flat > 180 && sparse.flat > 180);
// And both dim the discontinuity, to about a third -- `intensity_floor` is 0.3 and a
// riser spanning the axis is the longest span there is, so it sits on the floor. Both
// read 71. Before the slope grading the sparse frame read 191 here.
PLOT_CHECK(dense.riser * 100 < dense.flat * 55);
PLOT_CHECK(sparse.riser * 100 < sparse.flat * 55);
// The point of the exercise: the same edge at the two zooms is the same ink, so there
// is no step at the threshold for a reader to see. A tier either way is 23 of 191, and
// the measured pair is exact.
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();
}
PLOT_TEST(the_box_zoom_frame_lands_exactly_where_it_says) {
// The frame a host sets has to reach the framebuffer and cover exactly the rect it names. Both halves
// are read back rather than trusted, and the two renders are compared: what is under the frame is a
// trace, the grid and the axis chrome, so the assertions are differential -- a predicate guessing
// those colours would be testing the guess.
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}, true, plot::AxisPlace::Left, false});
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
// The *floor* of a pixel rather than its luminance: the fill is the axis furniture's light neutral at
// a low alpha, so over a saturated trace it can lower a channel while it raises the floor, and the
// floor is what says "something light was laid over this".
const auto floor_of = [&](plot::u32 x, plot::u32 y) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, px));
return static_cast<int>(std::min(px[0], std::min(px[1], px[2])));
};
// The renderer does not clear -- that is the host's -- and a second draw over the first would double
// every trace and change every pixel for reasons that have nothing to do with the frame.
const auto draw = [&](bool framed) {
if (framed) p.set_selection_rect({200.0f, 100.0f, 200.0f, 120.0f});
plot::gl::glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
plot::gl::glClear(plot::gl::GL_COLOR_BUFFER_BIT);
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(p);
r.end_frame();
};
draw(false);
const int border_before = floor_of(200, 160); // the frame's left border, mid-height
const int inside_before = floor_of(300, 160); // the middle of the framed area
const int outside_before = floor_of(500, 160); // to the right of it
draw(true);
const int border_after = floor_of(200, 160);
const int inside_after = floor_of(300, 160);
const int outside_after = floor_of(500, 160);
// A frame, not a wash: the border is nearly opaque, the fill is not.
PLOT_CHECK(border_after > inside_after);
PLOT_CHECK(border_after > border_before + 60);
// A fill, not a panel: it lightens what it covers and leaves it readable.
PLOT_CHECK(inside_after > inside_before + 10);
// Exactly the rect, and not a pixel beyond it: this is the assertion that the frame matches the area
// the gesture is choosing rather than merely being somewhere on screen.
PLOT_CHECK(outside_after == outside_before);
p.clear_selection_rect();
}
+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);
}