Compare commits

..
15 Commits
Author SHA1 Message Date
Martino Ferrari 1d5b286611 crosshair: axis badges, clustered cursor labels, and the shared-y horizontal line
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
- Axis badges: the y value in the y label strip and the x time in the x strip, each a
  full-opacity panel in the crosshair's colour, **centred on the line it names**. The tick
  text is flushed before the fill -- the over-text batch is flushed after every line batch,
  so without that flush the label behind printed through the panel (the "transparent
  backplate" defect).
- Cursor labels: collected across the cursor loop and clustered at its end, so two marks in
  one column share a panel with one line each instead of overprinting, and a label stays on
  its own mark's row.
- Figure: a split row's cells share one y (`set_share_row_y`), so the horizontal line is
  drawn in every cell of the hovered row -- one value, one scale, spanning the row -- and
  still in no other row.

Tests: `figure_crosshair_horizontal_spans_a_split_rows_cells`, plus the render-smoke
additions for the badge placement/flush and the clustered labels.
2026-11-05 17:58:49 +01:00
Martino Ferrari 2cb31cbb91 figure: x axes follow the column structure; split rows keep the right gutter on the last cell only 2026-11-04 07:00:52 +01:00
Martino Ferrari 69f0083fc0 figure: note same-axis-kind assumption in row y fit 2026-11-04 01:13:29 +01:00
Martino FerrariandClaude Opus 4.6 2b4b316ad8 figure: shared-y rows draw one y axis, on the first cell
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-04 00:43:41 +01:00
Martino Ferrari 25c3381551 figure: cells of a row can share one y range 2026-11-04 00:10:47 +01:00
Martino Ferrari 73f610b136 adapters: nearest-cell distance in double 2026-11-03 19:28:24 +01:00
Martino Ferrari e5b7faadec adapters: route pointer events by cell rect 2026-11-03 17:04:16 +01:00
Martino Ferrari 7d8c453232 figure: rows can hold several plots (cells), cell weights and column gap 2026-11-03 16:47:47 +01:00
Martino Ferrari f79a97be9b fix: a density grid is bounded in cells, not by the window's pixels
A grid was the axes rect in device pixels, so dragging a window wide made the
image wider with it -- and the per-frame pass over it is O(cells): the fade
multiplies every cell, a host's ink()/peak() read every cell, and a frame the
fade moved converts and uploads every cell as one byte. At 2x device scale a
1520x850 window's axes rect is 2877 device pixels wide, so four rows of one
trace are 4.5 M cells and 4.9 MB uploaded a frame; measured headless on
llvmpipe with a 2 s window, 12.9 ms of tick and 12.8 ms of draw a frame, every
frame under a time fade -- a cost that follows the window and has no ceiling.

The grid is now the axes rect up to kDensityCellCap (2^18 cells, a mebibyte of
counts), scaled down uniformly past it so cells stay square and the picture
keeps the axes' aspect, and magnified back over the rect with a linear filter:
a cell is a count, so a fragment between two of them is between their two ink
levels, which a point sample cannot give once a cell is several pixels wide.
The same four rows at the cap are 1.05 M cells, 3.9 ms of tick and 5.6 ms of
draw and 1.0 MB uploaded, while a grid under the cap is untouched -- a 640x360
viewport's 573x313 axes is still 179 349 cells, one a device pixel.

The image's footprint is the axes rect rounded up to whole device pixels on
both sides of the pipe, so a grid under the cap keeps the exact
one-texel-a-pixel mapping it has always had (sx/sy come out exactly 1) and only
a capped grid reads its cell index through a scale. Moving the decay itself to
the GPU was considered and left out: the CPU grid is what ink()/peak() read,
what the deposit adds to and what the per-sweep dedupe keys on, so a GPU image
would have to be reconciled with it per frame -- a larger change than this
bound, and not one to half-do.

Tests: a_density_grid_is_capped_whatever_the_window reads the bound off a
3840x2160 viewport (261 504 cells of a 3784x2138 axes, aspect kept),
a_capped_density_is_magnified_over_the_axes_rect reads the screen side of it
(569x460 cells covering all 832 of the rect's columns), and the density cases
now assert their capped figures. 399 cases, 0 failed.
2026-10-16 21:09:07 +02:00
Martino Ferrari 1b8b55d3ee feat: a phosphor density is the trace, one grid per trace in its own colour
A user looked at the display and reported three things at once: "there is clearly a gain offset
between latest acquisition and persistent image", "persistent instead of being brighter + opaque is
just gray/white", and "last acquisition does not seem to be affected, it looks like persistent image
+ last acquisition (so for example last acquisition never fade in time mode)". What they add up to is
that this was not a phosphor yet: it was a **density with a solid trace drawn over it**.

Four changes, and the first is the one the others follow from:

- **The current acquisition is deposited like every other, and no solid trace is drawn over the
  image.** `Renderer::draw_traces()` returns at once while a density is on: the trace ink *is* the
  image, so the newest acquisition is a sweep inside it and fades with it. Drawing the polyline as
  well put a solid, unfading stroke over its own faint ghost -- the reported "persistent image + last
  acquisition", where the last acquisition never faded in time mode.
- **One grid per trace, in that trace's own colour.** `Plot::density_grid(trace)` is one screen-sized
  `f32` grid per trace, deposited from that trace and drawn in `Trace::color`, because a density that
  had one hue for the plot could not say *which* trace put the ink there -- two signals agreeing in a
  cell would be one blended pixel. The display's memory is therefore `traces * columns * rows * 4`
  bytes rather than one plot's worth (a trace with no series, an unbound slot, holds no grid), which
  is the one cost this change adds: four signals in one 800x480 row are four grids, 6 MB.
- **The ceiling is the trace's own brightness, not the fade's steady state.** `full_scale` now
  defaults to **one sweep**: a cell with one acquisition's ink is drawn in its trace's colour at that
  colour's own alpha, and a signal crossing the same place every acquisition settles *at* the colour
  its trace would have drawn (the ceiling clamps) rather than a fraction of a brighter steady state --
  the gain offset the user reported. `Style::density`'s near-white 0.85 tint is gone with it, because
  there is no one tint any more. A caller that wants the fade's ramp on screen instead still sets
  `full_scale` to the sum the fade settles at and accepts the offset; that is a legitimate choice, just
  not the one a phosphor makes.

The reading is a **framebuffer** one, because neither `ink`/`peak` nor a tint can make a colour or a
fade: `a_phosphor_density_is_the_trace_in_its_own_colour` deposits one sweep, checks the saturated
cell is *exactly* the trace's colour (`full=224`, the trace's `0xE0` green, against `bg=25` -- the old
monochrome tint read ~190 red), then fades it one time constant and checks the same pixel is dimmer
(`faded=115`) and still the trace's hue -- which a stroke over the density could not do. It moves the
trace and draws without depositing to prove no polyline is drawn (`moved` equals the no-density frame
at the moved row), and reads a steady state against a quarter of it (`steady=224`, `quarter=138`), in
the signal's colour throughout. `a_density_holds_one_grid_per_trace` reads two traces' grids apart
(`267 472 cells each, ink 584/584`, in two rows). The grid's own cases are unchanged where they should
be (`a_density_halves_per_acquisition_and_keeps_the_newest_brightest` `peak_steady=2.000`,
`a_density_fades_by_its_time_constant` `exp(-dt/tau)`).

Suite: **397 cases, 0 failed**, 4/4 ctest entries.
2026-10-16 20:21:38 +02:00
Martino Ferrari 738205293e fix: the density's brightness ceiling is the fade's steady state, and the ramp is linear
A user looked at the display and reported "the colour fading is not applied, it looks white or
full color, no opacity gradient" -- and the *numbers* were right: ink decayed, `step-time` was
1/e, `step-acquisitions` was 1 + 0.5 x 1. It was the mapping from a cell's value to a pixel.

**Neither the shader nor the blend was wrong** (the answer to the question that was asked): the
fragment write is already the weighted one (`vec4(u_color.rgb, u_color.a * ink)`) with the frame's
straight-alpha blend, and a cell at half ink over a grid line rendered 160 where the arithmetic
predicts 160. What was wrong was the **scale**:

- `full_scale` defaulted to **1.0**, so the *newest* acquisition already sat at the ceiling and
  everything above it clamped to the same pixel -- a display of one sweep and a display of five
  were one white. It is now **1/(1 - 0.5) = 2.0**, the steady state of the default per-acquisition
  fade, so one sweep reads half ink and white is reached only where acquisitions agree. A caller
  whose fade settles elsewhere states its own sum (udpscope's is `1/(1 - factor)`, in its commit).
- `gamma` defaulted to **0.45**, the value `Style::intensity_gamma` wants where the measure is a
  *dwell fraction*; on a *count* it spends most of the ramp on the last sweep (a quarter of the
  scale mapped to 0.54). It is now **1.0 -- linear in sweeps**, because half as many sweeps must
  read as half the ink.

The case that would have caught this is a **framebuffer** reading, not the grid's, and
`a_phosphor_density_is_drawn_under_the_traces` now makes it: it fades the grid to a quarter
through the time rule (`dt = tau ln 4`, asserted at 0.25 off the ink) and reads the same pixel
back, so the ramp is asserted where the eye sees it -- **`empty=21 full=83 quarter=54`** (red
channel), against the 199 / 199 the ceiling and a single sweep both read at the old defaults. A
full cell must be measurably above the background and above a quarter cell, and a quarter cell
above the background: three assertions on pixels, none of which the grid's own numbers can make.

Suite: **396 cases, 0 failed**, 4/4 ctest entries.
2026-10-16 17:38:00 +02:00
Martino Ferrari 6a6915e64e feat: a plot can carry a phosphor density, one image for every acquisition
`Plot::set_density()` gives a plot a **phosphor density**: a screen-sized intensity image behind
its traces, into which each acquisition is deposited and which fades by a time constant or by a
per-acquisition factor. It is the library's and not a host's because the library is the side that
knows a trace's device geometry -- a host would be re-rasterising traces the renderer has already
staged, at a second derivation of the same mapping.

It is an **image and not a set of retained traces**, and that is the whole of its cost: one `f32`
cell per device pixel of the axes rect (`DensityGrid`), a cell's value a *number of sweeps*, so a
display of a thousand acquisitions costs what one costs and there is no depth to get wrong.
Measured on a 640x360 viewport's axes: **573x313 = 179 349 cells, 717 396 bytes** of grid, and one
byte per cell uploaded on the frame the grid moved.

- `Plot::accumulate_density()` deposits the acquisition on screen: every visible trace's window is
  mapped to sample indices and gathered exactly as `Renderer::draw_traces()` maps it, then walked
  column by column -- so the deposit is bounded by the axes' columns and not by the samples behind
  them -- and a cell takes **1.0 once per trace per sweep**, however many of that trace's own
  samples crossed it. Counting the hits instead put a single dense sweep at the ceiling of the
  brightness scale and hid the sweeps behind it, and it made a cell mean "how much the beam moved
  here" rather than "how many acquisitions were here"; the dedupe is a stamp per grid *row* (one
  `u64`, keyed by trace and column) so two sweeps agreeing on a cell still count twice. Dwell is a
  different question the library already answers (`Style::intensity_grading`).
- `decay_density(dt_s)` fades by the clock, `clear_density()` and switching the density off drop
  the image at once. `DensityConfig` carries `full_scale` and `gamma`, applied on the CPU when the
  grid changed: the value a host reads back through `ink()`/`peak()` and the picture it sees are
  one derivation, and a frame whose grid did not move skips the conversion and the upload entirely
  (`FrameStats::density_cells`/`density_upload_bytes`, the latter the *frame's* total over every
  row, which one row's assignment used to overwrite).
- The renderer draws it as one attribute-less quad over the axes rect (`kDensityVS`/`kDensityFS`,
  `DensityProgram`), over the grid and the invalid-span bands and **under the traces**, inside the
  axes' clip: it reserves no layout space and nothing on it can be picked, exactly as
  `Plot::set_invalid_spans()` does. `Style::density` is its tint.
- The two fades are the two questions a persistence display can be asked -- "still visible a second
  later" and "the N-th back is gone" -- so the choice is the caller's.

Six cases, all in the suite: four plot-level ones (the grid's resolution and memory, the
per-acquisition schedule **584 -> 876 -> 1022** ink with `peak_steady=2.000`, `exp(-dt/tau)` off
the ink, and the deposit's own geometry including a NaN as a gap) and one on the real driver
(`a_phosphor_density_is_drawn_under_the_traces`: `cells=179349 uploaded=179349` on the moved frame
and `uploaded=0` on the identical one after it, with the trace's stroke keeping its own colour over
the ink). The suite is **396 cases, 0 failed** and all four ctest entries pass.
2026-10-16 17:25:39 +02:00
Martino FerrariandClaude Opus 4.6 3ad02e5120 feat: a cleared ring can resume its absolute count at a given origin
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
A derived ring that is emptied and refilled from its source's retained
window has to come back numbered as the source is. `clear()` restarts it
at 0, which leaves every absolute index -- `base()`, `produced()`,
`copy_from()` -- short by that origin for a whole window's worth of data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-15 16:30:05 +02:00
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
28 changed files with 3558 additions and 236 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
# happens to sit in the repo root.
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
+22 -2
View File
@@ -14,6 +14,12 @@ built on top of it.
| Qt6 | `plot::qt6` | `adapters/qt6/` |
| 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
(`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
@@ -52,6 +58,20 @@ run touched a GPU, so read it as an upper bound and a record of the software pat
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;
- a **phosphor density** a host can turn on for a plot (`Plot::set_density()`, an image rather
than a set of retained traces): **one grid per trace**, in that trace's own colour, the axes
rect in device pixels, drawn *in place of* the traces -- a host may not also draw them over it --
into which each acquisition is deposited, 1.0 per cell a trace crosses, so brightness is how
often it passed. It fades either by a **time constant** in stream seconds or by a
**per-acquisition factor**, and the newest acquisition is deposited like every other, so it
fades too. The resolution is bounded (`kDensityCellCap`, 262 144 cells -- a mebibyte of counts --
a grid, past which the rect is scaled down uniformly and the image magnified back over it with a
linear filter), because the per-frame pass is `O(cells)` and the window is not bounded at all:
memory is the cap times the traces on screen times 4 bytes, whatever the window (a 640x360
viewport's axes is 179 349 cells per trace, 717 kB, under it, while four rows of one trace in a
3040x1700 one used to be 4.5 M cells and 18 MB between them). The deposit walks the axes'
columns rather than the samples, and a frame whose grids did not move costs one draw call per
trace (`FrameStats::density_cells`/`density_upload_bytes`); see `docs/data-and-performance.md`;
- 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);
- grid with independent major/minor line classes (`Style::grid_major_visible`,
@@ -190,12 +210,12 @@ documentation.
```sh
cmake --preset linux-debug
cmake --build build/debug
ctest --test-dir build/debug --output-on-failure # 377 cases, 0 failed
ctest --test-dir build/debug --output-on-failure # 396 cases, 0 failed
tools/coverage.sh # configure+build+run+capture+floor check
```
The `linux-debug` suite runs **377 cases**; the `linux-coverage` preset builds **360** of
The `linux-debug` suite runs **396 cases**; the `linux-coverage` preset builds **379** of
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
`tools/coverage.sh` against `tools/coverage_floor.py`: **80%** per file under `src/` and
+27 -22
View File
@@ -455,9 +455,11 @@ void Host::set_figure(Figure* figure) noexcept {
row_rects_.clear(); // no figure, no rows: the handlers fall back to `controller_`
return;
}
const u32 rows = figure_->row_count();
row_controllers_.reserve(rows);
for (u32 i = 0; i < rows; ++i) {
// One controller per **plot** (a row of several cells has one for each), indexed by the
// flattened plot index the figure uses.
const u32 plots = figure_->plot_count();
row_controllers_.reserve(plots);
for (u32 i = 0; i < plots; ++i) {
row_controllers_.push_back(std::make_unique<input::Controller>(figure_->plot(i)));
}
// The new figure's rects go into the cache now, not at the next frame: an event
@@ -469,8 +471,10 @@ void Host::set_figure(Figure* figure) noexcept {
void Host::refresh_row_rects() noexcept {
// The one `Figure::layout()` per call: every event handler reads `row_rects_`
// instead of paying this again, which is the whole point of the cache (R7).
row_rects_ = figure_ != nullptr ? figure_->layout().rows : std::vector<Rect>{};
// instead of paying this again, which is the whole point of the cache (R7). The rects
// are the figure's **cells** (one per plot, flattened), which for a figure with one
// cell per row are exactly its row rects.
row_rects_ = figure_ != nullptr ? figure_->layout().cells : std::vector<Rect>{};
}
input::Controller* Host::row_controller(u32 row) noexcept {
@@ -486,8 +490,7 @@ input::Controller& Host::controller_at(f32 x, f32 y) noexcept {
// frame stale, which is the documented worst case.
const std::vector<Rect>& rows = row_rects_;
if (rows.empty() || rows.size() != row_controllers_.size()) return controller_;
(void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in()
const u32 row = row_in(rows, y);
const u32 row = row_in(rows, x, y);
last_row_ = row;
input::Controller& controller = *row_controllers_[row];
// The row's own viewport, so the geometry this controller hit-tests is the geometry
@@ -506,7 +509,6 @@ void Host::feed_pointer_event(const input::PointerEvent& e) noexcept {
input::Controller& Host::pointer_target(f32 x, f32 y, input::Button button, bool pressed,
bool released) noexcept {
(void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in()
if (figure_ == nullptr || row_controllers_.empty()) return controller_;
const std::vector<Rect>& rows = row_rects_;
if (rows.empty() || rows.size() != row_controllers_.size()) return controller_;
@@ -523,7 +525,7 @@ input::Controller& Host::pointer_target(f32 x, f32 y, input::Button button, bool
// drag, and its press must not steal the capture to the row it landed in.
if (released && button == captured_button_) captured_ = false;
} else {
row = row_in(rows, y);
row = row_in(rows, x, y);
if (pressed) {
captured_ = true;
captured_row_ = row;
@@ -541,27 +543,30 @@ input::Controller& Host::pointer_target(f32 x, f32 y, input::Button button, bool
u32 Host::pointer_row(const std::vector<Rect>& rows) noexcept {
if (captured_ && captured_row_ < rows.size()) return captured_row_;
return row_in(rows, last_pointer_y_);
return row_in(rows, last_pointer_x_, last_pointer_y_);
}
u32 Host::row_in(const std::vector<Rect>& rows, f32 y) noexcept {
u32 Host::row_in(const std::vector<Rect>& rows, f32 x, f32 y) noexcept {
if (rows.empty()) return 0u;
// The row the pixel is in, or -- when it is in none of them, which is the row gap a
// host has asked for with `Figure::set_row_gap()` or the figure's own margins -- the
// row whose rect is nearest. Selection by distance rather than by "the first that
// matches" is what makes a pixel in a gap belong to the row it is *near*: the rows
// tile the figure at the default gap of 0, so this is unreachable until a host asks
// for one, and a gesture in a visible gap must not be routed to row 0 by default.
// The cell the pixel is in, or -- when it is in none of them, which is the row gap or
// column gap a host has asked for with `Figure::set_row_gap()`/`set_col_gap()` or the
// figure's own margins -- the cell whose rect is nearest (squared distance from the
// pixel to the rect). Selection by distance rather than by "the first that matches" is
// what makes a pixel in a gap belong to the cell it is *near*, and a gesture in a
// visible gap must not be routed to plot 0 by default. For a figure with one cell per
// row every cell spans the figure's width, so this reduces to the nearest row by y.
u32 row = 0u;
f32 best = std::numeric_limits<f32>::infinity();
double best = std::numeric_limits<double>::infinity();
for (u32 i = 0; i < rows.size(); ++i) {
const f32 top = rows[i].y;
const f32 bottom = rows[i].y + rows[i].h;
const f32 distance = y < top ? top - y : (y >= bottom ? y - bottom : 0.0f);
const Rect& r = rows[i];
// In double: in f32 the dy^2 term can be swallowed by a large dx^2 (pointer far outside).
const double dx = x < r.x ? double(r.x) - x : (x >= r.right() ? double(x) - r.right() : 0.0);
const double dy = y < r.y ? double(r.y) - y : (y >= r.bottom() ? double(y) - r.bottom() : 0.0);
const double distance = dx * dx + dy * dy;
if (distance < best) {
best = distance;
row = i;
if (distance == 0.0f) break; // inside this row: nothing can be nearer
if (distance == 0.0) break; // inside this cell: nothing can be nearer
}
}
return row;
+19 -15
View File
@@ -121,18 +121,21 @@ class Host {
/// `plot_` is then untouched by any of this.
void set_figure(Figure* figure) noexcept;
Figure* figure() noexcept { return figure_; }
/// One figure row's controller -- the one a gesture on that row reaches -- or
/// null while no figure is set or the row does not exist. A host reads a drag
/// One figure plot's controller -- the one a gesture on that plot reaches -- or null
/// while no figure is set or it does not exist. `row` is the **flattened plot index**
/// (`Figure::plot_count()` of them: the row count when no row holds several cells). A host reads a drag
/// mode or a hover out of it (`controller()` is the single-plot loop's, and is
/// not the one figure gestures go to).
input::Controller* row_controller(u32 row) noexcept;
/// The controller an event at framebuffer pixel (x, y) belongs to: `controller_` --
/// the single-plot loop -- while no figure is set, and the controller of the figure
/// row that pixel lands in otherwise. The row rects come from the cache
/// plot (cell) that pixel lands in otherwise -- x and y both pick it, as a row may hold
/// several cells side by side. The cell rects come from the cache
/// `refresh_row_rects()` fills -- the same ones the renderer draws -- so a gesture
/// lands on the row it was aimed at and no event lays the figure out; a pixel
/// inside no row -- the row gap a host asked for with `Figure::set_row_gap()`,
/// or a margin -- is the *nearest* row rather than dropped or defaulted, so a drag
/// inside no cell -- the row or column gap a host asked for with
/// `Figure::set_row_gap()`/`set_col_gap()`, or a margin -- is the *nearest* cell rather than
/// dropped or defaulted, so a drag
/// that leaves the data area still ends where it should.
///
/// It also hands that row its own viewport, which is what keeps the controller's
@@ -217,11 +220,12 @@ class Host {
void on_scroll(f64 dx, f64 dy) noexcept;
void on_key(int key, int action, int mods) noexcept;
/// The index of the row of `rows` a framebuffer y belongs to: the row it is in, or the nearest
/// row when it is in none (a row gap, a margin outside the figure). One rule, shared by the event
/// routing and the crosshair sync -- and taking the rects rather than reading them, so a caller
/// that has just laid the figure out does not make it lay out again for this.
static u32 row_in(const std::vector<Rect>& rows, f32 y) noexcept;
/// The index of the cell of `rows` (the figure's flattened cell rects) a framebuffer pixel
/// belongs to: the cell it is in, or the nearest cell when it is in none (a row or column gap,
/// a margin outside the figure). One rule, shared by the event routing and the crosshair sync
/// -- and taking the rects rather than reading them, so a caller that has just laid the figure
/// out does not make it lay out again for this.
static u32 row_in(const std::vector<Rect>& rows, f32 x, f32 y) noexcept;
/// The controller a pointer event at (x, y) belongs to, applying the button capture:
/// a press claims the row it landed in (`captured_row_` + `captured_button_`) and
@@ -231,10 +235,10 @@ class Host {
input::Controller& pointer_target(f32 x, f32 y, input::Button button, bool pressed,
bool released) noexcept;
/// The row the pointer's events currently go to: the captured row while a drag owns
/// the button, the row under the pointer otherwise. The run loop uses it to clear
/// every *other* row's hot handles, so a row the pointer left cannot keep drawing a
/// hover or a grab it last published.
/// The plot (flattened index) the pointer's events currently go to: the captured one
/// while a drag owns the button, the one under the pointer otherwise. The run loop uses
/// it to clear every *other* plot's hot handles, so one the pointer left cannot keep
/// drawing a hover or a grab it last published.
u32 pointer_row(const std::vector<Rect>& rows) noexcept;
/// Spreads the hover over a whole `Figure` (spec §15).
@@ -292,7 +296,7 @@ class Host {
u32 captured_row_ = 0u;
input::Button captured_button_ = input::Button::Left;
/// The figure's row rects as of the last `refresh_row_rects()` -- the cache the
/// The figure's cell rects (one per plot, flattened; the row rects when no row is split) as of the last `refresh_row_rects()` -- the cache the
/// event routing reads instead of laying the figure out per event (R7). Filled by
/// `set_figure()` and once per frame by the run loop; empty while no figure is
/// set, which is the state `controller_at()`/`pointer_target()` fall back to the
+36 -30
View File
@@ -245,9 +245,11 @@ void PlotArea::set_figure(Figure* figure) noexcept {
row_rects_.clear(); // no figure, no rows: the routing falls back to `controller_`
return;
}
const u32 rows = figure_->row_count();
row_controllers_.reserve(rows);
for (u32 i = 0; i < rows; ++i) {
// One controller per **plot** (a row of several cells has one for each), indexed by the
// flattened plot index the figure uses.
const u32 plots = figure_->plot_count();
row_controllers_.reserve(plots);
for (u32 i = 0; i < plots; ++i) {
row_controllers_.push_back(std::make_unique<input::Controller>(figure_->plot(i)));
}
// The new figure's geometry goes into the cache now, not at the next frame: an event
@@ -269,8 +271,10 @@ void PlotArea::lay_figure_out(Rect px) noexcept {
void PlotArea::refresh_row_rects() noexcept {
// The one `Figure::layout()` per call: every event handler reads `row_rects_`
// instead of paying this again, which is the whole point of the cache.
row_rects_ = figure_ != nullptr ? figure_->layout().rows : std::vector<Rect>{};
// instead of paying this again, which is the whole point of the cache. The rects are
// the figure's **cells** (one per plot, flattened), which for a figure with one cell
// per row are exactly its row rects.
row_rects_ = figure_ != nullptr ? figure_->layout().cells : std::vector<Rect>{};
}
input::Controller* PlotArea::row_controller(u32 row) noexcept {
@@ -283,8 +287,7 @@ input::Controller& PlotArea::controller_at(f32 x, f32 y) noexcept {
// -- and not a fresh `Figure::layout()`: an event pays no layout, or a burst of
// pointer motion over a multi-row figure would re-lay every row out per move.
if (row_rects_.empty() || row_rects_.size() != row_controllers_.size()) return controller_;
(void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in()
const u32 row = row_in(row_rects_, y);
const u32 row = row_in(row_rects_, x, y);
input::Controller& controller = *row_controllers_[row];
controller.set_viewport(row_rects_[row]);
return controller;
@@ -292,11 +295,11 @@ input::Controller& PlotArea::controller_at(f32 x, f32 y) noexcept {
u32 PlotArea::row_at(double x, double y) const noexcept {
if (figure_ == nullptr || row_rects_.empty()) return 0;
(void)x;
return row_in(row_rects_, to_device(x, y).y);
const Vec2 p = to_device(x, y);
return row_in(row_rects_, p.x, p.y);
}
input::Controller& PlotArea::target_controller(f32 y) noexcept {
input::Controller& PlotArea::target_controller(f32 x, f32 y) noexcept {
if (figure_ == nullptr || row_controllers_.empty()) return controller_;
if (row_rects_.empty() || row_rects_.size() != row_controllers_.size()) return controller_;
// The capture: while a press is open the gesture belongs to the row that press
@@ -304,7 +307,7 @@ input::Controller& PlotArea::target_controller(f32 y) noexcept {
// the release to the row the pointer is over *now* would leave the first row's drag
// open for ever -- its controller never sees the release that ends it -- and let a
// later gesture resume it from the stale anchor.
u32 row = press_open_ ? press_row_ : row_in(row_rects_, y);
u32 row = press_open_ ? press_row_ : row_in(row_rects_, x, y);
// Re-read against this frame's rects: the widget can be resized mid-drag, and the
// guard keeps a stale index from reaching past the controller list.
if (row >= row_controllers_.size()) row = 0;
@@ -314,23 +317,26 @@ input::Controller& PlotArea::target_controller(f32 y) noexcept {
return controller;
}
u32 PlotArea::row_in(const std::vector<Rect>& rows, f32 y) noexcept {
u32 PlotArea::row_in(const std::vector<Rect>& rows, f32 x, f32 y) noexcept {
if (rows.empty()) return 0;
// The row the pixel is in, or -- when it is in none of them, which is the row gap a
// host asked for with `Figure::set_row_gap()` or the figure's own margins -- the row
// whose rect is nearest. By distance rather than by "the first that matches": the
// rows tile the figure at the default gap of 0, so this is unreachable until a host
// asks for a gap, and a gesture in a visible gap must not default to row 0.
// The cell the pixel is in, or -- when it is in none of them, which is the row or
// column gap a host asked for with `Figure::set_row_gap()`/`set_col_gap()` or the
// figure's own margins -- the cell whose rect is nearest (squared distance from the
// pixel to the rect). By distance rather than by "the first that matches": a gesture
// in a visible gap must not default to plot 0. For a figure with one cell per row
// every cell spans the figure's width, so this reduces to the nearest row by y.
u32 row = 0;
f32 best = std::numeric_limits<f32>::infinity();
double best = std::numeric_limits<double>::infinity();
for (u32 i = 0; i < rows.size(); ++i) {
const f32 top = rows[i].y;
const f32 bottom = rows[i].y + rows[i].h;
const f32 distance = y < top ? top - y : (y >= bottom ? y - bottom : 0.0f);
const Rect& r = rows[i];
// In double: in f32 the dy^2 term can be swallowed by a large dx^2 (pointer far outside).
const double dx = x < r.x ? double(r.x) - x : (x >= r.right() ? double(x) - r.right() : 0.0);
const double dy = y < r.y ? double(r.y) - y : (y >= r.bottom() ? double(y) - r.bottom() : 0.0);
const double distance = dx * dx + dy * dy;
if (distance < best) {
best = distance;
row = i;
if (distance == 0.0f) break; // inside this row: nothing can be nearer
if (distance == 0.0) break; // inside this cell: nothing can be nearer
}
}
return row;
@@ -341,7 +347,7 @@ u32 PlotArea::pointer_row() const noexcept {
// The captured row while a press owns the gesture, so an in-flight drag keeps its
// highlight even after the pointer has left the row it began in.
if (press_open_ && press_row_ < row_rects_.size()) return press_row_;
return row_in(row_rects_, pointer_.y);
return row_in(row_rects_, pointer_.x, pointer_.y);
}
void PlotArea::sync_selection_frames() noexcept {
@@ -616,7 +622,7 @@ void PlotArea::pointer_press(input::Button button, u32 mods, double x, double y,
// The figure row this gesture belongs to for its whole life, claimed from the press's
// own position *before* the event is dispatched (see `target_controller()`). Harmless
// and unread while no figure is set.
press_row_ = figure_ != nullptr ? row_in(row_rects_, p.y) : 0;
press_row_ = figure_ != nullptr ? row_in(row_rects_, p.x, p.y) : 0;
press_open_ = true;
press_button_ = button;
press_mods_ = mods;
@@ -634,7 +640,7 @@ void PlotArea::pointer_press(input::Button button, u32 mods, double x, double y,
e.down = true;
e.pressed = true;
e.time_s = pointer_time_s(time_ms); // the double-click detector differences these
target_controller(p.y).on_pointer(e);
target_controller(p.x, p.y).on_pointer(e);
gtk_gl_area_queue_render(GTK_GL_AREA(widget_));
}
@@ -643,7 +649,7 @@ void PlotArea::pointer_release(Vec2 position, u32 time_ms) noexcept {
// Resolved while the press is still open, so the release reaches the row the press
// claimed rather than the row the pointer ended over -- the controller that owns the
// drag is the only one that can close it.
input::Controller& target = target_controller(position.y);
input::Controller& target = target_controller(position.x, position.y);
press_open_ = false;
input::PointerEvent e;
@@ -748,7 +754,7 @@ void PlotArea::on_drag_update(GtkGestureDrag* gesture, double offset_x, double o
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture)));
// The press's row, not the row the pointer has reached: `press_open_` is true here,
// so a drag that crosses a row boundary stays with the controller that began it.
area->target_controller(sample.position.y).on_pointer(e);
area->target_controller(sample.position.x, sample.position.y).on_pointer(e);
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
}
@@ -795,7 +801,7 @@ void PlotArea::on_zoom_scale_changed(GtkGestureZoom* gesture, double scale,
static_cast<f32>(scale), Vec2{static_cast<f32>(cx), static_cast<f32>(cy)}, area->mapping_);
// The folded event carries the focal point, which is the position this pinch belongs
// to; while a press is open the capture overrides it, as it does for a drag.
area->target_controller(g.y).on_gesture(g);
area->target_controller(g.x, g.y).on_gesture(g);
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
}
@@ -834,7 +840,7 @@ int PlotArea::on_scroll(GtkEventControllerScroll* scroll, double dx, double dy,
e.dx = static_cast<f32>(scroll_notches(dx, unit));
e.dy = static_cast<f32>(notches);
e.mods = gesture_mods(GTK_EVENT_CONTROLLER(scroll));
area->target_controller(at.y).on_wheel(e);
area->target_controller(at.x, at.y).on_wheel(e);
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
return TRUE; // consumed: the plot zoomed
}
@@ -858,7 +864,7 @@ void PlotArea::on_motion(GtkEventControllerMotion* motion, double x, double y, v
e.down = false;
e.time_s = PlotArea::pointer_time_s(
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(motion)));
area->target_controller(p.y).on_pointer(e);
area->target_controller(p.x, p.y).on_pointer(e);
}
// --- keyboard -------------------------------------------------------------
+21 -17
View File
@@ -260,15 +260,17 @@ class PlotArea {
Figure* figure() noexcept { return figure_; }
const Figure* figure() const noexcept { return figure_; }
/// One figure row's controller -- the one a gesture on that row reaches -- or null
/// while no figure is set or the row does not exist. A host reads a drag mode or a
/// One figure plot's controller -- the one a gesture on that plot reaches -- or null
/// while no figure is set or it does not exist. `row` is the **flattened plot index**
/// (`Figure::plot_count()` of them: the row count when no row holds several cells). A host reads a drag mode or a
/// hover out of it.
input::Controller* row_controller(u32 row) noexcept;
/// The controller an event at device pixel `(x, y)` belongs to: `controller()`
/// while no figure is set, and otherwise the controller of the figure row that
/// pixel lands in -- a pixel inside no row (a `Figure::set_row_gap()` gap, a
/// margin) is the *nearest* row rather than dropped, so a drag that leaves the data
/// while no figure is set, and otherwise the controller of the figure plot (cell)
/// that pixel lands in -- x and y both pick it, as a row may hold several cells side
/// by side. A pixel inside no cell (a `Figure::set_row_gap()`/`set_col_gap()` gap, a
/// margin) is the *nearest* cell rather than dropped, so a drag that leaves the data
/// area still ends where it should. It also hands that row its own viewport, which
/// is what keeps the controller's hit-test geometry in step with the layout between
/// two frames.
@@ -277,10 +279,10 @@ class PlotArea {
/// press capture, so a host may call it freely.
input::Controller& controller_at(f32 x, f32 y) noexcept;
/// The figure row a point in the widget's **logical** coordinates lands in -- the
/// The flattened plot index a point in the widget's **logical** coordinates lands in -- the
/// coordinates every GTK event controller reports, so a host's own drop target or
/// context menu can name the row a pointer is over without converting anything. The
/// same nearest-row rule as `controller_at()`, and 0 while no figure is set.
/// context menu can name the plot a pointer is over without converting anything. The
/// same nearest-cell rule (x and y) as `controller_at()`, and 0 while no figure is set.
u32 row_at(double x, double y) const noexcept;
/// Re-reads the figure's row rects into the cache the event routing answers from --
@@ -376,20 +378,21 @@ class PlotArea {
/// into device pixels. The one place that mapping exists.
Vec2 to_device(double x, double y) const noexcept;
/// The controller one event of this area's goes to, given the event's device y.
/// The controller one event of this area's goes to, given the event's device (x, y).
/// `controller_` while no figure is set; otherwise the row the gesture *belongs*
/// to: the row the open press claimed while a press is open, and the row under `y`
/// between gestures. That is the capture `controller_at()` deliberately omits, and
/// between gestures (x and y pick the cell). That is the capture `controller_at()` deliberately omits, and
/// it is why this -- not `controller_at()` -- is what every handler below calls.
///
/// Hands the chosen row its own viewport, so the geometry it hit-tests is the
/// geometry the last frame drew.
input::Controller& target_controller(f32 y) noexcept;
/// The row of `row_rects_` a device y belongs to: the row it is in, or the nearest
/// one when it is in none (a row gap, a margin outside the figure). Selection by
/// distance rather than "the first that matches" is what makes a pixel in a visible
/// gap belong to the row it is *near* instead of to row 0.
static u32 row_in(const std::vector<Rect>& rows, f32 y) noexcept;
input::Controller& target_controller(f32 x, f32 y) noexcept;
/// The cell of `row_rects_` (the figure's flattened cell rects) a device pixel belongs
/// to: the cell it is in, or the nearest one when it is in none (a row or column gap,
/// a margin outside the figure). Selection by distance rather than "the first that
/// matches" is what makes a pixel in a visible gap belong to the cell it is *near*
/// instead of to plot 0.
static u32 row_in(const std::vector<Rect>& rows, f32 x, f32 y) noexcept;
/// The row this area's events currently go to: the press's row while one is open,
/// so an in-flight gesture keeps its highlight, and the row under the last pointer
/// position otherwise. 0 with no rows.
@@ -457,7 +460,8 @@ class PlotArea {
/// `set_figure()` is called, and the single-plot path reads neither.
Figure* figure_ = nullptr;
std::vector<std::unique_ptr<input::Controller>> row_controllers_;
/// The figure's row rects as of the last `refresh_row_rects()`: the cache the event
/// The figure's cell rects (one per plot, flattened; the row rects when no row is split)
/// as of the last `refresh_row_rects()`: the cache the event
/// routing reads instead of laying the figure out per event. Empty while no figure
/// is set, which is the state the routing falls back to `controller_` on.
std::vector<Rect> row_rects_;
+4 -4
View File
@@ -41,8 +41,8 @@ ctest --test-dir build/debug --output-on-failure
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
`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 `377 cases, 0 failed` summary line.
`plot_umbrella_check`. The `plot_tests` binary itself runs **396 cases** in the `linux-debug`
preset; its harness prints one line per case and a final `396 cases, 0 failed` summary line.
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.
@@ -50,7 +50,7 @@ The case counts per file are the `PLOT_TEST(...)` declarations under `tests/`
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`
summary line still counts the whole suite, so a filtered run says `396 cases, 0 failed`
having run one — it is never evidence that the suite passes. Unset, every case runs.
## Install layout
@@ -126,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
live numbers.
**One thing to know about the coverage preset:** it builds **360 of the 377 cases**. The 17
**One thing to know about the coverage preset:** it builds **379 of the 396 cases**. The 17
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
adapters off. The toolkit-free adapter seams still run there: `test_qt_pointer.cpp` (5 cases)
+110
View File
@@ -327,6 +327,116 @@ way**, against the 191 the sparse frame gave before. Across the whole sweep from
samples a pixel the riser holds 71 and the level holds 191, so there is no step at the threshold
left to see.
## A phosphor density is the trace, one grid per trace
`Plot::set_density()` gives a plot a **phosphor density**: an image of how often its traces have
passed, drawn *in place of them* -- one grid per trace, in that trace's own colour -- fading by a
time constant or by an acquisition count. It is the library's because the library is the side that
knows a trace's device geometry; a host that built one itself would be re-rasterising traces the
renderer has already staged.
The split from the grading above is what it costs: grading measures *one* frame's dwell and
throws it away, while the density **keeps** what it measured — that is the whole point of it, and
also why it cannot be a per-frame accumulator.
- **The grid is the screen's, not the data's — bounded — and there is one per trace.** The axes
rect in device pixels (`DensityGrid`, `plot/plot/density.hpp`), one `f32` cell each, **up to
`kDensityCellCap` = 262 144 cells** (2^18, a mebibyte of counts): under the cap the grid *is* the
rect — the axes of a 640x360 viewport is a **573x313** grid of **179 349 cells** and **717 396
bytes**, and a thousand acquisitions through it cost the same — and past it the rect is scaled
down *uniformly*, so a cell stays square and the picture keeps the axes' aspect. A plot holds one
of these for **each trace it draws**, so the display's memory is that figure times the traces on
screen; the bound is per *grid*, so four traces hold at most 1 MiB each whatever the window is.
The colour is the reason for the per-trace split: a density that had one hue for the plot could
not say *which* trace put the ink there, and two signals agreeing in a cell would read as one
blended pixel. `Plot::density_grid(trace)` returns a trace's image; a trace with no series (an
unbound slot) holds no grid at all.
The cap is what keeps the resolution from following the window, and the window is not a fixed
thing: a 1520x850-logical window at 2x device scale has a 2877-pixel-wide axes rect, and four
rows of one trace are **4.5 M cells** — 18 MB of counts, every one of them walked per frame. The
pass is `O(cells)` in all three places it happens, so an unbounded image makes the *frame* cost a
function of the window: measured on llvmpipe (four traces, one a row, a 2 s window, a 3040x1700
framebuffer) the grid pass alone was **12.9 ms of tick and 12.8 ms of draw a frame** and 4.9 MB
uploaded, against **3.9 ms / 5.6 ms** and 1.0 MB at the cap. The renderer magnifies the capped
image back over the rect with a **linear** filter, because a cell is a *count* — a continuous
quantity — and a fragment between two of them is between their two ink levels, which is what a
linear tap returns and a point sample cannot (it would show the cell grid as blocks once a cell
is several pixels wide). A one-cell-tall feature is thereby spread over its neighbours, which is
what any magnification of an image does.
- **A cell's value is a number of sweeps**, and a trace covering it during one
`Plot::accumulate_density()` leaves 1.0, **once however many of its own samples crossed it**.
Counting the hits instead puts a single dense sweep at the ceiling of the brightness scale and
hides the sweeps behind it, and a cell would then mean "how much the beam moved here" rather
than "how many acquisitions were here". The dedupe is a stamp per grid *row* (one `u64`, a few
kB, shared by every trace's deposit), keyed by (trace, column), so two sweeps agreeing on a cell
still count twice and the two segments that share a column boundary do not.
- **The deposit is one pass per cell, not one per sample.** A trace's visible window is mapped to
sample indices and gathered exactly as `Renderer::draw_traces()` maps it (`neighbours()`, the
same 64-spp gate for the LOD level), then walked as a polyline: for each grid column the
segment covers, the rows it spans in that column get +1 each. A window of millions of samples
over hundreds of columns therefore costs *columns*, plus one row of ink per column the trace
crossed — the deposit is bounded by the screen, not by the data, which is what makes an
accumulation affordable at the trigger's own rate.
- **The newest acquisition is deposited like every other, and no trace is drawn over the image.**
`Renderer::draw_traces()` returns at once while a density is on: the trace ink *is* the image, so
the acquisition just drawn is a sweep *inside* it and fades with the rest. Drawing the polyline
on top as well would put a solid, unfading stroke over its own faint ghost — the "persistent
image + last acquisition" a user reported, where the newest acquisition never faded.
- **The brightness ceiling is the trace's own brightness, and the ramp is linear in sweeps.**
`DensityConfig::full_scale` defaults to **one sweep**, so a cell with one acquisition's ink is
drawn in its trace's colour at that colour's own alpha, and a signal crossing the same place
every acquisition settles *exactly* at the colour its trace would have drawn — the settlement is
a clamp, not a gain. `gamma` defaults to 1.0, because a cell's value is a count and half as many
sweeps must read as half the ink. A ceiling at the *fade's steady state* (`1/(1-0.5)=2`) was the
first default and is a legitimate alternative: it makes the newest sweep a fraction of the
settled brightness so the fade's ramp is visible, at the cost of a gain offset between the newest
sweep and the image behind it. The case that reads either one off the *screen* rather than the
grid is `a_phosphor_density_is_the_trace_in_its_own_colour`.
- **The scale is applied once, on the CPU.** `DensityConfig::full_scale` and `gamma` map a cell's
count to a luminance byte when the grid changed, and the shaders (`kDensityVS`/`kDensityFS`)
only tint that byte with the trace's colour. The one derivation is deliberate: a value a host
reads back through `DensityGrid::ink()`/`peak()` and the picture it sees come from the same
numbers.
- **Per frame, it is one texture upload and one draw call *per trace* — or one draw call each.**
The upload is one byte per cell (**179 kB** for the axes above, and at most 256 kB a trace at
`kDensityCellCap`, whatever the window); the one texture is re-filled per trace, and a frame whose
grids did not move skips the conversion and the uploads entirely and pays the quads alone
(`FrameStats::density_upload_bytes` reads 0 for it). The decay is what makes the grids move every
frame under `TimeConstant`, and what makes them stand still between acquisitions under
`PerAcquisition`.
A `TimeConstant` fade therefore does pay the conversion and the upload every frame, whatever the
bound — a *bounded* bill rather than a free one. Applying the fade on the GPU instead was
considered and is a **larger** change than this bound and was deliberately not bundled with it:
the image would have to live in a ping-pong target the decay multiplies in place, while the
*count* stays on the CPU, because that is what a host reads back through `ink()`/`peak()`, what
the deposit adds whole swaths to, and what the per-sweep dedupe keys on — so the two would have
to be reconciled per frame anyway. The bound is what makes that reconciliation a fixed cost.
`a_density_grid_is_the_axes_in_device_pixels` reads the resolution and the memory back
(**578x453 = 261 834 cells, 1 047 336 bytes** on its 640x480 viewport, which is past the cap, and
the 320x240 leg it ends on is the axes rect *exactly*, one cell a device pixel —
`ink=578 peak=1` for one flat sweep, the ink of one sweep *is* the columns it covers),
`a_density_grid_is_capped_whatever_the_window` reads the bound itself off a 3840x2160 viewport
(**681x384 = 261 504 cells = 1 046 016 bytes of a 3784x2138 axes**: the cells never exceed
`kDensityCellCap`, the grid is within 1 % of the whole budget and its aspect is the axes' — the
uniform scaling), and `a_capped_density_is_magnified_over_the_axes_rect` reads the *screen* side of
it (**569x460 = 261 740 cells of an 832x673 axes**: the image covers all **832** of the rect's
columns rather than the 569 cells it holds — the assertion a quad drawn at the grid's own size
fails — the sweep stays on the row its transform maps it to, and its ink is most of the trace's own
colour, `green=195` against a background of `65`, the rest being the magnification's blend),
`a_density_halves_per_acquisition_and_keeps_the_newest_brightest` reads the count schedule
(578 → 867 → 1012 on a 578-column grid, i.e. `1 + 1/2 + 1/4` of the width, `peak_steady=2.000`),
`a_density_fades_by_its_time_constant` reads `exp(-dt/tau)` off the ink,
`a_density_holds_one_grid_per_trace` reads two traces' grids apart (**261 834 cells each,
ink 578/578**, in two different rows), and
`a_phosphor_density_is_the_trace_in_its_own_colour` reads the frame: `cells=179349 uploaded=179349`
on the frame the grid moved and `uploaded=0` on the identical one after it; a saturated cell is the
trace's own colour exactly (`full=224`, the trace's `0xE0` green, against `bg=25`, where the old
near-white tint read ~190 red); one time constant dims it (`faded=115`); and a steady state
(`steady=224`) against a quarter of it (`quarter=138`) keeps the trace's hue, red near the
background's.
## One sample or a statistic of many
Every number the library reports is one the samples actually hold. What differs between
+12
View File
@@ -127,6 +127,18 @@ class RingBuffer {
/// pushing).
void clear() noexcept { produced_.store(0, std::memory_order_relaxed); }
/// Consumer thread only: discards every element and resumes the absolute count
/// at `absolute`, so the next push() lands there (not safe while a producer is
/// pushing).
///
/// This is for a *recompute*: a ring whose contents are derived from another is
/// emptied and refilled from a given absolute origin, and it must come back
/// numbered as its source is. clear() would restart it at 0 and leave every
/// absolute index -- base(), produced(), copy_from() -- off by that origin.
void clear_to(u64 absolute) noexcept {
produced_.store(absolute, std::memory_order_relaxed);
}
private:
u64 cap_;
u64 mask_;
+1
View File
@@ -54,6 +54,7 @@
#include <plot/plot/axis.hpp>
#include <plot/plot/crosshair.hpp>
#include <plot/plot/cursor.hpp>
#include <plot/plot/density.hpp>
#include <plot/plot/figure.hpp>
#include <plot/plot/grid.hpp>
#include <plot/plot/legend.hpp>
+10 -6
View File
@@ -102,12 +102,16 @@ struct Axis {
/// group stops following or the row leaves it.
bool live_owner = false;
f64 live_owner_newest = 0.0;
/// Set while this plot is a member of a `Figure` x link group (`Figure::link_x()`
/// writes it, `unlink_row()` clears it). The group owns the shared x window *and*
/// its follow state -- `Figure::link_x()` refuses a `Live` member joiner -- so
/// `Plot::set_x_follow(Live)` on this axis is refused after linking too, rather
/// than letting a later call put the member's axis back into a state the group
/// cannot drive (see `Plot::set_x_follow()`).
/// Set while this axis is a member of a `Figure` link group. On an x axis that is
/// an x link group (`Figure::link_x()` writes it, `unlink_row()` clears it): the
/// group owns the shared x window *and* its follow state -- `Figure::link_x()`
/// refuses a `Live` member joiner -- so `Plot::set_x_follow(Live)` on this axis is
/// refused after linking too, rather than letting a later call put the member's
/// axis back into a state the group cannot drive (see `Plot::set_x_follow()`).
/// On a y axis it is the row's shared-y group (`Figure::set_share_row_y()`): the
/// group, not the plot, owns the range *and* the auto scale state, so
/// `Plot::autoscale_axes()` skips the axis and the figure fits the union of the
/// members' traces instead.
bool link_member = false;
/// Ticks for the last `refresh()`, ascending, majors and (when asked) minors.
std::vector<Tick> ticks;
+19
View File
@@ -91,6 +91,14 @@ struct Crosshair {
/// On by default, and free to leave on: a plot with no visible trace has no rows to
/// measure and the panel is then not placed at all.
bool show_readout = true;
/// Write the two values the crosshair's lines meet the axes at as small badges in the
/// label strips -- the y value beside the y ticks, the x value beside the x ticks, each
/// in its axis' own format. On by default: it is the axis reading an oscilloscope puts
/// where the eye already is, and a host that does not want it turns it off
/// (`Plot::set_crosshair_axis_labels()`). The badges are placed only when the strip they
/// belong to exists and the line they belong to is drawn (a vertical-only crosshair has
/// no y to badge, and a row whose x strip went to a neighbour badges nothing).
bool axis_labels = true;
/// The data x of the vertical line, on x axis 0.
f64 x = 0.0;
/// The data y of the horizontal line, on y axis 0. Meaningless for a vertical-only
@@ -277,6 +285,17 @@ struct CrosshairLayout {
/// `Crosshair::show_readout` is off, or when the panel could not be placed inside the
/// axes at all.
Rect panel{};
/// The two axis badges, when `Crosshair::axis_labels` is on and the crosshair is active:
/// the y value in the **y label strip**, right-aligned on the axis line at the horizontal
/// line's height, and the x value in the **x label strip**, centred on the vertical
/// line's column -- each in its axis' own format, so a badge reads exactly like the tick
/// labels beside it. Zero-sized (and the text empty) when the flag is off, the crosshair
/// is inactive, the line they belong to is not drawn, their strip does not exist (a row
/// whose x strip went to the row below), or there is no text backend to measure them.
Rect y_axis_badge{};
std::string y_axis_text;
Rect x_axis_badge{};
std::string x_axis_text;
/// One row per visible trace, in trace order -- see `CrosshairRow`. Present whether or
/// not the panel could list them, so the markers are drawn even in a plot too small to
/// hold a panel.
+195
View File
@@ -0,0 +1,195 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <span>
#include <vector>
#include <plot/core/types.hpp>
namespace plot {
/// The most cells one density grid may hold: **262 144**, a mebibyte of `f32` counts.
///
/// A grid is the axes rect in device pixels -- the screen's resolution and not the data's, which is
/// the whole point of accumulating into a grid -- but the screen is not a fixed thing. A window can
/// be dragged to any width, and at 2x device scale the axes rects of a 1520x850-logical window are
/// 2877 device pixels wide each: four rows of one trace are **4.5 M cells**, 18 MB of counts
/// between them, and every one of them is walked per frame. That walk is `O(cells)`: the fade
/// multiplies every cell, a host's `ink()`/`peak()` read every cell, and a frame the fade moved
/// converts and uploads every cell as one byte. So a resolution tied to the window is a per-frame
/// cost tied to the window, with no ceiling -- measured headless on llvmpipe with four rows of one
/// trace, a 2 s window and a 3040x1700 framebuffer: **4.5 M cells, 12.9 ms of tick and 12.8 ms of
/// draw a frame, 4.9 MB uploaded**.
///
/// This is the ceiling, in **cells** rather than in pixels: the axes rect is scaled down, uniformly,
/// until it fits, so a cell stays square and the picture keeps the axes' aspect (see `DensityGrid`).
/// The number is a frame's budget read off that same measurement -- the same four rows at the cap
/// are 1.05 M cells and **3.9 ms of tick and 5.6 ms of draw**, a third and a half of the unbounded
/// grid's pass -- while the image is still 681 columns across on a 3784-pixel axes rect. It is a
/// **cap** and not a resolution: a grid whose axes rect is under it is still exactly that rect in
/// device pixels, and only an axes rect of more than a quarter of a megapixel -- a 4K window's, or
/// one row of a tall one -- is scaled.
inline constexpr usize kDensityCellCap = usize{1} << 18;
/// Which way a plot's **phosphor density** fades between the acquisitions drawn into it.
///
/// The two are answers to two different questions, which is why the choice belongs to the
/// caller rather than to a default: a bench scope's persistence is set by *time* -- "the trace
/// is still visible a second after it was drawn" -- while a sweep-to-sweep accumulation is set
/// by *count* -- "the tenth acquisition back is gone". The two agree only while the trigger
/// rate is constant, and a rate that varies between runs is exactly when a caller has to pick.
enum class DensityDecay {
/// `intensity *= exp(-dt / time_constant_s)` every time the grid is advanced
/// (`Plot::decay_density()`), so the image fades while you watch it whatever the trigger
/// rate -- a slow stream draws faint sweeps, a fast one bright ones, and both are still
/// legible a time constant later. A `time_constant_s <= 0` freezes the grid instead of
/// fading it.
TimeConstant,
/// `intensity *= per_acquisition` once per accumulated acquisition, applied by
/// `Plot::accumulate_density()` *before* the new acquisition is deposited, so the sweep just
/// drawn is the brightest and each older one is the factor down: the N-th back is gone
/// whatever the rate. It scales with the trigger rate by construction -- the record of the
/// last few sweeps is what the display holds -- which is what a caller wants when the
/// interesting comparison is between successive acquisitions and not between seconds.
PerAcquisition,
};
/// A plot's phosphor density: whether the grid is on, how it fades, and how a cell's count is
/// turned into brightness. Plain data, replaced wholesale by `Plot::set_density()`.
///
/// The image is **one grid per trace**, drawn in that trace's own colour (`Trace::color`), because
/// a density that had one hue for the whole plot could not say *which* trace put the ink there --
/// two signals agreeing in a cell would be one blended pixel, and a display of several traces
/// could not be read back to the trace it came from. A cell's brightness is a count of sweeps in
/// *that trace's* colour: `full_scale` (default one sweep, the trace's own brightness) is where it
/// saturates, and a signal crossing the same place every acquisition settles exactly at the colour
/// its trace would have drawn.
///
/// Its reason to exist as a *plot* feature rather than a host's overlay is the same one
/// `Plot::set_invalid_spans()` has: the plot is what knows a trace's device geometry -- the
/// visible window about to be drawn, on the axes rect the layout settled -- so the host would
/// otherwise have to rasterise traces the library has already staged, at a second derivation of
/// the same mapping that could disagree with the first.
struct DensityConfig {
/// Whether the grid is drawn *and* accumulated. Off is the default and costs nothing: no
/// grid is allocated, no frame uploads one, and the traces are drawn exactly as they were.
/// Turning it off drops what was accumulated --- a caller that has stopped paying is not
/// holding a stale image -- see `Plot::set_density()`.
bool enabled = false;
/// How the grid fades (see `DensityDecay`).
DensityDecay decay = DensityDecay::PerAcquisition;
/// `TimeConstant`'s constant, in **stream seconds** -- the same clock the x axis reads, so a
/// caller advancing the density by its frame's own `dt` gets a fade that does not depend on
/// the frame rate. `<= 0` freezes the grid.
f32 time_constant_s = 1.0f;
/// `PerAcquisition`'s factor, clamped to `[0, 1]` at use. 0.5 -- half per acquisition -- is
/// what a phosphor's afterglow looks like sweep to sweep: 1, 0.5, 0.25, 0.125 and by the
/// seventh the layer is under 1 %.
f32 per_acquisition = 0.5f;
/// The cell value that reads as **full brightness**, in accumulated sweeps -- and the one field
/// a caller reads *off its own trace* rather than off the fade. A cell at this value is drawn in
/// its trace's own colour at that colour's own alpha; the default of **one sweep** is therefore
/// the ink the newest acquisition leaves, so the acquisition just drawn reads exactly as the
/// trace it is a picture of (a signal crossing the same place every acquisition settles at the
/// trace's brightness, where a ceiling above one sweep would settle *brighter* than the trace and
/// leave a gain offset between the newest sweep and the image behind it). Everything above the
/// ceiling clamps to the same pixel: where several acquisitions agree, the cell is the trace's
/// colour at full strength and no brighter.
///
/// A caller that wants the fade's own ramp on screen instead -- the newest sweep a *fraction* of
/// the steady state, so the eye can rank the sweeps -- sets this to the sum the fade settles at
/// (for `PerAcquisition`'s factor `f` that is `1/(1-f)`) and accepts the gain offset; that was
/// this field's first default, and it is a legitimate choice, not the one a phosphor makes.
f32 full_scale = 1.0f;
/// The gamma applied to the normalised cell value before it becomes a luminance. **1.0 by
/// default: linear in sweeps**, because that is what a count means -- a cell covered by half the
/// acquisitions is half the ink. Under 1 lifts the faint end, and that is what
/// `Style::intensity_gamma` is for where the value is a *dwell fraction* rather than a count;
/// here it flattens the top instead (0.45 maps a quarter of the scale to 0.54 and the ceiling to
/// 1.0, which is most of the ramp spent on the last sweep). Over 1 darkens the faint end for a
/// caller that wants only the strong agreement visible.
f32 gamma = 1.0f;
};
/// The density's own image: **the axes rect in device pixels, up to `kDensityCellCap` cells**, so
/// its resolution is the screen's and not the data's -- the whole point of accumulating into a grid
/// rather than keeping the acquisitions that were drawn into it -- while still being bounded by
/// something other than the window a user has dragged. Under the cap the grid *is* the axes rect and
/// a cell is a device pixel; past it the rect is scaled down uniformly (see `kDensityCellCap` for
/// the number and the frame budget it comes from), and the renderer magnifies the image back over
/// the rect with a linear filter, because a cell is a count of sweeps and not a pixel of a picture.
///
/// The bound is what makes the memory and the per-frame cost *numbers* rather than functions of the
/// window: `columns() * rows() * 4` bytes whatever the trigger has done **and** whatever the
/// window is, at most **262 144 cells, 1 048 576 bytes** a grid -- against the 4.5 MB a trace a
/// 3040x1700 framebuffer's axes used to ask for, and the 26 ms of tick and draw a frame the pass
/// over four of them then cost. A plot holds **one grid per trace** (see `DensityConfig`), so a
/// display's memory is that figure times the traces actually drawn -- a plot's unbound slots hold no
/// grid -- and four traces hold at most 4 MiB between them. The renderer adds one byte per cell as
/// the luminance it uploads (`DensityGrid::cells()` is `f32` because a **count** is what decays and
/// what `ink()`/`peak()` report; the byte is derived per changed frame, not stored here).
///
/// A cell's value is a **number of sweeps**: every visible trace of the plot that covers that cell
/// during one `Plot::accumulate_density()` leaves 1.0 in it -- once, however many of that trace's
/// own samples crossed it -- so brightness means how many acquisitions were here, and two traces
/// agreeing in one cell is twice the ink. (What the *beam* did inside one column is a different
/// question, and the library answers it elsewhere: `Style::intensity_grading`.) Decay multiplies
/// the whole image, so a cell's value is a decaying count of sweeps and not a bounded score.
///
/// The grid is indexed row-major from the axes rect's **top-left** corner: cell `(c, r)` is at
/// `r * columns() + c` and covers the device span `[axes.x + c*w/columns(), axes.x + (c+1)*w/columns())`
/// by `[axes.y + r*h/rows(), axes.y + (r+1)*h/rows())` -- the device pixel `(axes.x + c, axes.y + r)`
/// while the rect is under the cap, and a block of them when the plot scaled the image down.
/// `revision()` moves on every change, which is what lets the renderer skip the upload -- and the
/// whole conversion -- on a frame the grid did not move.
class DensityGrid {
public:
/// Grid width in cells: the axes rect's width in device pixels, rounded up, scaled down when the
/// rect is past `kDensityCellCap` (see the class comment). 0 until the first accumulation on a
/// laid-out plot.
u32 columns() const noexcept { return columns_; }
/// Grid height in cells: the axes rect's height in device pixels, rounded up, scaled down with
/// `columns()`: `columns() * rows()` cells.
u32 rows() const noexcept { return rows_; }
usize cell_count() const noexcept {
return static_cast<usize>(columns_) * static_cast<usize>(rows_);
}
/// The cells, row-major from the top-left, one per cell of the axes rect -- see the class comment
/// for the resolution and what one cell's value means.
std::span<const f32> cells() const noexcept { return cells_; }
/// One cell, or 0.0 outside the grid.
f32 at(u32 column, u32 row) const noexcept {
if (column >= columns_ || row >= rows_) return 0.0f;
return cells_[static_cast<usize>(row) * columns_ + column];
}
/// The **total ink**: every cell's value summed. The reading that says how much has been
/// drawn into the grid at all -- it grows with each accumulation and falls with each decay,
/// and is 0 for a grid that is off or was just cleared.
f64 ink() const noexcept {
f64 total = 0.0;
for (const f32 v : cells_) total += static_cast<f64>(v);
return total;
}
/// The **peak**: the brightest cell's value, 0 for an empty grid. It is the reading that
/// says whether the accumulation is saturating (at or past `DensityConfig::full_scale`, which
/// reads as full brightness) and whether the decay has reached the sweep behind the newest.
f32 peak() const noexcept {
f32 top = 0.0f;
for (const f32 v : cells_) top = v > top ? v : top;
return top;
}
/// How many times the image changed: bumped by every accumulation, decay, clear and resize.
/// The renderer compares it with the revision it last uploaded, so a frame that changed
/// nothing uploads nothing.
u64 revision() const noexcept { return revision_; }
private:
friend class Plot;
std::vector<f32> cells_;
u32 columns_ = 0;
u32 rows_ = 0;
u64 revision_ = 0;
};
} // namespace plot
+129 -30
View File
@@ -27,13 +27,21 @@ struct FigureLayout {
/// strips and axes come from its own `Plot::layout()` against this rect, so a row
/// with a y title keeps its column and no two rows can disagree about chrome.
std::vector<Rect> rows;
/// The strip the figure's one shared x axis is drawn in: the bottom row's own
/// `PlotLayout::x_label_area`, at the bottom of the bottom row's rect and so as
/// wide as that row's axes rather than the whole figure. Read back from the row
/// One rect per plot, flattened row-major (row 0's cells left to right, then row 1's,
/// ...): the **viewport** each `Plot` was given. A row of one cell has exactly its row
/// rect here; a row of several tiles its rect left to right with `col_gap` between
/// cells, the cells' widths summing to the row's less the gaps.
std::vector<Rect> cells;
/// The strip the figure's bottom x axis is drawn in: the bottom row's own
/// `PlotLayout::x_label_area` (for a bottom row split into several cells, only the **last
/// cell's** strip -- the others' strips are their own `plot(i).layout().x_label_area`, which
/// is where the controller's axis-zoom hit test reads each cell's gestures from), at the
/// bottom of the bottom row's rect and so as wide as that row's axes rather than the whole
/// figure. Read back from the row
/// rather than derived here, so a host and the frame cannot disagree about where
/// the axis is. It is nonzero whenever the figure has rows and the bottom row
/// reserves a label strip -- including the case where every row labels itself, in
/// which case it is simply the bottom row's own strip, one of several.
/// reserves a label strip. A row above whose columns differ from the row below keeps
/// a strip of its own (see `Figure`), which is its plots' `x_label_area`, not this.
///
/// It is the rect the controller's axis-zoom hit test reads (`x_label_area` is what
/// a middle drag in a label strip enters `DragMode::AxisZoomX` from), which is why
@@ -44,6 +52,10 @@ struct FigureLayout {
/// figure edge to edge, which is the default; it is dropped for a figure too
/// short to hold it, so the rows never lose their height to a gap.
f32 row_gap = 0.0f;
/// The horizontal gap between two cells of a row, in device pixels. `set_col_gap()`'s
/// value, or 0 when any row holding several cells was too narrow to hold it and
/// dropped it, so the cells never lose their width to a gap.
f32 col_gap = 0.0f;
};
/// Several plots stacked in one column, sharing one x axis.
@@ -59,12 +71,15 @@ struct FigureLayout {
/// **The shared x axis.** Row heights are an equal division of the figure by
/// default, `set_row_weights()` gives a row more of it, and every row keeps a floor
/// height so chrome can never erase a plot. When `set_x_labels_only_on_bottom()` is
/// on -- the default -- the figure's one x axis is the **bottom row's own**: the rows
/// above reserve no x chrome at all, and the bottom row keeps its own label and title
/// strips, so those labels and that title are drawn once, from the bottom row's own
/// ticks and transform. That places the shared axis *inside* a row, where the
/// controller's axis-zoom hit test can reach it, instead of in a band outside every
/// row that no gesture could land in.
/// on -- the default -- the x axis is the **bottom row's own** for every run of rows
/// with the *same columns* (the same number of cells and the same normalised cell
/// weights): those rows above reserve no x chrome at all, and the bottom row of the run
/// keeps its own label and title strips, so those labels and that title are drawn once,
/// from its own ticks and transform. A row whose columns differ from the row below keeps
/// its own strip, drawn under each of its cells, because the axis below is at another
/// scale and width. The rule is structural, never a function of what a plot holds. That
/// places a shared axis *inside* a row, where the controller's axis-zoom hit test can
/// reach it, instead of in a band outside every row that no gesture could land in.
///
/// **The x link.** `plot(i).set_x_range()` and a controller gesture both reach a
/// member's x range the same way, so a figure cannot see either of them happen.
@@ -94,13 +109,30 @@ class Figure {
/// which is the *unlinked* group: rows that share nothing, each with the range
/// its own plot was given.
explicit Figure(u32 rows);
/// A figure of `cells_per_row.size()` rows, row `r` holding `cells_per_row[r]` plots side
/// by side (a count of 0 is clamped to 1). Every row of one cell makes this the same
/// figure as `Figure(rows)`. There is deliberately no `initializer_list` constructor, so
/// `Figure{3u}` keeps meaning three rows.
explicit Figure(std::span<const u32> cells_per_row);
u32 row_count() const noexcept { return static_cast<u32>(plots_.size()); }
/// One row's plot. A row that does not exist yields a default-constructed `Plot`
/// rather than reading out of bounds -- the rule every accessor on `Plot` follows
/// -- so a stale row can only ever affect itself.
Plot& plot(u32 row) noexcept;
const Plot& plot(u32 row) const noexcept;
/// The number of figure rows -- the bands stacked top to bottom -- which equals
/// `plot_count()` for a figure no row of which is split.
u32 row_count() const noexcept { return static_cast<u32>(cells_per_row_.size()); }
/// The number of plots, i.e. cells over all rows. `plot()`, `link_x()`, `x_link()` and
/// the hovered plot of `set_crosshair()` index this flattened (row-major) range.
u32 plot_count() const noexcept { return static_cast<u32>(plots_.size()); }
/// How many cells row `row` holds; 0 for a row that does not exist.
u32 cells_in_row(u32 row) const noexcept;
/// The flattened index of row `row`'s first cell; `plot_count()` for `row ==
/// row_count()` and for anything beyond.
u32 first_plot_of_row(u32 row) const noexcept;
/// The row that holds flattened plot `plot` (the last row for an index past the end).
u32 row_of_plot(u32 plot) const noexcept;
/// One plot, by flattened cell index. A plot that does not exist yields a
/// default-constructed `Plot` rather than reading out of bounds -- the rule every
/// accessor on `Plot` follows -- so a stale index can only ever affect itself.
Plot& plot(u32 index) noexcept;
const Plot& plot(u32 index) const noexcept;
// --- geometry ---------------------------------------------------------
@@ -119,6 +151,21 @@ class Figure {
void clear_row_weights() noexcept { row_weights_.clear(); }
bool has_row_weights() const noexcept { return !row_weights_.empty(); }
/// How much of row `row`'s width each of its cells gets: `weights[i]` is cell i's
/// share of what is left after every cell has its floor width (64 logical pixels,
/// capped at an equal share of a narrow row). Normalised. Refused, changing nothing,
/// when the row does not exist, the count is not the row's cell count, or a weight is
/// not a positive finite number. A row with no weights divides equally.
Status set_cell_weights(u32 row, std::span<const f32> weights);
/// Back to an equal division of row `row`; a row that does not exist is ignored.
void clear_cell_weights(u32 row) noexcept;
bool has_cell_weights(u32 row) const noexcept;
/// The horizontal gap left between two cells of a row, in device pixels (0.0 by
/// default). Negative values are clamped to 0, and a gap too wide for a row to hold is
/// dropped for that layout rather than squeezing the cells to nothing.
void set_col_gap(f32 device_px) noexcept { col_gap_ = device_px > 0.0f ? device_px : 0.0f; }
f32 col_gap() const noexcept { return col_gap_; }
/// The gap left between two rows, in device pixels (0.0 by default, so the rows
/// tile the figure). Negative values are clamped to 0, and a gap too large for the
/// figure to hold is dropped for that layout rather than squeezing the rows to
@@ -143,14 +190,17 @@ class Figure {
/// than row by row for the left gutter's reason: the rows' axes have to line up, and
/// they end on a common column only if they are told one width. See
/// `Plot::set_right_gutter()` for what it is for. Negative values are clamped to 0.
/// In a split row whose cells share one y (`set_share_row_y`) only the **last** cell
/// keeps it, taken off the row like the y gutter, so the gap between two plots of the
/// row is the column gap.
void set_right_gutter(f32 device_px) noexcept {
right_gutter_ = device_px > 0.0f ? device_px : 0.0f;
}
f32 right_gutter() const noexcept { return right_gutter_; }
/// Whether the shared x axis is the figure's: every row reserves no x label strip
/// and one shared axis is drawn under the last row (the default), or every row
/// draws its own labels and the figure draws nothing extra.
/// Whether rows with the same columns as the row below hand it their x axis (the
/// default; a row with different columns keeps its own), or every row draws its own
/// labels and the figure draws nothing extra.
void set_x_labels_only_on_bottom(bool on) noexcept { labels_bottom_only_ = on; }
bool x_labels_only_on_bottom() const noexcept { return labels_bottom_only_; }
@@ -195,17 +245,21 @@ class Figure {
/// is not a number; such a call therefore leaves every row's crosshair as it was.
void set_crosshair(f64 x, bool active);
/// Places the crosshair with the pointer's **row** and that row's local y (spec §17): the
/// shared x goes to every row -- vertical lines everywhere, as the plain overload does -- and
/// the horizontal line is drawn **only in `hovered_row`**, where its y is a value of *that*
/// row's own scale. It is the fix for the reported defect -- a figure showing no horizontal
/// Places the crosshair with the pointer's **plot** and that plot's local y (spec §17): the
/// shared x goes to every plot -- vertical lines everywhere, as the plain overload does -- and
/// the horizontal line is drawn in `hovered_row` -- the **flattened plot index**, not the
/// figure-row index; they differ when a row holds several cells -- where its y is a value of
/// *that* plot's own scale. **When the row is a split whose cells share one y
/// (`set_share_row_y`), the line is drawn in every cell of that row**: it is one value on one
/// scale, so stopping it at the divider would hide the number the neighbouring cell is read
/// against. Rows never share y with each other, so no other row gets a line. It is the fix for the reported defect -- a figure showing no horizontal
/// line at all -- without inventing a figure-wide y, which would mean a different value in
/// every row: the line appears where the pointer is, and no row ever shows another row's y.
///
/// `y_in_row` is a data coordinate on `hovered_row`'s y axis 0, exactly as `Crosshair::y`'s
/// two-coordinate placement is; a host reads it from the row's own y transform at the
/// pointer's pixel row. `hovered_row` outside `[0, row_count())` is treated as "no row under
/// the pointer": every row gets the vertical line and none gets a horizontal one, the same
/// pointer's pixel row. `hovered_row` outside `[0, plot_count())` is treated as "no plot under
/// the pointer": every plot gets the vertical line and none gets a horizontal one, the same
/// state the single-coordinate overload produces.
///
/// Refused, changing nothing, when `x` is not a number -- matching `set_crosshair(x, active)`
@@ -226,9 +280,10 @@ class Figure {
/// How many groups there are, excluding the unlinked group 0.
u32 x_link_count() const noexcept { return static_cast<u32>(links_.size()); }
/// Moves one row into a link group and applies that group's range to it at once,
/// so the row is on the group's window from this call rather than from the next
/// frame. `group` 0 takes the row back out of its group, leaving it with the range
/// Moves one plot into a link group and applies that group's range to it at once; `row`
/// is the **flattened plot index** (see `plot_count()`), which is the row index only for a
/// figure with one cell per row. The plot is on the group's window from this call rather
/// than from the next frame. `group` 0 takes the plot back out of its group, leaving it with the range
/// it holds now.
///
/// A group with no range yet -- one that no row and no `set_x_link_range()` has
@@ -242,7 +297,7 @@ class Figure {
/// per-row window. `XFollow::Off` is the group's to set
/// (`set_x_link_follow()`), not the axis'.
Status link_x(u32 row, u32 group);
/// The group a row is in, or 0 when it is in none.
/// The group the plot with flattened index `row` is in, or 0 when it is in none.
u32 x_link(u32 row) const noexcept;
/// Sets a group's shared range and pushes it to every member.
@@ -269,6 +324,22 @@ class Figure {
/// A group's follow state. `Off` for group 0 and for a group that does not exist.
XFollow x_link_follow(u32 group) const noexcept;
/// Makes the cells of every split row (two or more) share their y axis 0: one range
/// and one auto scale state per row, default off. A range set on any member -- a
/// wheel, a drag, `Plot::set_y_range()` -- becomes the row's and is pushed to all of
/// its cells at the next `layout()`; the row is auto or manual as one, and while it is
/// auto it fits the union of every cell's visible traces (a cell with none is ignored;
/// padding and log handling are the plot's own, via `Plot::fit_y_bounds()`). The group,
/// not the plot, owns auto scale: members' own autoscale is suppressed, because each
/// would fit only its own traces and the cells would fight over the range. Turning it
/// on or off forgets the groups' state; a row of one cell is never a group.
void set_share_row_y(bool on);
bool share_row_y() const noexcept { return share_row_y_; }
/// Whether figure row `row` is auto scaled as a group. True for a row that is not a
/// group -- fewer than two cells, sharing off, or a row that does not exist -- so
/// "auto" is also the answer when there is no shared state to be manual.
bool row_y_auto(u32 row) const noexcept;
private:
/// One link group: the range its members share, the scroll state behind that
/// range while it follows, the rows in it (ascending) and, in step with them, the
@@ -325,7 +396,35 @@ class Figure {
/// a following group's window, push both to all members.
void sync_x_links();
/// A split row's shared y axis: the range last pushed to its cells (the value the
/// next frame compares each cell against, so a gesture is told from the group's own
/// write -- the same device as `XLink::pushed`), whether the group has been
/// initialised from its first cell, and whether it is auto scaled.
struct RowY {
Range pushed{};
bool have = false;
bool auto_scale = true;
};
/// Advances every split row's y group for this frame: adopt what a cell was changed
/// to (a range or an auto flag), fit the union of the cells' traces while the group
/// is auto, push the result to all cells. Runs before any `Plot::layout()`, which is
/// where a plot would otherwise autoscale on its own.
void sync_row_y();
/// Fills the row bookkeeping (`cells_per_row_`, `row_first_`, `cell_weights_`) from a
/// per-row cell count, clamping each to at least one. Does not touch `plots_`, which the
/// constructors size from `total_cells()`.
void build_cells(std::span<const u32> cells);
std::vector<Plot> plots_;
std::vector<u32> cells_per_row_;
/// Prefix sums of `cells_per_row_`: `rows + 1` entries, `row_first_[r]` the flattened
/// index of row r's first cell and the last entry the plot count.
std::vector<u32> row_first_;
std::vector<std::vector<f32>> cell_weights_;
bool share_row_y_ = false;
std::vector<RowY> row_y_;
f32 col_gap_ = 0.0f;
/// What `plot()` yields for a row that does not exist, so a stale row can be used
/// without reading out of bounds. Owned rather than a static, so one figure's
/// stray row can never write into another's.
+166
View File
@@ -12,6 +12,7 @@
#include <plot/plot/axis.hpp>
#include <plot/plot/crosshair.hpp>
#include <plot/plot/cursor.hpp>
#include <plot/plot/density.hpp>
#include <plot/plot/grid.hpp>
#include <plot/plot/legend.hpp>
#include <plot/plot/measure.hpp>
@@ -39,6 +40,13 @@ namespace plot {
/// are for a host that wants one without the other.
enum class XStrip { Both, LabelsOnly, TitleOnly, None };
/// Which parts of the left y chrome `Plot::layout()` reserves. `Both` is a plot drawn on
/// its own. `None` reserves no label width, no title column and ignores the figure's common
/// gutter: it is the mode of every cell of a split row but the first when the figure shares
/// the row's y axis (`Figure::set_share_row_y`), where the first cell draws the one axis.
/// The cell's ticks still refresh and its grid still builds.
enum class YStrip { Both, None };
/// Whether a strip mode reserves the label strip. One rule, read by `Plot::layout()`
/// when it sizes the strip and by the renderer when it decides whether to stage the
/// labels -- a row that reserves none would otherwise stage them over its own data.
@@ -237,6 +245,12 @@ class Plot {
void set_x_strip_mode(XStrip mode) noexcept { x_strip_ = mode; }
XStrip x_strip_mode() const noexcept { return x_strip_; }
/// Which parts of the left y chrome `layout()` reserves; `YStrip::Both` by default. With
/// `YStrip::None` the y label width, the y title column and the figure's common gutter are
/// all zero, and the renderer stages no y labels or title (`y_label_area.w == 0`).
void set_y_strip_mode(YStrip mode) noexcept { y_strip_ = mode; }
YStrip y_strip_mode() const noexcept { return y_strip_; }
/// The font layout() measures tick labels with. Borrowed, not owned, and may be
/// null -- then the label areas fall back to PlotLayout's defaults and no string
/// is measured: a plot with no renderer lays out with whatever the host sets
@@ -434,6 +448,20 @@ class Plot {
/// does not exist. Not for callers outside the figure: a `Plot` that is not in a
/// figure never has a linked axis.
void set_x_link_member(u32 axis, bool linked) noexcept;
/// The y-side twin of `set_x_link_member()`: publishes that this y axis is a member
/// of its row's shared-y group (`Figure::set_share_row_y()`). A linked y axis is
/// never fitted by `layout()` -- the group owns its range and auto scale, and fits
/// the union of every member's traces -- but a gesture or `set_y_range()` still
/// writes it, which is how the group learns of a change. A no-op for an axis that
/// does not exist. Not for callers outside the figure.
void set_y_link_member(u32 axis, bool linked) noexcept;
/// Union of the bounds of the visible traces bound to y axis `axis`: what a shared-y
/// group collects from each member. False -- and `out` untouched -- when none.
bool y_data_bounds(u32 axis, Range& out) const;
/// Fits y axis `axis` to `bounds` the way `auto_fit_y()` fits it to its own traces
/// (same padding, same log handling), leaving `auto_scale` alone. False -- range
/// untouched -- when the axis does not exist or cannot be fitted to `bounds`.
bool fit_y_bounds(u32 axis, Range bounds);
/// Data -> pixels for one axis, built from the axis' range, log flag and the
/// current layout. Always invertible: a collapsed range or a collapsed axes
@@ -529,6 +557,14 @@ class Plot {
/// belongs to. An id that names no cursor is ignored, like every other setter here.
void set_cursor_color(u32 id, Color color);
/// A cursor's caption (see `Cursor::label`): a vertical marker's badge text -- a horizontal
/// ruler writes its own value and needs no caption to be readable. Rendered on a
/// backplate, and labels whose backplates meet are merged into one panel with one line per
/// label (see the renderer), which is what keeps a marker pair half a division apart
/// readable instead of a smear of two texts over one column. An empty string removes the
/// caption; an id that names no cursor is ignored, like every other setter here.
void set_cursor_label(u32 id, std::string label);
/// A cursor's stroke: its logical width (`<= 0` for the style's default) and its dash
/// (`dash.off <= 0` for solid). The instrument's own, like its colour -- see `Cursor::width`.
void set_cursor_stroke(u32 id, f32 width, Vec2 dash);
@@ -583,6 +619,85 @@ class Plot {
void set_invalid_spans(std::span<const InvalidSpan> spans);
std::span<const InvalidSpan> invalid_spans() const noexcept { return invalid_spans_; }
// --- phosphor density --------------------------------------------------
/// Turns this plot's **phosphor density** on or off, and states how it fades. Off -- the
/// default -- is the plot exactly as it was: no grid is allocated, no geometry is deposited
/// and no frame uploads anything.
///
/// On, the plot keeps an intensity grid behind each of its traces, in that trace's own colour
/// (see `DensityGrid` for the resolution, the bound on it and the per-cell meaning, and
/// `DensityConfig` for the per-trace colour and the memory the grids cost), and deposits the
/// acquisition on screen into it with `accumulate_density()`. While a density is on the
/// renderer draws **no solid trace** over it: the trace ink *is* the image, so the newest
/// acquisition is deposited like every other and fades with the rest -- a trace left drawn on
/// top of its own density could never fade, which is what a report of "the last acquisition
/// never fades" was. It is deliberately *not* a set of retained traces -- the library has the
/// trace geometry in hand, so a density costs one grid per trace whatever the trigger has done,
/// where retaining the acquisitions would cost a window copy each.
///
/// Like `set_invalid_spans()` it reserves **no layout space** and nothing on it can be picked:
/// it is drawn inside the axes it was sized to, and a pointer neither finds it nor is found by
/// it. A cell is a device pixel of that axes rect while the rect is under `kDensityCellCap` and
/// a block of them past it, which the renderer scales back up with a linear filter -- the image
/// reserves no space either way, so the bound changes its resolution and nothing about the
/// layout.
///
/// **Disabling drops what was accumulated at once**, not at the next acquisition: a caller who
/// has stopped paying for the display is not left holding a stale image of it, which is the
/// rule the app's own `set_persist_depth()` follows for the very same reason. Re-enabling
/// starts from an empty grid -- the image is sized to the screen, and a screen it has not been
/// sized against yet is a screen it has nothing to say about.
void set_density(const DensityConfig& config);
/// The configuration, as set.
const DensityConfig& density() const noexcept { return density_; }
/// The accumulated image **of a trace**, for a host that wants to draw it or read it back. There
/// is one per trace, in trace order, because each is drawn in that trace's own colour (see
/// `DensityConfig`); an out-of-range trace reads an empty (0x0) image. Empty until the first
/// `accumulate_density()` on a laid-out plot, and dropped by `set_density()` off.
const DensityGrid& density_grid(u32 trace) const noexcept {
return trace < density_grids_.size() ? density_grids_[trace] : empty_density_grid();
}
/// Deposits **the acquisition on screen** into each trace's grid: every visible trace of this
/// plot, the newest acquisition included, over the x window its own axis is showing, 1.0 into
/// every cell it covers -- **once per cell per sweep**, however many of that trace's own samples
/// crossed it, so a cell's value is how many traces have covered it since the last decay and not
/// how much the beam moved (see `DensityGrid`; the dwell question is
/// `Style::intensity_grading`'s). Called once per *acquisition*, which is the cadence the grids
/// count in: a caller that deposited every frame would add one sweep sixty times a second, and
/// the per-acquisition fade would have nothing to measure itself against. The renderer draws no
/// polyline for a trace whose density is on (see `set_density()`), so this deposit is the whole
/// of what the newest acquisition looks like -- and it fades with the rest.
///
/// The geometry is the frame's own. The visible x range is mapped onto sample indices through
/// the series' own x (`Series::neighbours()`), never the `dx()` grid, and never the whole
/// series -- exactly as `Renderer::draw_traces()` maps it -- and the gathered points are taken
/// to pixels through the transforms the plot's last `layout()` pinned. So this belongs *after*
/// a layout: a plot that has never been laid out has no axes rect to size the grid to and
/// deposits nothing.
///
/// The grid is sized to that axes rect, **the rect in device pixels up to `kDensityCellCap`
/// cells** (`DensityGrid`), and a grid of the wrong size is reallocated -- which drops what it
/// held, because the image is of the axes rect and a new rect is a new image. Under
/// `DensityDecay::PerAcquisition` the fade happens here, before the deposit, so the acquisition
/// just drawn is the brightest. A NaN sample is a gap: it deposits nothing and joins nothing,
/// the library-wide policy.
void accumulate_density();
/// Fades the grid by `dt_s` of **stream seconds** -- the clock the x axis reads, so a caller
/// advancing the display by its frame's own `dt` gets a fade that does not depend on the frame
/// rate. Only `DensityDecay::TimeConstant` fades here: `PerAcquisition`'s factor is applied by
/// `accumulate_density()`, because there is no acquisition in a frame that merely passed.
/// A non-positive `dt_s` and a non-positive `time_constant_s` are both no-ops, so a caller that
/// has not measured a frame yet cannot wipe the grid by accident.
void decay_density(f64 dt_s);
/// Empties the grid (and leaves the configuration alone). What a caller does when the data
/// under the image is replaced: the screen-relative image of another stream's sweeps says
/// nothing about this one.
void clear_density() noexcept;
/// The measurement for `id`. Re-evaluated only when a cursor moved, when the
/// measure was added, or when the series underneath it changed: a different
/// series, a longer one, or a sliding window whose sample count stayed the same
@@ -781,6 +896,14 @@ class Plot {
/// **not** part of `set_crosshair()`, so placing a hover cannot silently change it.
void set_crosshair_readout(bool on) noexcept { crosshair_.show_readout = on; }
/// Writes or clears the two **axis badges** (see `Crosshair::axis_labels`): the y value in
/// the y label strip and the x value in the x label strip, at the heights and columns the
/// crosshair's lines meet the axes at. Takes effect on the next `layout()`. **Not** part of
/// `set_crosshair()`, for the same reason the readout flag is not.
void set_crosshair_axis_labels(bool on) noexcept { crosshair_.axis_labels = on; }
/// The axis-badge flag, as set.
bool crosshair_axis_labels() const noexcept { return crosshair_.axis_labels; }
/// Overrides this plot's crosshair stroke and dash, in logical pixels, without touching the
/// global style every other plot draws with (spec §17): each field left negative in `s`
/// defers to the matching `Style::crosshair_*` metric, so `CrosshairStyle{}` (the default)
@@ -929,6 +1052,26 @@ class Plot {
/// 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();
/// Deposits one trace's visible window into its own density grid: the same index window
/// `Renderer::draw_traces()` maps -- `neighbours()` for the ends, the 64-spp gate for the LOD
/// level -- gathered into `density_points_`, mapped to pixels through the transforms of the
/// plot's last layout, and left with `deposit_density_segment()`. A trace with no series, no
/// visible window or non-positive `dx()` deposits nothing.
void deposit_density_trace(const Trace& trace, DensityGrid& grid);
/// Deposits one already-mapped segment of a trace polyline into `grid`: for every grid column
/// the segment crosses, the rows it covers in that column, +1 each. The columns it also *clips*:
/// a column whose slice of the segment lies entirely outside the axes is skipped, so a trace off
/// the top of the plot deposits nothing rather than a band along the edge, and a segment with
/// both ends past an edge keeps the part that is inside. A degenerate segment (`x0 == x1`, or a
/// non-finite coordinate) fills the single column it is in.
void deposit_density_segment(f32 x0, f32 y0, f32 x1, f32 y1, DensityGrid& grid) noexcept;
/// Grows `density_grids_` -- one grid per trace -- to the last layout's axes rect in device
/// pixels, clearing a grid when the size moved (a *different* screen is a different image), and
/// returns whether any grid is usable. The one place the grids are allocated.
bool size_density_grids();
/// The image an out-of-range trace reads (see `density_grid()`): a shared empty grid, so the
/// accessor need not hand back a reference into nothing.
static const DensityGrid& empty_density_grid() noexcept;
/// Exact statistics of `region` over `series`, or an invalid result when the
/// interval covers no sample. The one place the region arithmetic lives.
static RegionStats evaluate_region_stats(const Region& region, const data::Series& series);
@@ -960,6 +1103,8 @@ class Plot {
std::string x_note_;
/// Which parts of the bottom x chrome are reserved at all; see set_x_strip_mode().
XStrip x_strip_ = XStrip::Both;
/// Which parts of the left y chrome are reserved at all; see set_y_strip_mode().
YStrip y_strip_ = YStrip::Both;
/// The floor a figure imposes on the left gutter; see set_min_y_gutter(). 0.0 means
/// none, which is what a plot standing on its own keeps.
f32 min_y_gutter_ = 0.0f;
@@ -977,6 +1122,27 @@ class Plot {
std::vector<Annotation> annotations_;
std::vector<Region> regions_;
std::vector<InvalidSpan> invalid_spans_;
/// The phosphor density: its configuration, the grids it accumulates into, and the scratch the
/// one visible window being deposited is gathered into. **One grid per trace** -- the image is of
/// the axes rect but each trace's is tinted with that trace's own colour (see `DensityConfig`), so
/// the memory is `traces * columns * rows * 4` bytes and not one plot's worth -- and each is
/// bounded by `kDensityCellCap`. The vector tracks `traces_` and outlives the frames its images
/// were drawn in, which is the whole point of accumulating into an image rather than retaining
/// traces.
DensityConfig density_{};
std::vector<DensityGrid> density_grids_;
/// Scratch for `deposit_density_trace()`: interleaved (x, y) of one gathered window, re-used
/// across traces and frames so a steady-state accumulation allocates nothing. Bounded by the
/// window, not the series, as `Renderer::gather_` is.
std::vector<f32> density_points_;
/// The per-sweep dedupe's stamps, one per grid **row** and shared by every trace's deposit: the
/// key of the last (trace, column) to deposit at that row, so one acquisition's own samples
/// cannot count a cell twice (see `deposit_density_segment()`). One `u64` a row -- a few kB at
/// any window -- and not one per cell, which would double a grid's memory for a comparison.
std::vector<u64> density_stamp_;
/// The sweep's own number, bumped once per trace deposited: the high half of every stamp key, so
/// every trace of a plot, and every acquisition, deposits exactly once per cell it covers.
u32 density_trace_ = 0;
/// The (region, trace) statistics cache, filled on demand by `region_stats()`
/// and so mutable: a lookup on a const Plot may cache, exactly as `measures_`
/// does. Entries are created for the pairs a caller actually asks about, never
+33
View File
@@ -84,4 +84,37 @@ class TextProgram {
gl::Program program_;
};
/// Linked phosphor-density program and its uniform surface: one textured quad over a plot's
/// axes rect. It fails exactly the way the two above do, propagating `Unsupported` and
/// `GLFailure`.
///
/// It is the one pipeline with **no vertex data**: the quad's four corners come from
/// `gl_VertexID`, so a draw is one four-vertex strip whatever the grid's size and there is no
/// buffer to stage or upload. The density itself reaches the GPU as a texture -- one texel per
/// cell -- which is the one thing this pipeline has that the others do not.
///
/// `create()` also points the `u_density` sampler at texture unit 0, which is the unit
/// `plot::gl::Texture::bind()` defaults to, and leaves this program bound. The texture stays
/// with the caller, who owns its lifetime.
class DensityProgram {
public:
bool create(Status* st = nullptr) noexcept;
bool valid() const noexcept { return program_.id() != 0; }
void bind() const noexcept { program_.use(); }
/// `u_viewport`: render target size in pixels.
void set_viewport(Vec2 px) const noexcept;
/// `u_origin` and `u_size`: the quad in `Rect` pixels -- the axes rect's top-left corner and its
/// size, which is the area the image is drawn over (its own grid may hold fewer cells than the
/// rect has pixels: see `kDensityCellCap`). They are two uniforms rather than a rect because a
/// `vec4` uniform would need a fourth setter shape for one caller; a corner and a size are what
/// the vertex shader builds the quad from anyway.
void set_rect(Vec2 origin, Vec2 size) const noexcept;
/// `u_color`: the tint the cells' luminances are multiplied into.
void set_color(Color color) const noexcept;
private:
gl::Program program_;
};
} // namespace plot::render
+109 -17
View File
@@ -48,6 +48,18 @@ struct FrameStats {
u32 staged_segments = 0; ///< line segments staged, all colour groups
u32 staged_markers = 0; ///< marker shapes staged over the traces
u32 draw_calls = 0; ///< draw calls issued, line and text
/// The phosphor density's own cells, when one was drawn: the **sum** of every trace's
/// `Plot::density_grid()` resolution, zero for a plot with the density off. It is what the
/// *frame* drew, so a host reading the density's cost off a frame reads it here rather than off
/// the grids, which say what is accumulated and not what was spent -- and it is the number that
/// scales with the trace count, since the image is one grid per trace (see `DensityConfig`).
u32 density_cells = 0;
/// The bytes of that image the frame **uploaded**: `columns * rows` when the grid had changed
/// since the last frame and 0 when it had not. It is the one field that separates the two
/// costs of a density -- the conversion and upload on the frame that moved, the single draw
/// call on the frames that did not -- which is exactly the pair a host comparing frame times
/// with the display on and off needs to tell apart.
u32 density_upload_bytes = 0;
};
/// Draws a `Plot` into whatever framebuffer is bound.
@@ -83,6 +95,24 @@ struct FrameStats {
/// a guard -- and the frame reports it (see `last_frame_had_gl_error()`).
///
/// Not thread-safe, and confined to the thread that owns the current context.
/// One cursor label's measured text and placed badge: what `Renderer::collect_cursor_label()`
/// builds and `Renderer::draw_clustered_cursor_labels()` places and draws. A rendering
/// detail of the cursor labels' backplates, declared at namespace scope only because a
/// private nested type cannot be named in an out-of-class member definition's signature.
struct CursorLabel {
std::string text; ///< the one line the backplate carries
Color border{}; ///< the panel's border: the cursor's own colour
f32 anchor = 0.0f; ///< the coordinate the label belongs to (x for a marker, y for a ruler)
f32 w = 0.0f; ///< the measured text width
bool horizontal = false;
/// The placed pen and backplate: centred on the anchor across the mark, nudged onto a
/// free row when a close neighbour's badge is in the way, and clustered into one panel
/// by `draw_clustered_cursor_labels()`.
Vec2 pen{};
Rect box{};
};
class Renderer {
public:
Renderer() noexcept = default;
@@ -93,8 +123,8 @@ class Renderer {
Renderer(const Renderer&) = delete;
Renderer& operator=(const Renderer&) = delete;
/// Destroys every GL object this renderer created: both programs, both vertex
/// arrays and their instance buffers, and the glyph atlas texture. The
/// Destroys every GL object this renderer created: all three programs, the vertex arrays and
/// their instance buffers, the glyph atlas texture and the density's own texture. The
/// renderer is left uninitialised (`ready() == false`) and `init()` can be
/// called again on the same object.
///
@@ -226,12 +256,13 @@ class Renderer {
/// exactly as a stand-alone plot is, and `draw(Plot&)` itself is unchanged for
/// single-plot hosts.
/// The figure's one shared x axis is the **bottom row's own**, so it is drawn by
/// that row's normal path: when the figure says labels belong on the bottom only
/// (the default), the rows above reserve no x chrome and the bottom row stages its
/// own labels and title once, inside its own strip -- the rect
/// `FigureLayout::x_axis_strip` reads back. With that switched off, every row keeps
/// its own chrome and each draws its own labels; there is then no shared axis.
/// A shared x axis is the **bottom row's own** (of a run of rows with the same
/// columns), so it is drawn by that row's normal path: when the figure says labels
/// belong on the bottom only (the default), the rows above that match the row below
/// reserve no x chrome and the bottom row stages its own labels and title once,
/// inside its own strip -- the rect `FigureLayout::x_axis_strip` reads back. A row
/// whose columns differ keeps and draws its own; with that switched off, every row
/// does and there is no shared axis.
/// Styling, the text backend and `ui_scale` are installed on **every** row before
/// the figure computes its layout, so the strips the rows measure and the strokes
@@ -513,6 +544,27 @@ class Renderer {
void draw_plot(Plot& plot, const PlotLayout& layout);
void draw_grid(const Plot& plot);
/// The plot's **phosphor density** (`Plot::set_density()`), when one is on: one textured quad per
/// trace over the axes rect, each in **that trace's own colour** -- a saturated cell is the trace
/// at its own brightness -- drawn over the grid and the invalid-span bands. The traces themselves
/// are not drawn while it is on (`draw_traces()` returns), so this is the whole of the data ink
/// and the newest acquisition is inside it.
///
/// Each trace's image reaches the GPU as an `GL_R8` texture, one texel per cell, converted here
/// from the grid's counts -- the scale and the gamma are the plot's and this stage derives
/// neither, so the numbers a host reads back and the picture it sees are one derivation. The
/// texture is magnified over the axes rect with a linear filter, because past `kDensityCellCap` a
/// cell is several device pixels across. A frame whose grids did not change skips the conversion
/// *and* the uploads and costs the draw calls, which `FrameStats::density_upload_bytes` says per
/// frame.
void draw_density(const Plot& plot, const PlotLayout& layout);
/// One grid's quad: fills the shared `GL_R8` texture from `grid` (skipping when its revision and
/// address are the ones already uploaded) and draws it over the axes rect in `tint` -- the rect,
/// and not the grid's own cell count, so a capped grid is scaled back over the area it is an
/// image of. The one place the density conversion lives, shared by every trace of
/// `draw_density()`.
void draw_density_grid(const DensityConfig& config, const DensityGrid& grid, Color tint,
const PlotLayout& layout);
/// `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
@@ -587,13 +639,22 @@ class Renderer {
/// the clip, and a range maximum that undershoots the edge by a ulp is not mistaken for
/// off the plot.
void draw_cursors(const Plot& plot, const PlotLayout& layout);
/// One cursor's label: the caller's caption for either orientation, and for a horizontal
/// ruler the value itself, written in its y axis' own tick format (`Axis::format_value()`)
/// -- the number a ruler exists to be read off. A vertical cursor's caption starts at the
/// top of the axes beside its marker; a ruler's text starts at the axes' left edge on its
/// own row, with the caption first on that line when there is one.
void draw_cursor_label(const Plot& plot, const PlotLayout& layout, const Cursor& cursor,
f32 at);
/// One cursor's label, measured rather than drawn: a horizontal ruler's line is the value
/// itself, written in its y axis' own tick format (`Axis::format_value()`) -- the number a
/// ruler exists to be read off -- with the caption first on the line when there is one; a
/// vertical cursor's line is its caption, beside the marker at the top of the axes. The
/// label is then placed (centred on its mark, stacked clear of close neighbours) and drawn
/// clustered (`draw_clustered_cursor_labels()`), which is what keeps a pair of readout
/// marks half a division apart readable: two texts over one column is a smear, one panel
/// with two lines is a readout.
void collect_cursor_label(const Plot& plot, const PlotLayout& layout, const Cursor& cursor,
f32 at, std::vector<CursorLabel>& labels);
/// Draws the placed labels' backplates -- one panel per cluster of meeting boxes, every
/// line at its own placed pen -- and stages the lines. Clustering is what keeps a pair of
/// readout marks half a division apart readable: two texts over one column is a smear, one
/// panel with two lines is a readout.
void draw_clustered_cursor_labels(const PlotLayout& layout,
const std::vector<CursorLabel>& labels);
/// The regions: the fill, one bound per edge, the optional label. Every rect comes
/// from `PlotLayout::regions` -- the same geometry the controller hit-tests -- so
/// a region is drawn where it can be grabbed, and neither side derives the other's
@@ -622,8 +683,12 @@ class Renderer {
/// outermost pixels the fill covers (the crisp-hairline rule -- a border drawn on the measured
/// rect's fractional edges is lit half-heartedly, and a frame bright on three sides and dim on the
/// fourth reads as a mistake). One helper, two callers: the border's colour is the only thing a
/// legend panel and a readout panel do not share.
void draw_readout_panel(const Rect& panel, Color border);
/// legend panel and a readout panel do not share. `background_alpha` scales the frame's own
/// background under the border: the legend and the crosshair's readout keep the shared
/// translucent backplate (what they cover stays faintly visible), while a *badge* -- a
/// cursor's value box, the crosshair's axis badges -- is read against whatever it sits on
/// (ticks, a noisy trace) and is drawn opaque.
void draw_readout_panel(const Rect& panel, Color border, float background_alpha);
/// One trace's colour swatch in `box`, opaque whatever the trace's own alpha is: a swatch drawn at
/// a trace's 15% would be the least legible thing in the frame, and the swatch is the one element
/// that says which colour an entry or a readout row *is*. The legend's entries and the crosshair's
@@ -653,6 +718,12 @@ class Renderer {
/// Drawn after every other interior pass -- the legend included -- because it is what the
/// 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);
/// The crosshair's **axis badges** (`CrosshairLayout::y_axis_badge`/`x_axis_badge`): the
/// values the two lines meet the axes at, each on the shared readout backplate with its
/// border in the crosshair's colour, drawn over the tick labels they sit among. Called
/// from `draw_plot()` after the label strips are drawn, which is the only order in which a
/// badge in a strip can sit on the ticks and still be read.
void draw_crosshair_axis_badges(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
@@ -679,6 +750,27 @@ class Renderer {
render::TextAtlas atlas_;
render::LineProgram line_program_;
render::TextProgram text_program_;
/// The phosphor density's pipeline, its one attribute-less vertex array and the texture a grid is
/// uploaded into. The texture is `GL_R8`, one texel per cell of the plot's grid, and is re-filled
/// for each trace in turn -- a trace's grid is drawn before the next trace's replaces it -- so a
/// row of several traces still costs the one texture. It is re-created when the grid's size moves
/// (a resized plot is a differently sized image, and the plot has already dropped what the old one
/// held).
render::DensityProgram density_program_;
gl::VertexArray density_vao_;
gl::Texture density_texture_;
/// The luminance image as uploaded, re-used across frames so a steady-state frame reallocates
/// nothing: one byte per cell.
std::vector<u8> density_pixels_;
u32 density_w_ = 0;
u32 density_h_ = 0;
/// The grid the texture currently holds, by address, and its revision. Compared per frame and per
/// trace, so a frame whose grids did not move skips the conversion *and* the upload -- the pause
/// in a persistence display. The address is part of the key because the texture is shared: two
/// traces of one plot have two grids with two revisions, and without the address a skip would
/// draw one trace's image under another's colour.
const DensityGrid* density_uploaded_grid_ = nullptr;
u64 density_uploaded_revision_ = 0;
render::LineVertexArray line_vao_;
render::TextVertexArray text_vao_;
+22
View File
@@ -68,4 +68,26 @@ extern const char* const kLineFS;
extern const char* const kTextVS;
extern const char* const kTextFS;
/// GLSL source of the phosphor-density pipeline: one textured quad over the axes rect.
///
/// The vertex shader builds the quad from `gl_VertexID` -- a four-vertex `GL_TRIANGLE_STRIP`
/// like the other two pipelines, and the only one with **no vertex attributes at all**: the
/// image is one rectangle whose corners are uniform state, so there is no geometry to stage and
/// nothing to upload (the density's own bytes go to a texture, not to a buffer).
///
/// Uniforms: `u_viewport` (vec2, pixels), `u_origin` (vec2, the quad's top-left corner in `Rect`
/// pixels), `u_size` (vec2, its size in pixels -- the axes rect the image is drawn over, which the
/// texture's cells are spread across: one texel a cell while the rect is under `kDensityCellCap`,
/// and a filtered magnification past it), `u_color` (vec4), `u_density` (sampler2D, unit 0).
///
/// The fragment shader multiplies the sampled single-channel image into `u_color`'s alpha, which
/// is the whole of the brightness mapping: a cell's count was turned into a luminance byte on the
/// CPU (`Plot`), where the count, the decay and the gamma live, so this stage has no scale of its
/// own to get out of step with the numbers a host reads back.
///
/// `Rect` is top-left origin while GL is bottom-left, so the y axis is flipped exactly once,
/// here, as in the line pipeline.
extern const char* const kDensityVS;
extern const char* const kDensityFS;
} // namespace plot::render
+338 -38
View File
@@ -23,6 +23,88 @@ namespace {
/// wants a taller floor gives the figure a taller viewport, or gives the rows weights.
constexpr f32 kMinRowHeightLogical = 24.0f;
/// The width a cell never drops below, in logical pixels, converted exactly as the row
/// height floor is. It yields to the row -- capped at an equal share of the width there
/// is -- so a row too narrow for its cells still tiles instead of overflowing.
constexpr f32 kMinCellWidthLogical = 64.0f;
/// The number of plots a per-row cell count asks for, each row holding at least one.
u32 total_cells(std::span<const u32> cells) noexcept {
u32 total = 0;
for (const u32 c : cells) total += std::max<u32>(c, 1u);
return total;
}
/// Splits the row rect `r` into `n` cells in `out[0..n)` and returns the gap actually used.
/// The cells tile `r` exactly: the gap is dropped when `r` cannot hold it, every cell first
/// gets the floor width and then its weight's share of what is left, and the last cell
/// closes the row on its right edge, absorbing the rounding of the ones before it.
f32 place_cells(const Rect& r, u32 n, const std::vector<f32>& weights, f32 col_gap,
f32 ui_scale, Rect* out) {
if (n == 1u) {
out[0] = r;
return col_gap;
}
f32 gap = col_gap;
f32 usable = r.w - gap * static_cast<f32>(n - 1u);
if (usable <= 0.0f) {
gap = 0.0f;
usable = r.w;
}
usable = std::max(0.0f, usable);
const f32 floor_device = ui_scale > 0.0f ? kMinCellWidthLogical * ui_scale
: kMinCellWidthLogical;
const f32 floor_px = std::min(floor_device, usable / static_cast<f32>(n));
const f32 extra = usable - floor_px * static_cast<f32>(n);
f32 total = 0.0f;
const bool weighted = weights.size() == n;
if (weighted) {
for (const f32 w : weights) total += w;
}
f32 x = r.x;
f32 consumed = 0.0f;
for (u32 i = 0; i < n; ++i) {
const f32 share = weighted && total > 0.0f ? weights[i] / total : 1.0f / static_cast<f32>(n);
const f32 end = i + 1u == n ? usable : consumed + floor_px + extra * share;
out[i] = Rect{x, r.y, end - consumed, r.h};
consumed = end;
x = out[i].right() + gap;
}
return gap;
}
/// The share of the row each of its `n` cells is given by `weights`, exactly as `place_cells`
/// reads them: an empty (or wrongly sized, or non-positive-total) vector means equal shares.
std::vector<f32> normalised_shares(const std::vector<f32>& weights, u32 n) {
std::vector<f32> shares(n, 1.0f / static_cast<f32>(n));
f32 total = 0.0f;
if (weights.size() == n) {
for (const f32 w : weights) total += w;
}
if (total > 0.0f) {
for (u32 i = 0; i < n; ++i) shares[i] = weights[i] / total;
}
return shares;
}
/// Whether two rows have the **same columns**: the same number of cells and the same
/// normalised cell weights (within 1e-4). The rows are all the figure's width, so such rows
/// get cells with identical x extents -- the same y gutter, gap, floors and right gutter
/// apply to both -- and one row's x axis serves the other. Structural, never content: it
/// reads the cell counts and the weights the host set and nothing a plot holds.
bool same_columns(const std::vector<u32>& cells_per_row,
const std::vector<std::vector<f32>>& cell_weights, u32 row_a, u32 row_b) {
const u32 n = cells_per_row[row_a];
if (n != cells_per_row[row_b]) return false;
if (n == 1u) return true;
const std::vector<f32> a = normalised_shares(cell_weights[row_a], n);
const std::vector<f32> b = normalised_shares(cell_weights[row_b], n);
for (u32 i = 0; i < n; ++i) {
if (std::fabs(a[i] - b[i]) > 1e-4f) return false;
}
return true;
}
/// A coordinate in the shared axis' **axis space**: `log10` when the figure's one
/// x axis -- the bottom row's, the row the shared strip belongs to -- is
/// logarithmic, the value itself otherwise. A following group's span and offset
@@ -41,18 +123,72 @@ f64 from_link_space(f64 v, bool log) noexcept {
} // namespace
Figure::Figure(u32 rows) : plots_(rows) {}
Plot& Figure::plot(u32 row) noexcept {
return row < plots_.size() ? plots_[row] : fallback_;
Figure::Figure(u32 rows) : plots_(rows) {
const std::vector<u32> ones(rows, 1u);
build_cells(std::span<const u32>(ones));
}
const Plot& Figure::plot(u32 row) const noexcept {
return row < plots_.size() ? plots_[row] : fallback_;
Figure::Figure(std::span<const u32> cells_per_row) : plots_(total_cells(cells_per_row)) {
build_cells(cells_per_row);
}
void Figure::build_cells(std::span<const u32> cells) {
cells_per_row_.assign(cells.begin(), cells.end());
for (u32& c : cells_per_row_) c = std::max<u32>(c, 1u);
row_first_.assign(cells_per_row_.size() + 1u, 0u);
for (std::size_t r = 0; r < cells_per_row_.size(); ++r) {
row_first_[r + 1u] = row_first_[r] + cells_per_row_[r];
}
cell_weights_.assign(cells_per_row_.size(), {});
}
u32 Figure::cells_in_row(u32 row) const noexcept {
return row < cells_per_row_.size() ? cells_per_row_[row] : 0u;
}
u32 Figure::first_plot_of_row(u32 row) const noexcept {
return row < cells_per_row_.size() ? row_first_[row] : plot_count();
}
u32 Figure::row_of_plot(u32 plot) const noexcept {
u32 r = 0;
while (r + 1u < cells_per_row_.size() && row_first_[r + 1u] <= plot) ++r;
return r;
}
Plot& Figure::plot(u32 index) noexcept {
return index < plots_.size() ? plots_[index] : fallback_;
}
const Plot& Figure::plot(u32 index) const noexcept {
return index < plots_.size() ? plots_[index] : fallback_;
}
Status Figure::set_cell_weights(u32 row, std::span<const f32> weights) {
if (row >= cells_per_row_.size() || weights.size() != cells_per_row_[row]) {
return Status{ErrorCode::InvalidArgument, "cell weights must name every cell of an existing row"};
}
for (const f32 w : weights) {
if (!std::isfinite(w) || !(w > 0.0f)) {
return Status{ErrorCode::InvalidArgument,
"cell weights must be positive finite numbers: a weight is a share of "
"the row, and a cell with none of it has no rect"};
}
}
cell_weights_[row].assign(weights.begin(), weights.end());
return Status{};
}
void Figure::clear_cell_weights(u32 row) noexcept {
if (row < cell_weights_.size()) cell_weights_[row].clear();
}
bool Figure::has_cell_weights(u32 row) const noexcept {
return row < cell_weights_.size() && !cell_weights_[row].empty();
}
Status Figure::set_row_weights(std::span<const f32> weights) {
if (weights.size() != plots_.size()) {
if (weights.size() != cells_per_row_.size()) {
return Status{ErrorCode::InvalidArgument, "row weights must name every row"};
}
f32 total = 0.0f;
@@ -330,6 +466,81 @@ bool Figure::adopt_member_changes(XLink& group) {
return false;
}
void Figure::set_share_row_y(bool on) {
share_row_y_ = on;
row_y_.clear();
for (u32 row = 0; row < cells_per_row_.size(); ++row) {
if (cells_per_row_[row] < 2u) continue;
for (u32 i = row_first_[row]; i < row_first_[row] + cells_per_row_[row]; ++i) {
plots_[i].set_y_link_member(0u, on);
}
}
}
bool Figure::row_y_auto(u32 row) const noexcept {
return row < row_y_.size() && row_y_[row].have ? row_y_[row].auto_scale : true;
}
void Figure::sync_row_y() {
if (!share_row_y_) return;
row_y_.resize(cells_per_row_.size());
for (u32 row = 0; row < cells_per_row_.size(); ++row) {
const u32 n = cells_per_row_[row];
if (n < 2u) continue;
RowY& g = row_y_[row];
const u32 first = row_first_[row];
// Axes added after set_share_row_y() are covered here.
for (u32 i = first; i < first + n; ++i) plots_[i].set_y_link_member(0u, true);
if (!g.have) {
g.pushed = plots_[first].y_axis(0u).range;
g.auto_scale = plots_[first].y_axis(0u).auto_scale;
}
// What happened since the last push. A member's auto flag that differs from the group's
// is a request (set_y_range clears it, auto_fit_y / set_y_auto_scale(true) sets it);
// otherwise a member whose range differs from the pushed one was moved by a gesture.
bool want_auto = g.auto_scale;
Range target = g.pushed;
bool flag_request = false;
for (u32 i = first; i < first + n && !flag_request; ++i) {
const Axis& a = plots_[i].y_axis(0u);
if (a.auto_scale != g.auto_scale) {
want_auto = a.auto_scale;
if (!want_auto) target = a.range; // auto -> manual: the gesture's range
flag_request = true;
}
}
if (!flag_request && !want_auto) {
for (u32 i = first; i < first + n; ++i) {
const Range r = plots_[i].y_axis(0u).range;
if (r.min != g.pushed.min || r.max != g.pushed.max) {
target = r;
break;
}
}
}
if (want_auto) {
Range bounds{};
bool any = false;
for (u32 i = first; i < first + n; ++i) {
Range b{};
if (!plots_[i].y_data_bounds(0u, b)) continue;
bounds = any ? Range{std::min(bounds.min, b.min), std::max(bounds.max, b.max)} : b;
any = true;
}
// The first cell stands in for the row: all cells are assumed to share one y axis kind/padding (the host builds them identically).
if (any && plots_[first].fit_y_bounds(0u, bounds)) target = plots_[first].y_axis(0u).range;
// else: nothing to fit -- keep the range the group had
}
for (u32 i = first; i < first + n; ++i) {
plots_[i].set_y_range(0u, target); // clears the flag ...
plots_[i].set_y_auto_scale(0u, want_auto); // ... so restate it: every member mirrors the group
}
g.pushed = target;
g.auto_scale = want_auto;
g.have = true;
}
}
void Figure::sync_x_links() {
for (XLink& group : links_) {
if (group.rows.empty()) continue;
@@ -369,6 +580,8 @@ void Figure::sync_x_links() {
const FigureLayout& Figure::layout() {
const u32 rows = row_count();
layout_.rows.assign(rows, Rect{});
layout_.cells.clear();
layout_.col_gap = 0.0f;
layout_.row_gap = row_gap_;
layout_.x_axis_strip = Rect{};
if (rows == 0u) {
@@ -377,12 +590,13 @@ const FigureLayout& Figure::layout() {
}
const f32 figure_h = std::max(0.0f, viewport_.h);
// The rows tile the figure itself: the shared x axis is the *bottom row's own*, so
// nothing is taken off the figure for it. That is what keeps the axis reachable --
// its strip is a real `PlotLayout::x_label_area` inside the bottom row's rect, which
// is the rect the controller's axis-zoom hit test reads and the strip an x title is
// drawn in -- and it is why an interior row reserves no x chrome at all instead of
// leaving a band no one owns.
// The rows tile the figure itself: an x axis is a row's *own* strip (the bottom row's, and
// the strip of any row whose columns differ from the row below), so nothing is taken off
// the figure for it. That is what keeps the axis reachable -- its strip is a real
// `PlotLayout::x_label_area` inside a row's rect, which is the rect the controller's
// axis-zoom hit test reads and the strip an x title is drawn in -- and it is why a row
// that hands its axis to the one below reserves no x chrome at all instead of leaving a
// band no one owns.
const f32 content_h = figure_h;
// The gap is dropped, rather than squeezing the rows, when the figure cannot hold
// it: a figure one pixel tall with four rows still gives every row a positive
@@ -441,25 +655,59 @@ const FigureLayout& Figure::layout() {
// short to hold the one asked for.
layout_.row_gap = gap;
const u32 bottom_row = rows - 1u;
// Each row rect is split into its cells; a one-cell row's cell is the rect itself, so an
// unsplit figure lays out exactly as it always did.
layout_.cells.assign(plots_.size(), Rect{});
layout_.col_gap = col_gap_;
for (u32 row = 0; row < rows; ++row) {
Plot& p = plots_[row];
p.set_viewport(layout_.rows[row]);
// One x axis for the whole figure, and it is the bottom row's: every row keeps its
// x chrome -- labels, title and the strip the controller's axis zoom is found in --
// and every other row gives all of it to that one, so the labels and the title are
// not drawn once per row. A figure told to label every row keeps them everywhere,
// and then there is no shared axis to speak of: each row has its own.
p.set_x_strip_mode(labels_bottom_only_ && row != bottom_row ? XStrip::None : XStrip::Both);
// Before either pass below, and the same value for every row: nothing is measured on
// the right, so unlike the left gutter this needs no measurement pass -- the rows end
// on a common column because they are all told the one width.
p.set_right_gutter(right_gutter_);
const u32 n = cells_per_row_[row];
const f32 used = place_cells(layout_.rows[row], n, cell_weights_[row], col_gap_,
floor_scale, &layout_.cells[row_first_[row]]);
if (n > 1u && used < col_gap_) layout_.col_gap = 0.0f;
}
// Which rows carry their own x strip, decided structurally and before the first
// measurement pass so that pass and the final one see the same strips. The bottom row
// always has one. A row above hides its strip only when the row directly below has the
// same columns (see `same_columns()`), because then the cells of the two rows have the
// same x extents and the axis below serves this row too; the rule is transitive, since
// that row is itself either labelled or hidden in favour of one that matches it. A row
// whose columns differ from the one below -- `[1][2]` over `[3]` -- keeps its own strip,
// drawn under each of its cells, because the axis below is at another scale and width.
// A figure told to label every row keeps the strips everywhere.
std::vector<XStrip> strips(rows, XStrip::Both);
if (labels_bottom_only_) {
for (u32 row = rows - 1u; row-- > 0u;) {
// By induction the row below is labelled or itself hidden in favour of a matching
// row, so hiding this one on a match with it is the transitive rule.
if (same_columns(cells_per_row_, cell_weights_, row, row + 1u)) strips[row] = XStrip::None;
}
}
for (u32 row = 0; row < rows; ++row) {
const XStrip strip = strips[row];
// A split row whose cells share one y range also shares one y axis, drawn by its first
// cell: every other cell reserves no y chrome at all (see `YStrip::None`).
const bool shared_y = share_row_y_ && cells_per_row_[row] > 1u;
// ... and its right gutter is the last cell's alone, so the gap between two plots of the
// row is the column gap and not a gutter more (see the placement block below).
const u32 last = row_first_[row + 1u] - 1u;
for (u32 i = row_first_[row]; i < row_first_[row + 1u]; ++i) {
Plot& p = plots_[i];
p.set_viewport(layout_.cells[i]);
p.set_x_strip_mode(strip);
p.set_y_strip_mode(shared_y && i != row_first_[row] ? YStrip::None : YStrip::Both);
// Before either pass below, and the same value for every plot: nothing is measured on
// the right, so unlike the left gutter this needs no measurement pass -- the plots
// end on a common column because they are all told the one width.
p.set_right_gutter(shared_y && i != last ? 0.0f : right_gutter_);
}
}
// After the viewports, so a row's ticks and grid -- built from its axes rect -- and
// its transforms describe this frame's rects; and before the rows lay themselves
// out below, so a following group's window is the range they lay out with.
sync_x_links();
// Before any `Plot::layout()`, where a plot would autoscale its own y axis.
sync_row_y();
// Every row is laid out against its own rect, so after this call each row's
// chrome is current: `plot(i).layout()` reads back what the figure computed rather
// than what the row last held. That is the contract `Renderer::draw(Figure&)`
@@ -486,26 +734,63 @@ const FigureLayout& Figure::layout() {
// `set_min_y_gutter()` seeds the maximum rather than being imposed on a row before the
// measurement, so a reserved floor cannot widen what a row reports and then be widened
// by its own reservation on the next frame.
//
// Over the flattened plots: every cell of every row takes part, so the y axes of a split
// row line up with each other and with the rows above and below.
f32 gutter = min_y_gutter_;
for (u32 row = 0; row < rows; ++row) {
plots_[row].set_min_y_gutter(0.0f);
const PlotLayout& row_layout = plots_[row].layout();
//
// A `YStrip::None` cell draws no y axis, so it has no width to contribute and takes no part
// in the maximum: the gutter is the widest of the cells that do draw one.
for (Plot& p : plots_) {
p.set_min_y_gutter(0.0f);
const PlotLayout& row_layout = p.layout();
if (p.y_strip_mode() == YStrip::None) continue;
gutter = std::max(gutter, row_layout.y_axis_width + row_layout.y_title_width);
}
// Rows that share one y axis divide their *plot* widths, not their cell widths: the first
// cell's y strip would otherwise make its plot narrower than its neighbours'. The gutter is
// taken off the row first, the remainder is split by `place_cells` (so the weights, the gap
// and the floor all apply to plot widths), and the first cell then grows left by the gutter
// to hold the axis. The gutter is capped at half the row, so a row too narrow for it
// degrades to ordinary cells instead of producing a negative rect. The cells still tile the
// row: the first starts on its left edge and the last still ends on its right edge.
//
// The right gutter is taken off the same way and belongs to the **last** cell alone (the
// other cells were told none above): left to every cell, the visible gap between two
// plots would be the column gap plus a whole right gutter. Together with the y gutter it is
// capped at half the row, and never exceeds the figure's own right gutter.
for (u32 row = 0; row < rows; ++row) {
const u32 n = cells_per_row_[row];
if (!share_row_y_ || n <= 1u) continue;
const Rect& r = layout_.rows[row];
const f32 g_eff = std::max(0.0f, std::min(gutter, r.w / 2.0f));
const f32 rg_eff = std::max(0.0f, std::min(right_gutter_, r.w / 2.0f - g_eff));
Rect* cells = &layout_.cells[row_first_[row]];
const f32 used = place_cells(Rect{r.x + g_eff, r.y, r.w - g_eff - rg_eff, r.h}, n,
cell_weights_[row], col_gap_, floor_scale, cells);
cells[0].x -= g_eff;
cells[0].w += g_eff;
cells[n - 1u].w += rg_eff;
layout_.col_gap = used < col_gap_ ? 0.0f : layout_.col_gap;
for (u32 i = row_first_[row]; i < row_first_[row + 1u]; ++i) plots_[i].set_viewport(layout_.cells[i]);
// What the cell actually reserved, so a row too narrow for the whole gutter is not asked
// for more than it was given (the same value whenever the row has room).
plots_[row_first_[row + 1u] - 1u].set_right_gutter(rg_eff);
}
// A one-row figure imposes exactly what that row measured, so it lays out as the plot
// would on its own; a figure whose rows all need the same strip imposes that, which is
// also what each would have done alone.
for (u32 row = 0; row < rows; ++row) {
plots_[row].set_min_y_gutter(gutter);
plots_[row].layout();
for (Plot& p : plots_) {
p.set_min_y_gutter(p.y_strip_mode() == YStrip::None ? 0.0f : gutter);
p.layout();
}
// The figure's one x axis is the bottom row's, so the strip it is drawn in is that
// row's own label area -- the rect the controller hit-tests and the renderer stages
// its labels in, read back here rather than derived again, so a host and the frame
// cannot disagree about where the shared axis is. It is zero-sized only when there
// are no rows at all: with `set_x_labels_only_on_bottom(false)` it is simply the last
// row's own strip, one of several.
// `x_axis_strip` is the bottom row's own label area (the last plot's) -- the rect the
// controller hit-tests and the renderer stages its labels in, read back here rather than
// derived again, so a host and the frame cannot disagree about where it is. Rows above
// that carry their own strip (their columns differ from the row below, or every row is
// labelled) have it in their plots' own `layout().x_label_area`. It is zero-sized only
// when there are no rows at all.
layout_.x_axis_strip = plots_.back().layout().x_label_area;
return layout_;
}
@@ -534,6 +819,21 @@ void Figure::set_crosshair(f64 x, u32 hovered_row, f64 y_in_row, bool active) {
// than clearing a hover the pointer still has.
if (!active || hovered_row >= plots_.size()) return;
plots_[hovered_row].set_crosshair(x, y_in_row, true);
// **The cells of a split row share one y**, so the horizontal line means the same value in
// every one of them: it spans the row rather than stopping at the divider between two cells
// that read the same scale. Only that row -- rows do not share y with each other, so a
// sibling row's line would be a value on a scale it does not have -- and only when the row
// really is split (`set_share_row_y`, which is what makes the y common in the first place).
if (!share_row_y_) return;
const u32 row = row_of_plot(hovered_row);
if (row >= cells_per_row_.size() || cells_per_row_[row] <= 1u) return;
const u32 first = row_first_[row];
for (u32 k = 0; k < cells_per_row_[row]; ++k) {
const u32 cell = first + k;
if (cell != hovered_row && cell < plots_.size()) {
plots_[cell].set_crosshair(x, y_in_row, true);
}
}
}
void Figure::clear_crosshair() {
+392 -6
View File
@@ -311,6 +311,11 @@ void Plot::set_cursor_color(u32 id, Color color) {
cursors_[id].color = color;
}
void Plot::set_cursor_label(u32 id, std::string label) {
if (id >= cursors_.size()) return;
cursors_[id].label = std::move(label);
}
void Plot::set_cursor_stroke(u32 id, f32 width, Vec2 dash) {
if (id >= cursors_.size()) return;
cursors_[id].width = width;
@@ -348,6 +353,307 @@ void Plot::set_invalid_spans(std::span<const InvalidSpan> spans) {
invalid_spans_.assign(spans.begin(), spans.end());
}
// --- phosphor density ---------------------------------------------------------
void Plot::set_density(const DensityConfig& config) {
// Disabling is a caller asking to stop paying for the display, and a trigger that has stopped
// firing never reaches another accumulation -- so the image goes now rather than at an event
// that may not come. The same rule, for the same reason, as the app's `set_persist_depth()`.
if (density_.enabled && !config.enabled) {
for (DensityGrid& grid : density_grids_) {
grid.cells_.clear();
grid.cells_.shrink_to_fit();
grid.columns_ = 0u;
grid.rows_ = 0u;
++grid.revision_;
}
density_grids_.clear();
density_grids_.shrink_to_fit();
}
// The two fields the renderer turns a cell's count into a luminance with: a change to either is
// a change to every cell's *byte*, and a grid's own revision does not see it. A host that states
// its configuration every frame (udpscope does) must not force a re-upload per frame, so this
// bumps only when one of the two actually moved -- and then a re-upload is owed.
const bool rescale = density_.enabled && config.enabled &&
(density_.full_scale != config.full_scale || density_.gamma != config.gamma);
density_ = config;
if (rescale) {
for (DensityGrid& grid : density_grids_) ++grid.revision_;
}
}
void Plot::decay_density(f64 dt_s) {
if (!density_.enabled || density_.decay != DensityDecay::TimeConstant) return;
// A non-positive frame and a non-positive time constant are both "do not fade": the first is a
// caller that has not measured a frame yet, and the second is a caller asking for a grid that
// is drawn and never fades.
if (!(dt_s > 0.0) || !(density_.time_constant_s > 0.0f)) return;
const f32 factor =
static_cast<f32>(std::exp(-dt_s / static_cast<f64>(density_.time_constant_s)));
if (!(factor < 1.0f)) return;
for (DensityGrid& grid : density_grids_) {
if (grid.cells_.empty()) continue;
for (f32& cell : grid.cells_) cell *= factor;
++grid.revision_;
}
}
void Plot::clear_density() noexcept {
for (DensityGrid& grid : density_grids_) {
if (grid.cells_.empty()) continue;
std::fill(grid.cells_.begin(), grid.cells_.end(), 0.0f);
++grid.revision_;
}
}
bool Plot::size_density_grids() {
const Rect& axes = layout_.axes;
if (!(axes.w >= 1.0f) || !(axes.h >= 1.0f)) return false;
u32 columns = static_cast<u32>(std::ceil(axes.w));
u32 rows = static_cast<u32>(std::ceil(axes.h));
if (columns == 0u || rows == 0u) return false;
// **The grid is the axes rect in device pixels, up to `kDensityCellCap`.** Beyond it the rect is
// scaled down *uniformly*, so the cells stay square in device pixels and the picture keeps the
// axes' aspect: a cap on one axis alone would stretch a cell, and a density smeared in one
// direction only reads as a bug in the data rather than as a coarse image. The cells are then no
// longer one device pixel across, which is why the renderer magnifies the image with a linear
// filter instead of a point sample -- see `Renderer::draw_density_grid()`.
//
// The floor of the product is what makes the cap hold: `floor(w*s) * floor(h*s) <= w*s * h*s
// <= cap` for the `s` chosen, and the two `max(1, ...)` keep a very thin axes rect from
// rounding to nothing.
const u64 pixels = static_cast<u64>(columns) * static_cast<u64>(rows);
if (pixels > static_cast<u64>(kDensityCellCap)) {
const f64 scale = std::sqrt(static_cast<f64>(kDensityCellCap) / static_cast<f64>(pixels));
columns = std::max(1u, static_cast<u32>(static_cast<f64>(columns) * scale));
rows = std::max(1u, static_cast<u32>(static_cast<f64>(rows) * scale));
}
// **One grid per trace**, because a density is drawn in the colour of the trace that laid it
// down (see `DensityConfig`). The vector tracks the trace list, so a trace added after the first
// accumulation gets its own empty grid here while the grids already sized keep what they held.
if (density_grids_.size() != traces_.size()) density_grids_.resize(traces_.size());
// The per-sweep dedupe's stamps are one per grid **row** and shared by every trace's deposit:
// the key a cell is deduped against carries the trace's number (see
// `deposit_density_segment()`), so a stamp left by one trace's sweep never matches another's, and
// a zeroed stamp -- every key has a non-zero trace number in its high half -- matches nothing.
if (density_stamp_.size() != rows) density_stamp_.assign(rows, 0u);
bool usable = false;
for (usize i = 0u; i < traces_.size(); ++i) {
DensityGrid& grid = density_grids_[i];
if (!traces_[i].visible()) {
// A trace with nothing to draw gets no image: the grid is per *trace*, and a slot with no
// series is not one. Dropping it here is also what keeps the display's memory proportional
// to the traces actually on screen rather than to a plot's fixed trace slots -- a row of four
// signals out of eight slots holds four grids, not eight.
if (!grid.cells_.empty()) {
grid.cells_.clear();
grid.cells_.shrink_to_fit();
grid.columns_ = 0u;
grid.rows_ = 0u;
++grid.revision_;
}
continue;
}
if (columns != grid.columns_ || rows != grid.rows_) {
// A grid of another size is another image, and there is nothing honest to carry over from
// one to the other: the cells are device pixels, so a resize moves every one of them. Drop
// what it held rather than resample it -- the display restarts, which is what a scope does
// when its screen changes.
grid.cells_.assign(static_cast<usize>(columns) * rows, 0.0f);
grid.columns_ = columns;
grid.rows_ = rows;
++grid.revision_;
}
usable = usable || !grid.cells_.empty();
}
return usable;
}
void Plot::accumulate_density() {
if (!density_.enabled) return;
if (!size_density_grids()) return;
// The fade before the deposit, never after: under `PerAcquisition` the acquisition just drawn
// is the brightest one and each older one is the factor down, which is what "the N-th back is
// gone" means. TimeConstant fades in `decay_density()` instead, because its fade is the clock's
// and not the acquisition's.
if (density_.decay == DensityDecay::PerAcquisition) {
const f32 factor = std::clamp(density_.per_acquisition, 0.0f, 1.0f);
for (DensityGrid& grid : density_grids_) {
if (grid.cells_.empty()) continue;
for (f32& cell : grid.cells_) cell *= factor;
++grid.revision_;
}
}
// **Every visible trace of the acquisition on screen, the newest included.** The current
// acquisition is deposited here like any other, so it is inside the image and fades with it --
// there is no second, solid trace drawn over the density (see `Renderer::draw_traces()`), which
// is what a report of "the last acquisition never fades" turned out to be. One grid each, so two
// traces that agree read as two colours at full strength where they overlap and not as one
// blended one.
const usize n = std::min(density_grids_.size(), traces_.size());
for (usize i = 0u; i < n; ++i) deposit_density_trace(traces_[i], density_grids_[i]);
}
void Plot::deposit_density_trace(const Trace& trace, DensityGrid& grid) {
if (!trace.visible()) return;
const data::Series& series = *trace.series;
const u64 n = series.size();
if (!(series.dx() > 0.0) || n == 0u) return;
// The same index window `Renderer::draw_traces()` maps, by the same calls: `neighbours()` on
// the series' own stamps -- never the `(x - x0()) / dx()` grid, which is a nominal spacing and
// would lose the segments entering and leaving the frame -- with one sample of slack on each
// side and clamped to the series.
const Range window = x_range(trace.axis_x);
u64 first = 0u;
u64 last = n;
u64 i0 = 0u;
u64 i1 = 0u;
if (series.neighbours(window.min, i0, i1)) first = i0 > 0u ? i0 - 1u : 0u;
if (series.neighbours(window.max, i0, i1)) last = std::min(n, i1 + 1u);
if (last <= first) return;
// The same level gate the renderer uses, and for the same reason: a window denser than the
// pixel grid is a *reduction*, and the density is a reduction of it too -- one pass per cell
// the column's own envelope crosses. The levels agree because the ask is the same ask.
const f64 samples_per_pixel =
static_cast<f64>(last - first) / static_cast<f64>(layout_.axes.w);
const u32 level = samples_per_pixel >= static_cast<f64>(style_.dense_threshold_spp)
? series.pyramid().level_for(n, samples_per_pixel)
: 0u;
density_points_.clear();
series.gather(level, first, last, window.min, density_points_, trace.mode);
if (density_points_.size() < 2u) return;
// A fresh stamp for this trace's part of this acquisition: the key a cell is deduped against is
// `(this, column)`, so every trace of a plot deposits once per cell it covers even where they
// overlap, and a cell one trace crosses twice in one sweep deposits once.
++density_trace_;
const Transform tx = x_transform(trace.axis_x);
const Transform ty = y_transform(trace.axis_y);
f32 px = 0.0f;
f32 py = 0.0f;
u32 run = 0u; // points in the current unbroken run
// A run's *first* point is not deposited on its own: the segment that leaves it already puts
// ink in that cell, and depositing both would count the pen's arrival twice -- a single pass
// would read as two in the cell every trace starts in, which is exactly the cell a peak
// reading is taken from. A run of one point -- a lone visible sample, or one isolated by a
// gap -- is deposited as a degenerate segment instead, because a place a trace *was* is ink
// however short its stay.
const auto flush_run = [&]() {
if (run == 1u) deposit_density_segment(px, py, px, py, grid);
};
for (usize k = 0; k + 1u < density_points_.size(); k += 2u) {
// x is gathered relative to the visible range's minimum (see `Series::gather()`), so the
// absolute x is reconstructed in f64 before it is mapped -- the same reconstruction, and the
// same reason, as the staging paths.
const f64 x_abs = window.min + static_cast<f64>(density_points_[k]);
const f64 y_abs = static_cast<f64>(density_points_[k + 1u]);
if (!std::isfinite(x_abs) || !std::isfinite(y_abs)) {
flush_run(); // a NaN is a gap: it joins nothing, so the trace is deposited in pieces
run = 0u;
continue;
}
const f32 sx = static_cast<f32>(tx.to_screen_x(x_abs));
const f32 sy = static_cast<f32>(ty.to_screen_y(y_abs));
if (!std::isfinite(sx) || !std::isfinite(sy)) {
flush_run();
run = 0u;
continue;
}
if (run > 0u) deposit_density_segment(px, py, sx, sy, grid);
px = sx;
py = sy;
++run;
}
flush_run();
++grid.revision_;
}
void Plot::deposit_density_segment(f32 x0, f32 y0, f32 x1, f32 y1, DensityGrid& grid) noexcept {
const u32 columns = grid.columns_;
const u32 rows = grid.rows_;
if (columns == 0u || rows == 0u) return;
if (density_stamp_.size() < rows) return; // sized with the grid; nothing deposits before that
if (!std::isfinite(x0) || !std::isfinite(y0) || !std::isfinite(x1) || !std::isfinite(y1)) return;
const Rect& axes = layout_.axes;
// **The image's footprint is the axes rect rounded up to whole device pixels** -- the rect
// `size_density_grids()` counted the cells on -- so `sx`/`sy` are exactly **1** while that rect is
// under `kDensityCellCap`: a cell *is* a device pixel and this is the arithmetic the density has
// always used. Past the cap they are a fraction of one, and the cell index is read through the
// scale rather than straight off the pixel. (The rounding is why the footprint, not the
// fractional rect, is the reference: a cell is defined on whole pixels, and a grid of `ceil(w)` of
// them covers the rect the axes was laid out with.)
const f32 sx = axes.w > 0.0f ? static_cast<f32>(columns) / std::ceil(axes.w) : 1.0f;
const f32 sy = axes.h > 0.0f ? static_cast<f32>(rows) / std::ceil(axes.h) : 1.0f;
const f32 lo_x = std::min(x0, x1);
const f32 hi_x = std::max(x0, x1);
i32 c0 = static_cast<i32>(std::floor((lo_x - axes.x) * sx));
i32 c1 = static_cast<i32>(std::floor((hi_x - axes.x) * sx));
// Clipped to the axes' columns *and* to the grid: a segment reaching past the right edge keeps
// the part that is inside, and one entirely outside deposits nothing at all. The clamp is what
// keeps ink off the edge column when a trace leaves the frame sideways.
c0 = std::max(c0, 0);
c1 = std::min(c1, static_cast<i32>(columns) - 1);
if (c1 < c0) return;
const f32 span = hi_x - lo_x;
const f32 denom = x1 - x0;
const i32 last_row = static_cast<i32>(rows) - 1;
// The segment's y span inside one column's own slice of x: the two ends of the slice taken
// along the segment. Every column's span meets its neighbour's end exactly (the y is linear in
// x), so the deposit is a continuous trace and not a dotted one.
const auto column_y_span = [&](i32 column, f32& out_lo, f32& out_hi) {
const f32 ca = axes.x + static_cast<f32>(column) / sx;
f32 ya = y0;
f32 yb = y1;
if (span > 0.0f && denom != 0.0f) {
const f32 a = std::max(ca, lo_x);
const f32 b = std::min(ca + 1.0f / sx, hi_x);
ya = y0 + (a - x0) / denom * (y1 - y0);
yb = y0 + (b - x0) / denom * (y1 - y0);
}
out_lo = std::min(ya, yb);
out_hi = std::max(ya, yb);
};
for (i32 c = c0; c <= c1; ++c) {
f32 y_lo = 0.0f;
f32 y_hi = 0.0f;
column_y_span(c, y_lo, y_hi);
// A column whose slice of the segment lies wholly outside the axes deposits nothing: without
// this, a trace above the top of the plot would paint a band along the top row, which is the
// one thing a density must not do -- it would say the trace was there.
if (!(y_hi >= axes.y) || !(y_lo <= axes.bottom())) continue;
const f32 top = std::max(y_lo, axes.y);
// One ULP inside the bottom edge, so a segment ending exactly on it lands in the last row
// rather than one past the end.
const f32 bottom = std::min(y_hi, std::nextafter(axes.bottom(), axes.y));
i32 r0 = std::clamp(static_cast<i32>(std::floor((top - axes.y) * sy)), 0, last_row);
i32 r1 = std::clamp(static_cast<i32>(std::floor((bottom - axes.y) * sy)), 0, last_row);
// **One pass per cell per sweep, not per hit.** A column of a dense window is crossed hundreds
// of times by one acquisition's own samples, and counting each crossing would put a single
// sweep at the ceiling of the brightness scale: the newer sweeps behind it would be invisible,
// and a cell's value would mean "how much the beam moved here" instead of "how many
// acquisitions were here" -- which is what a persistence display is. The stamp is per
// *(trace, column)*, so two sweeps that agree on a cell still count twice, and the two segments
// that share a column's boundary do not.
const u64 key = (static_cast<u64>(density_trace_) << 32) | static_cast<u64>(static_cast<u32>(c));
f32* column_cells = grid.cells_.data() + static_cast<usize>(c);
for (i32 r = r0; r <= r1; ++r) {
const usize row = static_cast<usize>(r);
if (density_stamp_[row] == key) continue; // this sweep has already put its ink here
density_stamp_[row] = key;
column_cells[row * static_cast<usize>(columns)] += 1.0f;
}
}
}
const DensityGrid& Plot::empty_density_grid() noexcept {
static const DensityGrid empty{};
return empty;
}
f64 Plot::x_axis_origin(u32 axis) const {
return axis < x_axes_.size() ? x_axes_[axis].origin : 0.0;
}
@@ -518,6 +824,19 @@ void Plot::set_x_link_member(u32 axis, bool linked) noexcept {
x_axes_[axis].link_member = linked;
}
void Plot::set_y_link_member(u32 axis, bool linked) noexcept {
if (axis >= y_axes_.size()) return;
y_axes_[axis].link_member = linked;
}
bool Plot::y_data_bounds(u32 axis, Range& out) const {
return trace_bounds_union(/*x_side=*/false, axis, out);
}
bool Plot::fit_y_bounds(u32 axis, Range bounds) {
return axis < y_axes_.size() && fit_axis_range(y_axes_[axis], bounds);
}
XFollow Plot::x_follow(u32 axis) const {
return axis < x_axes_.size() ? x_axes_[axis].follow : XFollow::Off;
}
@@ -700,7 +1019,8 @@ void Plot::autoscale_axes() {
if (x_axes_[i].auto_scale && !follows_live(i)) fit_x_axis(i);
}
for (u32 i = 0; i < y_axes_.size(); ++i) {
if (y_axes_[i].auto_scale) fit_y_axis(i);
// A shared-y member is fitted by its figure, against every cell's traces.
if (y_axes_[i].auto_scale && !y_axes_[i].link_member) fit_y_axis(i);
}
}
@@ -997,13 +1317,18 @@ const PlotLayout& Plot::layout() {
y_width = y_axes_.empty() ? 0.0f : 2.0f * pad;
x_height = text_->height() + 2.0f * pad;
}
// A row of a `Figure` gives its x strip to the figure's one shared axis -- the
// bottom row's own strip, where that axis lives -- so it reserves none: the seed and
// A row of a `Figure` that hands its x axis to the row below (which has the same
// columns, so the same x extents) reserves no strip: the seed and
// the fixed point below both start from zero, and `axes` then reaches the viewport's
// bottom edge. Set here rather than in the loop, because the loop's `need_x` is what
// the strip wants and this overrides the want, not the measurement.
const bool x_labels = x_strip_has_labels(x_strip_);
if (!x_labels) x_height = 0.0f;
// The y strip likewise: a cell that gives its y axis to a neighbour (`YStrip::None`) reserves
// no label width, no title column and no share of the figure's gutter, so every y-strip
// quantity below collapses to zero and `axes` starts at the viewport's left edge.
const bool y_none = y_strip_ == YStrip::None;
if (y_none) y_width = 0.0f;
// The title strips, though, are needed before the loop and never change, unlike
// the label strips: a strip holds exactly one text line per titled axis and no
// tick label can change that. Nothing at all is reserved without a text backend
@@ -1017,7 +1342,7 @@ const PlotLayout& Plot::layout() {
// no title reserves no row for a title either, and one that reserves the title and no
// labels still gets its row -- below where the labels would have been.
const u32 x_titles = x_strip_has_title(x_strip_) ? titled_x_axis_count() : 0u;
const f32 y_title_w = text_ != nullptr ? y_title_strip_width() : 0.0f;
const f32 y_title_w = text_ != nullptr && !y_none ? y_title_strip_width() : 0.0f;
const f32 x_title_h = text_ != nullptr && x_titles != 0u
? static_cast<f32>(x_titles) * text_->height() + 2.0f * pad
: 0.0f;
@@ -1038,7 +1363,7 @@ const PlotLayout& Plot::layout() {
// -- and it is applied before the title column below, so the pair still fits the cap
// (the floor is clamped to the same budget) and the invariant `axes.w >= share` holds.
// `min_y_gutter_` is 0.0 unless a figure set it, so this is a no-op for a lone plot.
const f32 y_floor = std::min(min_y_gutter_, y_budget);
const f32 y_floor = y_none ? 0.0f : std::min(min_y_gutter_, y_budget);
y_width = std::min(std::max(y_width, y_floor - y_title_w), y_budget);
x_height = std::min(x_height, x_budget);
const f32 y_title_width = std::min(y_title_w, y_budget - y_width);
@@ -1071,7 +1396,8 @@ const PlotLayout& Plot::layout() {
if (text_ == nullptr || pass + 1 >= kMaxLayoutPasses) break;
// The same caps as above, so this compares like with like and the loop still
// reaches its fixed point on the second pass.
const f32 need_y = std::min(std::clamp(y_label_width(), 0.0f, vw), y_budget);
const f32 need_y =
y_none ? 0.0f : std::min(std::clamp(y_label_width(), 0.0f, vw), y_budget);
// Overridden, not merely unmeasured: the strip is not reserved, so the fixed
// point has to be zero for a row that gave its strip away.
const f32 need_x =
@@ -1567,6 +1893,14 @@ void Plot::build_crosshair() {
const auto inside = [](f32 v, f32 lo, f32 hi) {
return std::clamp(v, std::min(lo, hi), std::max(lo, hi));
};
crosshair.y_axis_badge = Rect{};
crosshair.y_axis_text.clear();
crosshair.x_axis_badge = Rect{};
crosshair.x_axis_text.clear();
if (!crosshair_.active) {
crosshair.rows.clear();
return;
}
// The two lines, each a stroke box held *inside* the axes by half its own width. The
// renderer stages the stroke across the box's centre line, so this is the whole placement
@@ -1597,6 +1931,58 @@ void Plot::build_crosshair() {
crosshair.horizontal = Rect{axes.x, at - half, axes.w, stroke};
}
// The axis badges, when asked for (`Crosshair::axis_labels`): the values the two lines
// meet the axes at, written in the strips the tick labels are written in and in each
// axis' own format, so a badge reads exactly like the tick beside it. Placed here rather
// than by the renderer, for the readout panel's reason -- one placement rule, one place to
// read it -- and left empty whenever there is nothing honest to place: no text backend to
// measure with, the line they belong to not drawn (a vertical-only crosshair has no y),
// or their strip absent or too small (a row whose x strip went to the row below, a y
// gutter collapsed to a title). The x badge is held to the axes' own columns as well, so
// a crosshair in the leftmost cell of a split row cannot badge into its neighbour's.
if (crosshair_.axis_labels && text_ != nullptr) {
const f32 badge_pad = style_.device(4.0f);
const f32 badge_h = text_->height() + 2.0f * badge_pad;
if (crosshair_.horizontal && layout_.y_label_area.w > 0.0f &&
layout_.y_label_area.h >= badge_h) {
char text[64];
y_axis(0u).format_value(crosshair_.y, text, sizeof text);
crosshair.y_axis_text.assign(text);
const f32 w = text_->measure(crosshair.y_axis_text.c_str()) + 2.0f * badge_pad;
// Right-aligned on the axis line -- where the y ticks end -- and **centred** on the
// horizontal line's own height, the way the x badge is centred on its column: the badge
// names the line it sits beside, and a badge whose top edge *is* that line reads as
// belonging to the tick below it. A badge is as wide as its own text and is capped by
// nothing else, so it
// is held within the **left strips' union**: the label strip first, the title strip
// beside it when there is one, and never over the data or off the viewport's left
// edge. A `std::clamp` on an inverted pair is undefined, which is exactly the state
// "the strip is narrower than the badge" produces -- hence the ordered min/max chain.
const f32 left = layout_.y_title_area.w > 0.0f ? layout_.y_title_area.x
: layout_.y_label_area.x;
f32 bx = layout_.y_label_area.right() - w;
if (bx < left) bx = left;
bx = std::min(bx, axes.x - w);
if (bx < left) bx = left; // a badge wider than the union stays at its left edge
const f32 at = inside(static_cast<f32>(ty.to_screen_y(crosshair_.y)) - 0.5f * badge_h,
layout_.y_label_area.y + badge_pad,
layout_.y_label_area.bottom() - badge_pad - badge_h);
crosshair.y_axis_badge = Rect{bx, at, w, badge_h};
}
if (crosshair_.vertical && layout_.x_label_area.w > 0.0f &&
layout_.x_label_area.h >= badge_h) {
char text[64];
x_axis(0u).format_value(crosshair_.x, text, sizeof text);
crosshair.x_axis_text.assign(text);
const f32 w = text_->measure(crosshair.x_axis_text.c_str()) + 2.0f * badge_pad;
const f32 column =
inside(static_cast<f32>(tx.to_screen_x(crosshair_.x)), axes.x, axes.right());
const f32 x = inside(column - 0.5f * w, layout_.x_label_area.x,
layout_.x_label_area.right() - w);
crosshair.x_axis_badge = Rect{x, layout_.x_label_area.y, w, badge_h};
}
}
// The rows: one per *visible* trace -- a trace with no series or no samples is not under
// the pointer, so it has nothing to say -- named or not, because the readout is about what
// the pointer is over and not about identity. The vector is filled in place and its entries
+23
View File
@@ -60,4 +60,27 @@ void TextProgram::set_texture_size(Vec2 texels) const noexcept {
program_.set_uniform("u_tex_size", texels);
}
bool DensityProgram::create(Status* st) noexcept {
program_ = gl::Program::from_source(kDensityVS, kDensityFS, st);
if (program_.id() == 0) {
return false;
}
// The grid texture is bound to texture unit 0 by plot::gl::Texture::bind(), so the sampler
// has to point there. Set once, here, not per frame -- the same shape as the text program's.
program_.use();
program_.set_uniform_int("u_density", 0);
return true;
}
void DensityProgram::set_viewport(Vec2 px) const noexcept { program_.set_uniform("u_viewport", px); }
void DensityProgram::set_rect(Vec2 origin, Vec2 size) const noexcept {
program_.set_uniform("u_origin", origin);
program_.set_uniform("u_size", size);
}
void DensityProgram::set_color(Color color) const noexcept {
program_.set_uniform("u_color", color);
}
} // namespace plot::render
+326 -38
View File
@@ -193,14 +193,23 @@ Renderer::~Renderer() {
void Renderer::release_gl() noexcept {
ready_ = false;
// Assigning fresh objects releases every GL name through the handles'
// destructors: both programs, both vertex arrays and their instance buffers,
// and the atlas texture. Safe to call more than once; every assignment is a
// no-op while the renderer is uninitialised.
// destructors: all three programs, the vertex arrays and their instance
// buffers, the glyph atlas texture and the density's own texture. Safe to call
// more than once; every assignment is a no-op while the renderer is
// uninitialised.
atlas_ = render::TextAtlas{};
line_program_ = render::LineProgram{};
text_program_ = render::TextProgram{};
density_program_ = render::DensityProgram{};
line_vao_ = render::LineVertexArray{};
text_vao_ = render::TextVertexArray{};
density_vao_ = gl::VertexArray{};
density_texture_ = gl::Texture{};
density_pixels_.clear();
density_w_ = 0;
density_h_ = 0;
density_uploaded_grid_ = nullptr;
density_uploaded_revision_ = 0;
lines_.clear();
glyphs_under_.clear();
glyphs_over_.clear();
@@ -252,10 +261,10 @@ bool Renderer::init(Status* st) noexcept {
return false;
}
if (!line_program_.create(st) || !text_program_.create(st)) {
if (!line_program_.create(st) || !text_program_.create(st) || !density_program_.create(st)) {
return false;
}
if (!line_vao_.create(st, mode) || !text_vao_.create(st, mode)) {
if (!line_vao_.create(st, mode) || !text_vao_.create(st, mode) || !density_vao_.create(st)) {
return false;
}
ready_ = true;
@@ -462,7 +471,7 @@ void Renderer::draw(Figure& figure) {
// Every row is prepared before the figure lays itself out, because a row's strips
// are measured from the metrics this installs: a row measuring with one backend and
// drawing with another is the defect the propagation point exists to prevent.
const u32 rows = figure.row_count();
const u32 rows = figure.plot_count();
for (u32 row = 0; row < rows; ++row) prepare_plot(figure.plot(row));
// The figure's one layout for the frame. It hands every row its rect and its
@@ -471,10 +480,11 @@ void Renderer::draw(Figure& figure) {
// than laying each row out a second time. Nothing draws before this: a row's
// viewport is the figure's to set.
//
// The figure's one x axis needs no pass of its own: it is the bottom row's own, so
// that row stages it in its own strip through `draw_tick_labels()` like any other
// plot's axis -- and every row above it reserved no x chrome, which is what keeps
// the labels from appearing once per row without a second staging here.
// A shared x axis needs no pass of its own: it is the bottom row's own (of a run of rows
// with the same columns), so that row stages it in its own strip through
// `draw_tick_labels()` like any other plot's axis -- and every row of the run above it
// reserved no x chrome, which is what keeps the labels from appearing once per row without
// a second staging here. A row whose columns differ stages its own.
figure.layout();
for (u32 row = 0; row < rows; ++row) {
Plot& plot = figure.plot(row);
@@ -519,6 +529,10 @@ void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) {
// 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);
// ... the density included: it is trace ink and not chrome, so it goes over the bands exactly
// where the traces did -- and, while it is on, *instead* of them (`draw_traces()` returns) -- and
// under them for the same reason they are under the data.
draw_density(plot, layout);
clear_clip();
draw_axis_lines(plot);
set_clip(layout.axes);
@@ -551,6 +565,10 @@ void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) {
clear_clip();
draw_tick_labels(plot, layout);
draw_titles(plot, layout);
// The crosshair's axis badges, last of all: they sit in the label strips among the tick
// labels, so they are drawn after them and unclipped -- and over them, which is the point
// (a badge the ticks print through is two numbers in one place).
draw_crosshair_axis_badges(layout);
}
void Renderer::end_frame() noexcept {
@@ -865,8 +883,8 @@ void Renderer::draw_tick_labels(const Plot& plot, const PlotLayout& layout) {
const f32 line_h = font_backend_.height();
// X labels: unless the plot gave its x label strip away -- a row of a `Figure`
// that reserves no x chrome, because the figure draws one shared axis in the bottom
// row's own strip instead -- in which case it has no strip to put them in and their
// that reserves no x chrome, because the row below has the same columns and draws the
// axis in its own strip instead -- in which case it has no strip to put them in and their
// absence is the point. A plot with no strip would otherwise stage its labels just
// above `axes`' bottom edge, i.e. over its own data. Centred under their tick on the
// strip's single text line, which is the one rule for a plot's labels and a figure's
@@ -889,9 +907,14 @@ void Renderer::draw_tick_labels(const Plot& plot, const PlotLayout& layout) {
// Y labels: right-aligned in their strip, centred on the tick and pulled back
// inside the strip at the two extremes (a label centred on the top tick would
// otherwise hang above the plot area).
//
// Unless the plot gave its y strip away (`YStrip::None`, a later cell of a split row that
// shares one y axis): its label area is then zero wide, and labels right-aligned against it
// would be drawn in the neighbouring cell. Keyed on the area rather than the mode so a strip
// the layout clamped to nothing stages nothing either.
const f32 top_limit =
std::max(layout.y_label_area.y, layout.y_label_area.bottom() - line_h);
for (u32 i = 0; i < plot.y_axis_count(); ++i) {
for (u32 i = 0; layout.y_label_area.w > 0.0f && i < plot.y_axis_count(); ++i) {
const Transform transform = plot.y_transform(i);
for (const Tick& tick : plot.y_axis(i).ticks) {
if (!tick.major || tick.label.empty()) continue;
@@ -1357,6 +1380,14 @@ u32 Renderer::stage_trace_slopes(const Plot& plot, const Trace& trace, const f32
}
void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
// **No solid trace while the density is on.** The trace ink *is* the density image (see
// `Plot::set_density()` and `draw_density()`): every visible trace was deposited, the newest
// acquisition included, so drawing the polyline as well would put a solid, unfading stroke over
// its own faint image -- the "persistent image + last acquisition" a report described, where the
// newest acquisition never faded because it was never only the image. The traces still exist --
// the legend, the crosshair readout and the measurements read them -- they are simply not drawn
// as lines.
if (plot.density().enabled) return;
// 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
// rendering choice rather than the collapsed plot it is.
@@ -1648,6 +1679,9 @@ void Renderer::draw_cursors(const Plot& plot, const PlotLayout& layout) {
// does not depend on the marker's own colour differing from the accent -- a cursor
// created in the accent colour is otherwise indistinguishable from an idle one.
const HotHandles hot = plot.hot_handles();
// Collected across the loop below and drawn clustered at its end -- see
// `draw_clustered_cursor_labels()` for why one panel can carry several lines.
std::vector<CursorLabel> labels;
for (u32 i = 0; i < plot.cursor_count(); ++i) {
const Cursor& cursor = plot.cursor(i);
@@ -1703,34 +1737,143 @@ void Renderer::draw_cursors(const Plot& plot, const PlotLayout& layout) {
flush_lines(mapping(plot, cursor.axis_x, 0, x_drawn),
on ? style_.cursor : cursor.color, width, cursor.dash);
}
draw_cursor_label(plot, layout, cursor, drawn);
// The labels are drawn after the strokes, clustered: collecting them all first is what
// lets two marks in the same column share one panel instead of overprinting each other.
collect_cursor_label(plot, layout, cursor, drawn, labels);
}
draw_clustered_cursor_labels(layout, labels);
}
void Renderer::draw_cursor_label(const Plot& plot, const PlotLayout& layout, const Cursor& cursor,
f32 at) {
const f32 gap = style_.device(3.0f);
void Renderer::collect_cursor_label(const Plot& plot, const PlotLayout& layout,
const Cursor& cursor, f32 at,
std::vector<CursorLabel>& labels) {
(void)layout;
if (cursor.is_horizontal()) {
// A ruler's text is the value it is *for*, written the way the y axis writes its own
// tick labels, so a value read off a ruler reads exactly like the tick beside it. The
// caller's caption, when there is one, is staged first on the same line, so neither the
// caption nor the value has to be dropped -- and both stay one text run's worth of
// glyphs, since the pen advances per glyph either way.
const Vec2 pen{layout.axes.x + gap, at + style_.device(2.0f)};
// caller's caption, when there is one, leads the same line.
char value[32];
plot.y_axis(cursor.axis_y).format_value(cursor.y, value, sizeof value);
if (cursor.label.empty()) {
stage_text(glyphs_under_, pen, value, style_.axis_text);
const std::string text =
cursor.label.empty() ? std::string(value) : cursor.label + " " + value;
CursorLabel label;
label.text = text;
label.border = cursor.color;
label.anchor = at; // the ruler's own row: the badge is centred on it
label.w = font_backend_.measure(text.c_str());
label.horizontal = true;
labels.push_back(std::move(label));
return;
}
stage_text(glyphs_under_, pen, cursor.label.c_str(), style_.axis_text);
stage_text(glyphs_under_, {pen.x + font_backend_.measure(cursor.label.c_str()) + gap, pen.y},
value, style_.axis_text);
return;
if (cursor.label.empty()) return;
CursorLabel label;
label.text = cursor.label;
label.border = cursor.color;
label.anchor = at; // the marker's own column: the badge is centred on it
label.w = font_backend_.measure(cursor.label.c_str());
labels.push_back(std::move(label));
}
void Renderer::draw_clustered_cursor_labels(const PlotLayout& layout,
const std::vector<CursorLabel>& labels) {
if (labels.empty()) return;
const f32 pad = style_.device(3.0f);
const f32 line_h = font_backend_.height();
const Rect& axes = layout.axes;
// **Placement first, clustering second.** Each badge is centred on the mark it belongs to
// -- a ruler's badge straddles the ruler's own row, a marker's badge straddles the
// marker's own column -- because a badge that drifts from its mark is a number without an
// anchor. When two badges meet, the later one drops one text line (downward from its
// centred position) until it is clear, so a column of close rulers reads as a stack of
// numbers in their own rows rather than as texts printed through each other; the rows keep
// each badge beside its own mark, which a sequential stack from the panel's top would not.
std::vector<CursorLabel> placed;
placed.reserve(labels.size());
std::vector<CursorLabel> by_anchor = labels;
std::stable_sort(by_anchor.begin(), by_anchor.end(),
[](const CursorLabel& a, const CursorLabel& b) { return a.anchor < b.anchor; });
for (CursorLabel& label : by_anchor) {
f32 x = 0.0f;
f32 y = 0.0f;
if (label.horizontal) {
x = axes.x + pad;
y = label.anchor - 0.5f * line_h; // centred on the ruler's own row
} else {
// Centred on the marker's column, held inside the axes: a badge half over an edge is
// half a badge.
x = std::clamp(label.anchor - 0.5f * label.w, axes.x + pad,
std::max(axes.x + pad, axes.right() - pad - label.w));
y = axes.y + pad;
}
for (;;) {
const Rect box{x - pad, y - pad, label.w + 2.0f * pad, line_h + 2.0f * pad};
bool meets = false;
for (const CursorLabel& p : placed) {
meets = box.x < p.box.x + p.box.w && p.box.x < box.x + box.w &&
box.y < p.box.y + p.box.h && p.box.y < box.y + box.h;
if (meets) break;
}
label.pen = Vec2{x, y};
label.box = box;
if (!meets) break;
y += line_h; // one row down: still beside the mark, no longer on its neighbour
}
placed.push_back(label);
}
// The panels: one per cluster of meeting backplates -- the boxes' union plus padding, the
// border the cluster's first label's own -- with every line staged at its *placed* pen, so
// the panel is a unified plate behind badges that each still sit on their own mark.
const f32 margin = style_.device(2.0f);
std::vector<Rect> bounds;
std::vector<std::vector<std::size_t>> groups;
std::vector<char> used(placed.size(), 0);
for (std::size_t i = 0; i < placed.size(); ++i) {
if (used[i] != 0) continue;
used[i] = 1;
bounds.push_back(placed[i].box);
groups.emplace_back(1, i);
Rect& u = bounds.back();
for (bool grew = true; grew;) {
grew = false;
for (std::size_t j = 0; j < placed.size(); ++j) {
if (used[j] != 0) continue;
const Rect inflated{u.x - margin, u.y - margin, u.w + 2.0f * margin,
u.h + 2.0f * margin};
const Rect& b = placed[j].box;
const bool meet = inflated.x < b.x + b.w && b.x < inflated.x + inflated.w &&
inflated.y < b.y + b.h && b.y < inflated.y + inflated.h;
if (!meet) continue;
used[j] = 1;
groups.back().push_back(j);
const f32 rx = std::min(u.x, b.x);
const f32 ry = std::min(u.y, b.y);
u = Rect{rx, ry, std::max(u.x + u.w, b.x + b.w) - rx,
std::max(u.y + u.h, b.y + b.h) - ry};
grew = true;
}
}
}
for (std::size_t g = 0; g < groups.size(); ++g) {
Rect panel = bounds[g];
panel.x -= pad;
panel.y -= pad;
panel.w += 2.0f * pad;
panel.h += 2.0f * pad;
// Inside the axes, shifted rather than clipped: a panel that hangs over an edge is a
// second plot; a panel nudged along it is still a readout of what it labels. Opaque: a
// badge is read against ticks and traces, and the badge's line beneath it is covered --
// the box interrupting its own ruler is what makes the two read as one instrument.
panel.x = std::clamp(panel.x, axes.x,
std::max(axes.x, axes.right() - panel.w));
panel.y = std::clamp(panel.y, axes.y,
std::max(axes.y, axes.bottom() - panel.h));
draw_readout_panel(panel, placed[groups[g].front()].border, 1.0f);
for (std::size_t k = 0; k < groups[g].size(); ++k) {
const CursorLabel& label = placed[groups[g][k]];
stage_text(glyphs_under_, label.pen, label.text.c_str(), style_.axis_text);
}
if (!cursor.label.empty()) {
stage_text(glyphs_under_, {at + gap, layout.axes.y + style_.device(2.0f)},
cursor.label.c_str(), style_.axis_text);
}
}
@@ -1740,6 +1883,117 @@ void Renderer::draw_invalid_spans(const PlotLayout& layout) {
}
}
void Renderer::draw_density(const Plot& plot, const PlotLayout& layout) {
const DensityConfig& config = plot.density();
if (!config.enabled) {
// A plot that has stopped drawing a density is not holding a stale one: forget what the shared
// texture holds, so a re-enable -- which drops the grids and rebuilds them, possibly at the same
// addresses and with fresh revisions -- re-uploads rather than trusting a cache entry that
// happens to match.
density_uploaded_grid_ = nullptr;
return;
}
if (!(layout.axes.w > 0.0f) || !(layout.axes.h > 0.0f)) return;
if (!density_program_.valid()) return; // a pipeline that never linked draws nothing
// One quad per trace, in that trace's own colour: the plot holds one grid per trace (see
// `DensityConfig`), so where two traces agree the viewer sees both colours, and a saturated cell
// is the colour of the trace whose ink filled it and not a blend. A trace with no visible series
// was deposited into nothing (its grid is empty), so it is skipped.
for (u32 i = 0u; i < plot.trace_count(); ++i) {
const Trace& trace = plot.trace(i);
if (!trace.visible()) continue;
draw_density_grid(config, plot.density_grid(i), trace.color, layout);
}
}
void Renderer::draw_density_grid(const DensityConfig& config, const DensityGrid& grid, Color tint,
const PlotLayout& layout) {
const u32 columns = grid.columns();
const u32 rows = grid.rows();
if (columns == 0u || rows == 0u) return;
// The image as the shader reads it: one byte per cell, `full_scale` and `gamma` already
// applied. The conversion lives here rather than in the fragment shader so that a cell's
// *count* has exactly one reading -- the plot's, which is also what a host reads back through
// `DensityGrid::ink()`/`peak()`, and which decays once per frame on the CPU anyway.
//
// **One texture, re-filled per trace.** The cache is keyed by the grid's own address as well as
// its revision, so a plot drawn twice with nothing changed (the steady state of a display that
// has stopped being swept) skips the conversion *and* the upload -- while a row of several traces
// re-fills the one texture once per trace, which is what a figure of several rows already cost
// before the density was per trace.
const bool resized = columns != density_w_ || rows != density_h_;
if (resized) {
if (!density_texture_.create_r8(columns, rows, nullptr)) {
gl_error_ = true;
return;
}
// **Linear, and it used to be a point sample.** The filter is the thing that makes a cell
// bigger than a pixel acceptable: a cell is a *count*, a continuous quantity, so a fragment
// between two of them is between their two ink levels -- which is exactly what a linear tap
// returns, and what a `GL_NEAREST` fetch cannot (it would show the grid's own steps as blocks
// once the image is scaled up, `kDensityCellCap` cells spread across a wider axes rect). The
// nearest tap was right only while a cell *was* a device pixel and every fragment landed in
// its own; minification interpolates for the same reason, so a plot whose rect is under the
// cap (cell and pixel one and the same, up to the rounding of `ceil`) reads as it always did.
density_texture_.set_filter(gl::GL_LINEAR, gl::GL_LINEAR);
density_w_ = columns;
density_h_ = rows;
density_uploaded_grid_ = nullptr; // a fresh texture holds nothing
}
if (resized || density_uploaded_grid_ != &grid || grid.revision() != density_uploaded_revision_) {
const f32 full = config.full_scale > 0.0f ? config.full_scale : 1.0f;
const f32 gamma = config.gamma > 0.0f ? config.gamma : 1.0f;
// The gamma through a 256-entry table rather than a `pow` per cell: the output *is* eight
// bits, so quantising the count first and lifting the quantised value costs nothing a
// per-cell `pow` would have bought, and it keeps the per-frame pass a loop of multiplies.
u8 lut[256];
for (u32 i = 0u; i < 256u; ++i) {
const f32 v = static_cast<f32>(i) / 255.0f;
lut[i] = static_cast<u8>(std::clamp(std::pow(v, gamma), 0.0f, 1.0f) * 255.0f + 0.5f);
}
density_pixels_.resize(grid.cell_count());
const f32* cells = grid.cells().data();
for (usize i = 0; i < density_pixels_.size(); ++i) {
const f32 v = cells[i] / full;
const u32 quantised =
static_cast<u32>(std::clamp(v, 0.0f, 1.0f) * 255.0f + 0.5f);
density_pixels_[i] = lut[std::min(quantised, 255u)];
}
// `upload_r8` sets GL_UNPACK_ALIGNMENT to 1 and leaves it there (see gl::Texture), so the
// host's own value is saved and put back -- the same care `TextAtlas::sync()` takes around
// its own upload, since the two share a context and a frame.
gl::GLint alignment = 4;
gl::glGetIntegerv(gl::GL_UNPACK_ALIGNMENT, &alignment);
density_texture_.upload_r8(columns, rows, density_pixels_.data());
gl::glPixelStorei(gl::GL_UNPACK_ALIGNMENT, alignment);
density_uploaded_grid_ = &grid;
density_uploaded_revision_ = grid.revision();
// += and not =: a figure draws a density per trace per row, and what this field reports is the
// frame's upload, not the last one's.
stats_.density_upload_bytes += columns * rows;
}
// The quad covers the image's own **footprint**: the axes rect rounded up to whole device pixels,
// the rect `Plot::size_density_grids()` counted the cells on. While that rect is under
// `kDensityCellCap` it is exactly `columns` by `rows` pixels -- one texel a pixel, the mapping
// this has always had -- and past the cap the grid is smaller and the image is magnified back
// over it. The uv still runs 0..1 over the quad, so a fragment between two cells is a linear
// blend of them: the filter the texture was created with.
density_program_.bind();
density_program_.set_viewport({viewport_.w, viewport_.h});
density_program_.set_rect({layout.axes.x, layout.axes.y},
{std::ceil(layout.axes.w), std::ceil(layout.axes.h)});
// The tint is the trace's own colour: its alpha is the trace's, so a saturated cell reads as the
// trace at its own brightness, and the shader multiplies it by the cell's luminance.
density_program_.set_color(tint);
density_texture_.bind();
density_vao_.bind();
gl::glDrawArrays(gl::GL_TRIANGLE_STRIP, 0, 4);
stats_.density_cells += columns * rows;
++stats_.draw_calls;
}
void Renderer::draw_regions(const Plot& plot, const PlotLayout& layout) {
const HotHandles hot = plot.hot_handles();
const f32 edge_w = style_.device(style_.line_width_px);
@@ -1885,7 +2139,7 @@ void Renderer::draw_legend(const Plot& plot, const PlotLayout& layout) {
// The panel first, so every entry is on top of it. A legend's border is the frame's axis colour;
// the readout's is the crosshair's own (see draw_crosshair_readout), which is the only thing that
// differs between the two panels -- so it is the one parameter the shared helper takes.
if (plot.legend().frame) draw_readout_panel(legend.panel, style_.axis_line);
if (plot.legend().frame) draw_readout_panel(legend.panel, style_.axis_line, kLegendPanelAlpha);
for (const LegendRow& row : legend.rows) {
// The truncation row names no trace: it has no swatch to colour and its text is
@@ -1915,7 +2169,7 @@ void Renderer::draw_x_note(const Plot& plot, const PlotLayout& layout) {
// A sliver of axes gets no caption rather than one hanging over the frame: the panel is chrome
// *inside* the axes, and a backplate crossing the axis line would read as a second plot.
if (panel.w > axes.w || panel.h > axes.h) return;
draw_readout_panel(panel, style_.axis_line);
draw_readout_panel(panel, style_.axis_line, kLegendPanelAlpha);
// Bottom-left, beside the labels it qualifies -- the reader adds the caption to a tick, so it
// belongs at the end of the tick row and not in a corner of its own. `glyphs_under_` because
// the panel is flushed with the frame's line geometry and the under-text batch goes out after
@@ -1984,12 +2238,13 @@ void Renderer::draw_selection_frame(const PlotLayout& layout) {
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, float background_alpha) {
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
// it -- the one backplate both readout panels use.
// The frame's own background scaled by the caller's opacity, which dims whatever it covers
// -- or, at full opacity, covers it outright: a badge read against ticks or a noisy trace
// is read *through* nothing.
Color background = style_.background;
background.a *= kLegendPanelAlpha;
background.a *= background_alpha;
fill_pixels(panel, background);
// The border frames the pixels the fill covers, not the measured rect's own fractional edges:
// those are wherever the content happened to measure to, and a hairline drawn on one is lit
@@ -2088,14 +2343,47 @@ void Renderer::draw_crosshair_lines(const Plot& plot, const PlotLayout& layout)
}
}
void Renderer::draw_crosshair_axis_badges(const PlotLayout& layout) {
const CrosshairLayout& crosshair = layout.crosshair;
if (!crosshair.active) return;
const bool y_badge = crosshair.y_axis_badge.w > 0.0f && !crosshair.y_axis_text.empty();
const bool x_badge = crosshair.x_axis_badge.w > 0.0f && !crosshair.x_axis_text.empty();
if (!y_badge && !x_badge) return;
// The same backplate the readout panel has, its border in the crosshair's colour too --
// the badge is that panel's value column, moved to where the line meets the axis it
// names. Drawn here rather than in the readout pass because it sits in a *label strip*:
// the strips are drawn after the interior clip is cleared, so a badge that lived in the
// clipped pass would be trimmed to nothing.
//
// **The tick labels are flushed first, and that is the whole point of this panel.** They
// are staged into the over-text batch, which the frame flushes after *every* line batch --
// so a fill drawn straight away would sit under them and the label it covers would print
// through the badge (the "transparent" backplate). They are already placed at this point,
// so pushing them out costs one batch: the fill then covers them, and the badge's own text
// is staged after both fills and leaves with the rest of the over batch, over everything.
flush_text(glyphs_over_);
const f32 pad = style_.device(4.0f);
if (y_badge) draw_readout_panel(crosshair.y_axis_badge, style_.crosshair, 1.0f);
if (x_badge) draw_readout_panel(crosshair.x_axis_badge, style_.crosshair, 1.0f);
if (y_badge) {
stage_text(glyphs_over_,
{crosshair.y_axis_badge.x + pad, crosshair.y_axis_badge.y + pad},
crosshair.y_axis_text.c_str(), style_.axis_text);
}
if (x_badge) {
stage_text(glyphs_over_,
{crosshair.x_axis_badge.x + pad, crosshair.x_axis_badge.y + pad},
crosshair.x_axis_text.c_str(), style_.axis_text);
}
}
void Renderer::draw_crosshair_readout(const Plot& plot, const PlotLayout& layout) {
const CrosshairLayout& crosshair = layout.crosshair;
if (crosshair.empty()) return;
// The readout: the same backplate a legend has (the shared `draw_readout_panel()`), with its
// border in the crosshair's own colour -- which is what makes the panel read as part of the
// instrument that placed it rather than as a second legend.
draw_readout_panel(crosshair.panel, style_.crosshair);
draw_readout_panel(crosshair.panel, style_.crosshair, kLegendPanelAlpha);
for (const CrosshairRow& row : crosshair.rows) {
if (!row.listed) continue;
+44
View File
@@ -235,4 +235,48 @@ void main() {
}
)";
const char* const kDensityVS = R"(#version 330 core
// No vertex attributes at all: the image is one axis-aligned rectangle whose corners are
// uniform state, so the quad comes from gl_VertexID alone and there is no vertex buffer to
// fill. The density's own bytes go to a texture (see kDensityFS), never to a buffer.
uniform vec2 u_viewport; // render target size, pixels
uniform vec2 u_origin; // the quad's top-left corner in Rect pixels
uniform vec2 u_size; // the quad's size in pixels -- the axes rect, which the image fills
out vec2 v_uv;
void main() {
// A four-vertex triangle strip, like the line and text pipelines: the corner index is the
// vertex id's two bits, so 0,1,2,3 are (0,0), (1,0), (0,1), (1,1).
vec2 corner = vec2(float(gl_VertexID & 1), float((gl_VertexID >> 1) & 1));
vec2 px = u_origin + corner * u_size;
// Rect is top-left origin, GL is bottom-left: the one and only y flip.
vec2 ndc = vec2(px.x / u_viewport.x * 2.0 - 1.0, 1.0 - px.y / u_viewport.y * 2.0);
gl_Position = vec4(ndc, 0.0, 1.0);
// uv runs 0..1 across the whole image, so a fragment lands wherever the axes rect puts it
// between the grid's cells: one texel a cell while the rect is under the cap (a cell a device
// pixel, the sampling all but a point sample) and a linear magnification of the image past it.
v_uv = corner;
}
)";
const char* const kDensityFS = R"(#version 330 core
in vec2 v_uv; // cell coordinate in [0,1] across the quad
uniform sampler2D u_density; // GL_R8 luminance, one texel per cell, GL_LINEAR
uniform vec4 u_color;
out vec4 o_color;
void main() {
// The cell's count was already turned into a luminance on the CPU, where the count, the
// decay and the gamma live -- so this stage has no scale of its own that a host reading the
// grid's numbers back could disagree with. All that is left is the tint: the density is drawn
// once per trace, in that trace's own colour (the caller's `u_color`), so a saturated cell is
// the trace at its own brightness and a faint one is the same hue at a lower alpha.
float ink = texture(u_density, v_uv).r;
o_color = vec4(u_color.rgb, u_color.a * ink);
}
)";
} // namespace plot::render
+553
View File
@@ -16,6 +16,7 @@
#include <plot/render/embedded_mono_font.hpp>
#include <cmath>
#include <span>
#include <vector>
namespace {
@@ -1141,3 +1142,555 @@ PLOT_TEST(figure_crosshair_draws_a_horizontal_line_only_in_the_hovered_row) {
PLOT_CHECK(figure.plot(1u).layout().crosshair.vertical.w > 0.0f);
PLOT_CHECK(figure.plot(1u).layout().crosshair.horizontal.h == 0.0f);
}
// ------------------------------------------------------------------ cell grid
namespace {
/// A figure whose rows hold `cells[r]` plots each (the flattened plots in row-major
/// order), with an x and a y axis on every plot unless `axes` is off.
plot::Figure split_figure(const std::vector<plot::u32>& cells, bool axes = true) {
plot::Figure figure{std::span<const plot::u32>(cells)}; // braces: parentheses would be a vexing parse
figure.set_viewport(whole_figure());
if (axes) {
for (plot::u32 i = 0; i < figure.plot_count(); ++i) {
figure.plot(i).add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
figure.plot(i).add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
}
}
return figure;
}
} // namespace
PLOT_TEST(figure_cells_all_ones_equals_rows) {
plot::Figure split = split_figure({1, 1, 1});
plot::Figure plain = figure_with_axes(3u);
const plot::FigureLayout& sl = split.layout();
const plot::FigureLayout& pl = plain.layout();
PLOT_CHECK(split.row_count() == 3u && split.plot_count() == 3u);
PLOT_CHECK(sl.cells.size() == 3u);
for (plot::u32 i = 0; i < 3u; ++i) {
PLOT_CHECK_NEAR(sl.cells[i].y, pl.rows[i].y, 0.01);
PLOT_CHECK_NEAR(sl.cells[i].h, pl.rows[i].h, 0.01);
PLOT_CHECK_NEAR(sl.cells[i].w, pl.rows[i].w, 0.01);
}
}
PLOT_TEST(figure_cells_tile_exactly) {
plot::Figure figure = split_figure({1, 2, 1});
figure.set_col_gap(10.0f);
const plot::FigureLayout& fl = figure.layout();
PLOT_CHECK(figure.row_count() == 3u && figure.plot_count() == 4u);
PLOT_CHECK(figure.cells_in_row(1u) == 2u);
PLOT_CHECK(figure.first_plot_of_row(1u) == 1u && figure.first_plot_of_row(3u) == 4u);
PLOT_CHECK(figure.row_of_plot(2u) == 1u);
PLOT_CHECK_NEAR(fl.cells[2].x - fl.cells[1].right(), 10.0, 0.01);
PLOT_CHECK_NEAR(fl.cells[2].right(), fl.rows[1].right(), 0.001);
PLOT_CHECK_NEAR(fl.cells[1].x, fl.rows[1].x, 0.001);
PLOT_CHECK_NEAR(fl.cells[1].y, fl.cells[2].y, 0.001);
PLOT_CHECK_NEAR(fl.cells[1].h, fl.cells[2].h, 0.001);
PLOT_CHECK_NEAR(fl.cells[1].w, fl.cells[2].w, 0.01);
PLOT_CHECK_NEAR(fl.col_gap, 10.0, 0.001);
}
PLOT_TEST(figure_cell_weights_and_floor) {
plot::Figure figure = split_figure({2});
figure.set_col_gap(0.0f);
const std::vector<plot::f32> w{3.0f, 1.0f};
PLOT_CHECK(figure.set_cell_weights(0u, w));
PLOT_CHECK(figure.has_cell_weights(0u));
{
const auto& c = figure.layout().cells;
PLOT_CHECK_NEAR((c[0].w - 64.0), (c[1].w - 64.0) * 3.0, 0.01);
}
const std::vector<plot::f32> tiny{1000.0f, 0.001f};
PLOT_CHECK(figure.set_cell_weights(0u, tiny));
PLOT_CHECK(figure.layout().cells[1].w >= 64.0f - 0.001f);
const std::vector<plot::f32> three{1.0f, 1.0f, 1.0f};
const std::vector<plot::f32> zero{1.0f, 0.0f};
PLOT_CHECK(!figure.set_cell_weights(0u, three));
PLOT_CHECK(!figure.set_cell_weights(0u, zero));
PLOT_CHECK(!figure.set_cell_weights(5u, w));
figure.clear_cell_weights(0u);
PLOT_CHECK(!figure.has_cell_weights(0u));
const auto& c = figure.layout().cells;
PLOT_CHECK_NEAR(c[0].w, c[1].w, 0.01);
}
PLOT_TEST(figure_cells_narrow_degrades) {
plot::Figure figure = split_figure({4}, false);
figure.set_viewport(plot::Rect{0.0f, 0.0f, 20.0f, 100.0f});
for (plot::u32 i = 0; i < 4u; ++i) {
figure.plot(i).add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
figure.plot(i).add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
}
figure.set_col_gap(10.0f);
const plot::FigureLayout& fl = figure.layout();
double sum = 0.0;
for (const plot::Rect& c : fl.cells) {
PLOT_CHECK(c.w >= 0.0f && c.h >= 0.0f);
PLOT_CHECK_NEAR(c.h, 100.0, 0.01);
sum += c.w;
}
PLOT_CHECK_NEAR(sum, 20.0, 0.01);
PLOT_CHECK_NEAR(fl.col_gap, 0.0, 0.001);
}
PLOT_TEST(figure_cells_share_x_link_and_crosshair) {
plot::Figure figure = split_figure({2, 1});
const plot::u32 group = figure.add_x_link();
for (plot::u32 i = 0; i < 3u; ++i) PLOT_CHECK(figure.link_x(i, group));
figure.plot(1u).set_x_range(0u, {3.0, 9.0});
figure.layout();
for (plot::u32 i = 0; i < 3u; ++i) {
PLOT_CHECK_NEAR(figure.plot(i).x_range(0u).min, 3.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(i).x_range(0u).max, 9.0, 1e-9);
}
figure.set_crosshair(4.0, true);
for (plot::u32 i = 0; i < 3u; ++i) {
PLOT_CHECK(figure.plot(i).crosshair().active);
PLOT_CHECK_NEAR(figure.plot(i).crosshair().x, 4.0, 1e-9);
}
figure.clear_crosshair();
for (plot::u32 i = 0; i < 3u; ++i) PLOT_CHECK(!figure.plot(i).crosshair().active);
}
PLOT_TEST(figure_cells_x_labels_bottom_row_only) {
// Rows with the same columns share the bottom one's axis: {1,1,1} labels the last row only.
plot::Figure figure = split_figure({1, 1, 1});
const plot::FigureLayout& fl = figure.layout();
PLOT_CHECK(figure.plot(0u).layout().x_label_area.h == 0.0f);
PLOT_CHECK(figure.plot(1u).layout().x_label_area.h == 0.0f);
PLOT_CHECK(figure.plot(2u).layout().x_label_area.h > 0.0f);
PLOT_CHECK_NEAR(fl.x_axis_strip.h, figure.plot(2u).layout().x_label_area.h, 0.001);
}
PLOT_TEST(figure_cells_common_y_gutter) {
plot::Figure figure = split_figure({2}, false);
give_magnitude_row(figure.plot(0u), 0u);
give_magnitude_row(figure.plot(1u), 1u);
const plot::FigureLayout& fl = figure.layout();
const double g0 = figure.plot(0u).layout().axes.x - fl.cells[0].x;
const double g1 = figure.plot(1u).layout().axes.x - fl.cells[1].x;
PLOT_CHECK(g0 > 0.0);
PLOT_CHECK_NEAR(g0, g1, 0.01);
}
PLOT_TEST(figure_row_y_cells_share_a_range_after_a_gesture) {
plot::Figure figure = split_figure({2, 1});
figure.set_share_row_y(true);
figure.layout();
figure.plot(1u).set_y_range(0u, {-3.0, 7.0}); // what a wheel / drag / pan ends in
figure.layout();
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).min, -3.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).max, 7.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(1u).y_range(0u).min, -3.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(1u).y_range(0u).max, 7.0, 1e-9);
// The unsplit row is its own thing.
PLOT_CHECK_NEAR(figure.plot(2u).y_range(0u).min, -1.0, 1e-9);
PLOT_CHECK(!figure.row_y_auto(0u));
}
/// The horizontal line is one *value*, and a split row's cells share one y scale: the line
/// spans the whole row instead of stopping at the divider between two cells that read the same
/// numbers. The rows of the figure still do not share y, so no other row gets one.
PLOT_TEST(figure_crosshair_horizontal_spans_a_split_rows_cells) {
plot::Figure figure = split_figure({2, 1});
figure.set_share_row_y(true);
figure.layout();
// The pointer in the *second* cell of the split row, half-way down it.
const plot::Rect cell1 = figure.layout().cells[1];
const plot::Transform ty = figure.plot(1u).y_transform(0u);
const plot::f32 y = static_cast<plot::f32>(ty.to_data_y(cell1.y + 0.5f * cell1.h));
figure.set_crosshair(50.0, 1u, y, true);
figure.layout();
const plot::CrosshairLayout& left = figure.plot(0u).layout().crosshair;
const plot::CrosshairLayout& right = figure.plot(1u).layout().crosshair;
const plot::CrosshairLayout& other = figure.plot(2u).layout().crosshair;
// Both cells of the row carry the line, at one screen row: they share the y scale, so the
// row's own y is the same value's height in each.
PLOT_CHECK(left.horizontal.w > 0.0f);
PLOT_CHECK(right.horizontal.w > 0.0f);
PLOT_CHECK_NEAR(left.horizontal.y + 0.5f * left.horizontal.h,
right.horizontal.y + 0.5f * right.horizontal.h, 0.75);
// The unsplit row keeps the shared-x vertical line and no horizontal one -- a line there
// would mean a value on a scale that row does not have.
PLOT_CHECK(other.vertical.w > 0.0f);
PLOT_CHECK(other.horizontal.h == 0.0f);
// The spread does not touch the x: both cells keep the one shared column.
PLOT_CHECK(left.vertical.w > 0.0f);
PLOT_CHECK(right.vertical.w > 0.0f);
// Without `set_share_row_y` the cells are separate scales again, and the line is the
// hovered cell's alone.
figure.set_share_row_y(false);
figure.set_crosshair(50.0, 1u, y, true);
figure.layout();
PLOT_CHECK(figure.plot(1u).layout().crosshair.horizontal.w > 0.0f);
PLOT_CHECK(figure.plot(0u).layout().crosshair.horizontal.h == 0.0f);
}
PLOT_TEST(figure_row_y_auto_fits_the_union_and_ignores_empty_cells) {
plot::Figure figure = split_figure({3});
for (plot::u32 i = 0; i < 3u; ++i) figure.plot(i).set_y_auto_scale(0u, true);
plot::data::ArraySeries a(std::vector<plot::f32>{0.0f, 10.0f, 5.0f}, 0.0, 1.0);
plot::data::ArraySeries b(std::vector<plot::f32>{-20.0f, 5.0f, 0.0f}, 0.0, 1.0);
figure.plot(0u).add_trace({&a, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
figure.plot(1u).add_trace({&b, plot::Color::hex(0xFF8080), 1.5f, 0u, 0u});
figure.set_share_row_y(true); // cell 2 has no trace
figure.layout();
PLOT_CHECK(figure.row_y_auto(0u));
const plot::Range r0 = figure.plot(0u).y_range(0u);
PLOT_CHECK(r0.min <= -20.0 && r0.max >= 10.0);
PLOT_CHECK(r0.max - r0.min < 40.0); // padded, not blown up
for (plot::u32 i = 1; i < 3u; ++i) {
PLOT_CHECK_NEAR(figure.plot(i).y_range(0u).min, r0.min, 1e-9);
PLOT_CHECK_NEAR(figure.plot(i).y_range(0u).max, r0.max, 1e-9);
}
}
PLOT_TEST(figure_row_y_manual_then_fit_returns_to_auto) {
plot::Figure figure = split_figure({2});
for (plot::u32 i = 0; i < 2u; ++i) figure.plot(i).set_y_auto_scale(0u, true);
plot::data::ArraySeries a(std::vector<plot::f32>{0.0f, 10.0f}, 0.0, 1.0);
plot::data::ArraySeries b(std::vector<plot::f32>{-4.0f, 4.0f}, 0.0, 1.0);
figure.plot(0u).add_trace({&a, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
figure.plot(1u).add_trace({&b, plot::Color::hex(0xFF8080), 1.5f, 0u, 0u});
figure.set_share_row_y(true);
figure.layout();
figure.plot(1u).set_y_range(0u, {1.0, 2.0});
figure.layout();
PLOT_CHECK(!figure.row_y_auto(0u));
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).min, 1.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).max, 2.0, 1e-9);
figure.layout(); // manual holds: no fight with the plots' own autoscale
PLOT_CHECK_NEAR(figure.plot(1u).y_range(0u).max, 2.0, 1e-9);
figure.plot(0u).auto_fit_y(0u); // what fit-all / double-click does to one cell
figure.layout();
PLOT_CHECK(figure.row_y_auto(0u));
PLOT_CHECK(figure.plot(1u).y_range(0u).min <= -4.0 && figure.plot(1u).y_range(0u).max >= 10.0);
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).min, figure.plot(1u).y_range(0u).min, 1e-9);
}
PLOT_TEST(figure_row_y_sharing_off_keeps_cells_independent) {
plot::Figure figure = split_figure({2});
figure.layout();
figure.plot(1u).set_y_range(0u, {-3.0, 7.0});
figure.layout();
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).max, 1.0, 1e-9);
PLOT_CHECK(!figure.share_row_y());
}
PLOT_TEST(figure_row_y_only_the_first_cell_has_a_gutter) {
plot::Figure figure = split_figure({1, 3}, false);
for (plot::u32 i = 0; i < figure.plot_count(); ++i) give_magnitude_row(figure.plot(i), 0u);
figure.set_share_row_y(true);
figure.layout();
PLOT_CHECK(figure.plot(0u).layout().y_axis_width > 0.0f); // unsplit row keeps its axis
PLOT_CHECK(figure.plot(1u).layout().y_axis_width > 0.0f); // first cell of the split row
for (plot::u32 i = 2; i < 4u; ++i) {
const plot::PlotLayout& l = figure.plot(i).layout();
PLOT_CHECK(l.y_axis_width == 0.0f && l.y_title_width == 0.0f && l.y_label_area.w == 0.0f);
PLOT_CHECK(l.axes.w > 0.0f);
}
// The gutter is one figure-wide value: the unsplit row and the split row's first cell agree.
PLOT_CHECK_NEAR(figure.plot(0u).layout().axes.x - figure.layout().cells[0].x,
figure.plot(1u).layout().axes.x - figure.layout().cells[1].x, 0.01);
}
PLOT_TEST(figure_row_y_equal_weights_give_equal_plot_widths_and_cells_tile) {
plot::Figure figure = split_figure({3}, false);
for (plot::u32 i = 0; i < 3u; ++i) give_magnitude_row(figure.plot(i), 0u);
figure.set_col_gap(8.0f);
figure.set_share_row_y(true);
const plot::FigureLayout& fl = figure.layout();
const double w0 = figure.plot(0u).layout().axes.w;
PLOT_CHECK_NEAR(figure.plot(1u).layout().axes.w, w0, 0.6);
PLOT_CHECK_NEAR(figure.plot(2u).layout().axes.w, w0, 0.6);
PLOT_CHECK_NEAR(fl.cells[0].x, fl.rows[0].x, 0.001);
PLOT_CHECK_NEAR(fl.cells[2].right(), fl.rows[0].right(), 0.001);
PLOT_CHECK_NEAR(fl.cells[1].x - fl.cells[0].right(), 8.0, 0.01);
PLOT_CHECK_NEAR(fl.cells[2].x - fl.cells[1].right(), 8.0, 0.01);
}
PLOT_TEST(figure_row_y_weights_apply_to_plot_widths) {
plot::Figure figure = split_figure({2}, false);
for (plot::u32 i = 0; i < 2u; ++i) give_magnitude_row(figure.plot(i), 0u);
figure.set_col_gap(0.0f);
figure.set_share_row_y(true);
const std::vector<plot::f32> w{3.0f, 1.0f};
PLOT_CHECK(figure.set_cell_weights(0u, w));
figure.layout();
const double a0 = figure.plot(0u).layout().axes.w;
const double a1 = figure.plot(1u).layout().axes.w;
PLOT_CHECK_NEAR(a0 - 64.0, (a1 - 64.0) * 3.0, 1.0);
}
PLOT_TEST(figure_row_y_narrow_row_degrades_without_negative_rects) {
plot::Figure figure = split_figure({4}, false);
figure.set_viewport(plot::Rect{0.0f, 0.0f, 30.0f, 100.0f});
for (plot::u32 i = 0; i < 4u; ++i) give_magnitude_row(figure.plot(i), 0u);
figure.set_col_gap(10.0f);
figure.set_share_row_y(true);
double sum = 0.0;
for (const plot::Rect& c : figure.layout().cells) {
PLOT_CHECK(c.w >= 0.0f && c.h >= 0.0f);
sum += c.w;
}
PLOT_CHECK_NEAR(sum, 30.0, 0.05);
}
PLOT_TEST(figure_row_y_sharing_off_still_gives_every_cell_a_gutter) {
plot::Figure figure = split_figure({2}, false);
give_magnitude_row(figure.plot(0u), 0u);
give_magnitude_row(figure.plot(1u), 1u);
figure.layout();
PLOT_CHECK(figure.plot(1u).layout().y_axis_width > 0.0f);
}
// ------------------------------------------------- x axes follow the columns
namespace {
/// A split figure with a text backend and a y axis of a real magnitude on every plot (so the
/// shared y gutter is non-zero), the host's configuration: row-shared y, a 6 px column gap and
/// a 35 px right gutter.
plot::Figure host_like_figure(const std::vector<plot::u32>& cells, bool share_y = true) {
plot::Figure figure = split_figure(cells, false);
for (plot::u32 i = 0; i < figure.plot_count(); ++i) give_magnitude_row(figure.plot(i), i);
figure.set_col_gap(6.0f);
figure.set_right_gutter(35.0f);
figure.set_share_row_y(share_y);
return figure;
}
bool shows_x_axis(plot::Figure& figure, plot::u32 plot_index) {
const plot::PlotLayout& l = figure.plot(plot_index).layout();
return l.x_axis_height > 0.0f && l.x_label_area.h > 0.0f;
}
bool hides_x_axis(plot::Figure& figure, plot::u32 plot_index) {
const plot::PlotLayout& l = figure.plot(plot_index).layout();
return l.x_axis_height == 0.0f && l.x_label_area.h == 0.0f;
}
} // namespace
PLOT_TEST(figure_x_axis_a_split_row_over_an_unsplit_one_keeps_its_own) {
plot::Figure figure = host_like_figure({2, 1});
figure.layout();
PLOT_CHECK(shows_x_axis(figure, 0u));
PLOT_CHECK(shows_x_axis(figure, 1u));
PLOT_CHECK(shows_x_axis(figure, 2u));
// ... and the other way round.
plot::Figure other = host_like_figure({1, 2});
other.layout();
PLOT_CHECK(shows_x_axis(other, 0u));
PLOT_CHECK(shows_x_axis(other, 1u));
PLOT_CHECK(shows_x_axis(other, 2u));
}
PLOT_TEST(figure_x_axis_rows_with_the_same_columns_share_the_bottom_axis) {
plot::Figure figure = host_like_figure({2, 2});
figure.layout();
PLOT_CHECK(hides_x_axis(figure, 0u));
PLOT_CHECK(hides_x_axis(figure, 1u));
PLOT_CHECK(shows_x_axis(figure, 2u));
PLOT_CHECK(shows_x_axis(figure, 3u));
// The hidden row's cells have exactly the x extents of the cells below, so the axis below
// serves them.
const plot::FigureLayout& fl = figure.layout();
for (plot::u32 c = 0; c < 2u; ++c) {
PLOT_CHECK_NEAR(fl.cells[c].x, fl.cells[2u + c].x, 1e-4);
PLOT_CHECK_NEAR(fl.cells[c].w, fl.cells[2u + c].w, 1e-4);
const plot::PlotLayout& up = figure.plot(c).layout();
const plot::PlotLayout& down = figure.plot(2u + c).layout();
PLOT_CHECK_NEAR(up.axes.x, down.axes.x, 1e-4);
PLOT_CHECK_NEAR(up.axes.w, down.axes.w, 1e-4);
PLOT_CHECK_NEAR(up.y_axis_width, down.y_axis_width, 1e-4);
}
// The same without row-shared y (every cell has its own y strip).
plot::Figure own_y = host_like_figure({2, 2}, false);
own_y.layout();
PLOT_CHECK(hides_x_axis(own_y, 0u) && hides_x_axis(own_y, 1u));
PLOT_CHECK(shows_x_axis(own_y, 2u) && shows_x_axis(own_y, 3u));
for (plot::u32 c = 0; c < 2u; ++c) {
PLOT_CHECK_NEAR(own_y.plot(c).layout().axes.x, own_y.plot(2u + c).layout().axes.x, 1e-4);
PLOT_CHECK_NEAR(own_y.plot(c).layout().axes.w, own_y.plot(2u + c).layout().axes.w, 1e-4);
}
}
PLOT_TEST(figure_x_axis_matching_compares_normalised_weights) {
const std::vector<plot::f32> one_three{1.0f, 3.0f};
const std::vector<plot::f32> two_six{2.0f, 6.0f};
const std::vector<plot::f32> halves{1.0f, 1.0f};
{ // 1:3 over 1:3
plot::Figure figure = host_like_figure({2, 2});
PLOT_CHECK(figure.set_cell_weights(0u, one_three));
PLOT_CHECK(figure.set_cell_weights(1u, one_three));
figure.layout();
PLOT_CHECK(hides_x_axis(figure, 0u) && hides_x_axis(figure, 1u));
PLOT_CHECK(shows_x_axis(figure, 2u) && shows_x_axis(figure, 3u));
const plot::FigureLayout& fl = figure.layout();
PLOT_CHECK_NEAR(fl.cells[0].w, fl.cells[2].w, 1e-3);
PLOT_CHECK_NEAR(figure.plot(1u).layout().axes.x, figure.plot(3u).layout().axes.x, 1e-3);
}
{ // 1:3 over 2:6 -- normalised, so the same columns
plot::Figure figure = host_like_figure({2, 2});
PLOT_CHECK(figure.set_cell_weights(0u, one_three));
PLOT_CHECK(figure.set_cell_weights(1u, two_six));
figure.layout();
PLOT_CHECK(hides_x_axis(figure, 0u) && hides_x_axis(figure, 1u));
}
{ // 1:3 over 1:1
plot::Figure figure = host_like_figure({2, 2});
PLOT_CHECK(figure.set_cell_weights(0u, one_three));
PLOT_CHECK(figure.set_cell_weights(1u, halves));
figure.layout();
PLOT_CHECK(shows_x_axis(figure, 0u) && shows_x_axis(figure, 1u));
PLOT_CHECK(shows_x_axis(figure, 2u) && shows_x_axis(figure, 3u));
}
{ // 1:3 over (no weights = equal)
plot::Figure figure = host_like_figure({2, 2});
PLOT_CHECK(figure.set_cell_weights(0u, one_three));
figure.layout();
PLOT_CHECK(shows_x_axis(figure, 0u) && shows_x_axis(figure, 1u));
}
{ // explicit 1:1 over (no weights = equal)
plot::Figure figure = host_like_figure({2, 2});
PLOT_CHECK(figure.set_cell_weights(0u, halves));
figure.layout();
PLOT_CHECK(hides_x_axis(figure, 0u) && hides_x_axis(figure, 1u));
}
{ // a different number of cells never matches, whatever the weights
plot::Figure figure = host_like_figure({3, 2});
figure.layout();
PLOT_CHECK(shows_x_axis(figure, 0u) && shows_x_axis(figure, 2u));
}
}
PLOT_TEST(figure_x_axis_unsplit_rows_are_unchanged) {
plot::Figure figure = host_like_figure({1, 1, 1});
figure.layout();
PLOT_CHECK(hides_x_axis(figure, 0u));
PLOT_CHECK(hides_x_axis(figure, 1u));
PLOT_CHECK(shows_x_axis(figure, 2u));
PLOT_CHECK_NEAR(figure.layout().x_axis_strip.h, figure.plot(2u).layout().x_label_area.h, 1e-4);
}
PLOT_TEST(figure_x_axis_follows_a_chain_of_matching_rows) {
{ // [2]/[2]/[1]: row 0 matches row 1 and hides; row 1 does not match row 2 and shows.
plot::Figure figure = host_like_figure({2, 2, 1});
figure.layout();
PLOT_CHECK(hides_x_axis(figure, 0u) && hides_x_axis(figure, 1u));
PLOT_CHECK(shows_x_axis(figure, 2u) && shows_x_axis(figure, 3u));
PLOT_CHECK(shows_x_axis(figure, 4u));
}
{ // [2]/[2]/[2]: transitive -- only the last row carries the axis.
plot::Figure figure = host_like_figure({2, 2, 2});
figure.layout();
for (plot::u32 i = 0; i < 4u; ++i) PLOT_CHECK(hides_x_axis(figure, i));
PLOT_CHECK(shows_x_axis(figure, 4u) && shows_x_axis(figure, 5u));
}
{ // [1]/[2]/[2]: the unsplit row matches nothing below it.
plot::Figure figure = host_like_figure({1, 2, 2});
figure.layout();
PLOT_CHECK(shows_x_axis(figure, 0u));
PLOT_CHECK(hides_x_axis(figure, 1u) && hides_x_axis(figure, 2u));
PLOT_CHECK(shows_x_axis(figure, 3u) && shows_x_axis(figure, 4u));
}
{ // the figure's x_axis_strip is still the last plot's label area
plot::Figure figure = host_like_figure({2, 1});
const plot::FigureLayout& fl = figure.layout();
PLOT_CHECK(fl.x_axis_strip.h > 0.0f);
PLOT_CHECK_NEAR(fl.x_axis_strip.h, figure.plot(2u).layout().x_label_area.h, 1e-4);
}
}
PLOT_TEST(figure_x_axis_labels_on_every_row_when_asked) {
plot::Figure figure = host_like_figure({2, 2, 1});
figure.set_x_labels_only_on_bottom(false);
figure.layout();
for (plot::u32 i = 0; i < figure.plot_count(); ++i) PLOT_CHECK(shows_x_axis(figure, i));
}
// -------------------------------------------- the gap between cells of a row
PLOT_TEST(figure_split_row_gap_between_plot_frames_is_the_column_gap) {
for (const plot::u32 n : {2u, 3u, 4u}) {
plot::Figure figure = host_like_figure({n});
const plot::FigureLayout& fl = figure.layout();
PLOT_CHECK_NEAR(fl.col_gap, 6.0, 1e-4);
for (plot::u32 k = 0; k + 1u < n; ++k) {
PLOT_CHECK_NEAR(figure.plot(k + 1u).layout().axes.x - figure.plot(k).layout().axes.right(),
6.0, 1e-3);
PLOT_CHECK_NEAR(fl.cells[k + 1u].x - fl.cells[k].right(), 6.0, 1e-3);
// Only the last cell holds the right gutter.
PLOT_CHECK_NEAR(figure.plot(k).layout().right_gutter, 0.0, 1e-6);
}
// Equal weights give equal axes widths, the first cell included.
for (plot::u32 k = 1; k < n; ++k) {
PLOT_CHECK_NEAR(figure.plot(k).layout().axes.w, figure.plot(0u).layout().axes.w, 0.6);
}
// The cells tile the row exactly.
PLOT_CHECK_NEAR(fl.cells[0].x, fl.rows[0].x, 1e-4);
PLOT_CHECK_NEAR(fl.cells[n - 1u].right(), fl.rows[0].right(), 1e-3);
// The right gutter is still there, on the last cell.
const plot::PlotLayout& last = figure.plot(n - 1u).layout();
PLOT_CHECK_NEAR(fl.cells[n - 1u].right() - last.axes.right(), 35.0, 1e-3);
PLOT_CHECK_NEAR(last.right_gutter, 35.0, 1e-3);
// The y gutter still sits on the first cell alone.
PLOT_CHECK(figure.plot(0u).layout().axes.x > fl.cells[0].x);
PLOT_CHECK_NEAR(figure.plot(1u).layout().axes.x, fl.cells[1].x, 1e-4);
}
}
PLOT_TEST(figure_split_row_gap_with_weights_and_a_y_gutter) {
plot::Figure figure = host_like_figure({1, 2});
const std::vector<plot::f32> w{3.0f, 1.0f};
PLOT_CHECK(figure.set_cell_weights(1u, w));
const plot::FigureLayout& fl = figure.layout();
const plot::Rect a0 = figure.plot(1u).layout().axes;
const plot::Rect a1 = figure.plot(2u).layout().axes;
PLOT_CHECK_NEAR(a1.x - a0.right(), 6.0, 1e-3);
PLOT_CHECK_NEAR(a0.w - 64.0, (a1.w - 64.0) * 3.0, 1.0);
PLOT_CHECK_NEAR(fl.cells[2].right(), fl.rows[1].right(), 1e-3);
PLOT_CHECK_NEAR(fl.cells[1].x, fl.rows[1].x, 1e-4);
PLOT_CHECK_NEAR(fl.cells[2].right() - a1.right(), 35.0, 1e-3);
// The unsplit row above keeps its own right gutter on its one plot.
PLOT_CHECK_NEAR(fl.cells[0].right() - figure.plot(0u).layout().axes.right(), 35.0, 1e-3);
}
PLOT_TEST(figure_split_row_gutters_degrade_on_a_narrow_row) {
plot::Figure figure = host_like_figure({3});
figure.set_viewport(plot::Rect{0.0f, 0.0f, 60.0f, 100.0f});
const plot::FigureLayout& fl = figure.layout();
double sum = 0.0;
for (const plot::Rect& c : fl.cells) {
PLOT_CHECK(c.w >= 0.0f && c.h >= 0.0f);
sum += c.w;
}
PLOT_CHECK_NEAR(sum + fl.col_gap * 2.0, 60.0, 0.05);
PLOT_CHECK_NEAR(fl.cells[2].right(), 60.0, 0.05);
for (plot::u32 i = 0; i < 3u; ++i) PLOT_CHECK(figure.plot(i).layout().axes.w >= 0.0f);
}
PLOT_TEST(figure_row_gutter_without_sharing_is_the_old_layout) {
plot::Figure figure = host_like_figure({2}, false);
const plot::FigureLayout& fl = figure.layout();
// Plain cells: equal halves of the row less the gap, every plot with its own right gutter.
PLOT_CHECK_NEAR(fl.cells[0].w, (fl.rows[0].w - 6.0) / 2.0, 1e-3);
PLOT_CHECK_NEAR(fl.cells[1].w, (fl.rows[0].w - 6.0) / 2.0, 1e-3);
PLOT_CHECK_NEAR(fl.cells[1].x - fl.cells[0].right(), 6.0, 1e-4);
for (plot::u32 i = 0; i < 2u; ++i) {
PLOT_CHECK_NEAR(figure.plot(i).layout().right_gutter, 35.0, 1e-3);
PLOT_CHECK_NEAR(fl.cells[i].right() - figure.plot(i).layout().axes.right(), 35.0, 1e-3);
}
// An unsplit row is unchanged by sharing being on.
plot::Figure unsplit = host_like_figure({1, 1});
const plot::FigureLayout& ul = unsplit.layout();
for (plot::u32 i = 0; i < 2u; ++i) {
PLOT_CHECK_NEAR(ul.cells[i].w, ul.rows[i].w, 1e-4);
PLOT_CHECK_NEAR(ul.cells[i].right() - unsplit.plot(i).layout().axes.right(), 35.0, 1e-3);
}
}
+31
View File
@@ -14,6 +14,9 @@
#include <glfw/glfw_host.hpp>
#include <plot/plot/figure.hpp>
#include <span>
#include <vector>
namespace {
/// A figure of `rows` rows over a known rect, with an x and a y axis in every row (so
@@ -315,3 +318,31 @@ PLOT_TEST(the_host_keeps_its_own_controller_while_no_figure_is_set) {
PLOT_CHECK(host.row_controller(0u) == nullptr);
PLOT_CHECK(&host.controller_at(10.0f, 10.0f) == &host.controller());
}
PLOT_TEST(glfw_host_routes_by_cell) {
// A figure of two rows, the first of one cell and the second of two side by side: the
// pointer's x as well as its y picks the plot, and the controllers are indexed by the
// flattened plot.
std::vector<plot::u32> cells{1, 2};
plot::Figure figure{std::span<const plot::u32>(cells)};
figure.set_viewport(plot::Rect{0, 0, 300, 600});
figure.set_col_gap(6.0f);
for (plot::u32 i = 0; i < 3; ++i) {
figure.plot(i).add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
figure.plot(i).add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
}
plot::glfw::Host host;
host.set_figure(&figure);
const std::vector<plot::Rect> c = figure.layout().cells;
PLOT_CHECK(c.size() == 3u);
for (plot::u32 i = 0; i < 3; ++i) {
PLOT_CHECK(host.row_controller(i) != nullptr);
const double cx = c[i].x + c[i].w * 0.5, cy = c[i].y + c[i].h * 0.5;
PLOT_CHECK(&host.controller_at(static_cast<plot::f32>(cx), static_cast<plot::f32>(cy)) ==
host.row_controller(i));
}
PLOT_CHECK(host.row_controller(3u) == nullptr);
// in the column gap: the nearest cell wins
PLOT_CHECK(&host.controller_at(c[1].right() + 1.0f, c[1].y + 5.0f) == host.row_controller(1));
PLOT_CHECK(&host.controller_at(c[2].x - 1.0f, c[2].y + 5.0f) == host.row_controller(2));
}
+403
View File
@@ -2808,3 +2808,406 @@ PLOT_TEST(the_selection_frame_is_carried_and_taken_off) {
PLOT_CHECK_NEAR(p.selection_rect().w, 0.0, 1e-6);
PLOT_CHECK_NEAR(p.layout().selection.h, 0.0, 1e-6);
}
// --- phosphor density -------------------------------------------------------
namespace {
/// A two-sample flat series: one segment across the whole window at y = 0, which is the
/// simplest geometry a density can be read against -- one cell per column and nothing else.
std::vector<f32> flat_pair() { return std::vector<f32>{0.0f, 0.0f}; }
/// A plot with one flat trace over a 640x480 viewport, laid out. The axes rect it returns is
/// the one `accumulate_density()` sizes the grid to, so a test reads both together.
struct DensityFixture {
plot::data::ArraySeries series;
plot::Plot plot;
plot::u32 ax = 0;
plot::u32 ay = 0;
explicit DensityFixture(std::vector<f32> y = flat_pair(), plot::f64 dx = 1.0)
: series(std::move(y), 0.0, dx) {
plot.set_viewport({0.0f, 0.0f, 640.0f, 480.0f});
ax = plot.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
ay = plot.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
PLOT_CHECK(plot.add_trace({&series, plot::Color::hex(0x00E070), 1.5f, ax, ay, "flat"}) == 0u);
plot.set_x_range(ax, {0.0, 1.0});
plot.set_y_range(ay, {-1.0, 1.0});
plot.layout();
}
};
} // namespace
/// The grid is the screen's, not the data's: one cell per device pixel of the axes rect **up to
/// `kDensityCellCap`**, and nothing at all until the density is switched on. The figures here are
/// the ones the API's memory claim rests on -- `cell_count * 4` bytes, whatever the trigger has
/// done and whatever the window is.
PLOT_TEST(a_density_grid_is_the_axes_in_device_pixels) {
DensityFixture f;
const plot::Rect axes = f.plot.layout().axes;
// The 640x480 viewport this fixture asks for is past the cap, so its grid is the axes rect
// *scaled down* -- the rule the library states, written out here so the assertion is arithmetic
// rather than a recorded number. A grid under the cap is the rect itself, which the 320x240 leg
// at the end reads.
const auto capped = [](plot::u32 w, plot::u32 h) {
std::pair<plot::u32, plot::u32> out{w, h};
const plot::u64 pixels = static_cast<plot::u64>(w) * static_cast<plot::u64>(h);
if (pixels > static_cast<plot::u64>(plot::kDensityCellCap)) {
const plot::f64 s = std::sqrt(static_cast<plot::f64>(plot::kDensityCellCap) /
static_cast<plot::f64>(pixels));
out.first = std::max(1u, static_cast<plot::u32>(static_cast<plot::f64>(w) * s));
out.second = std::max(1u, static_cast<plot::u32>(static_cast<plot::f64>(h) * s));
}
return out;
};
const auto [columns, rows] =
capped(static_cast<plot::u32>(std::ceil(axes.w)), static_cast<plot::u32>(std::ceil(axes.h)));
// The row a device y lands in is read through the grid's cells-per-*whole*-pixel, which is what
// the deposit uses (see `deposit_density_segment()`): 1 while the grid is the rect.
const plot::f32 sy = static_cast<plot::f32>(rows) / std::ceil(axes.h);
PLOT_CHECK(static_cast<plot::u64>(columns) * rows <= plot::kDensityCellCap);
// Off is the default, and off is *nothing*: no grid, no ink, and an accumulation that
// deposits nothing at all.
PLOT_CHECK(!f.plot.density().enabled);
f.plot.accumulate_density();
PLOT_CHECK(f.plot.density_grid(0u).cell_count() == 0u);
PLOT_CHECK(f.plot.density_grid(0u).ink() == 0.0);
PLOT_CHECK(f.plot.density_grid(0u).peak() == 0.0f);
plot::DensityConfig cfg;
cfg.enabled = true;
f.plot.set_density(cfg);
PLOT_CHECK(f.plot.density().enabled);
f.plot.accumulate_density();
const plot::DensityGrid& grid = f.plot.density_grid(0u);
PLOT_CHECK(grid.columns() == columns);
PLOT_CHECK(grid.rows() == rows);
PLOT_CHECK(grid.cell_count() == static_cast<plot::usize>(columns) * rows);
// `4 bytes * cells`, and never more than the cap: the whole point of the bound.
const plot::usize bytes = grid.cell_count() * sizeof(plot::f32);
PLOT_CHECK(bytes <= static_cast<plot::usize>(plot::kDensityCellCap) * sizeof(plot::f32));
std::printf("[density] grid=%ux%u cells=%zu grid_bytes=%zu ink=%.0f peak=%.0f\n",
grid.columns(), grid.rows(), grid.cell_count(), bytes, grid.ink(),
static_cast<double>(grid.peak()));
// One flat trace over the whole window lights every column exactly once: one segment, no
// column reached twice, so the ink *is* the width and the peak is one pass.
PLOT_CHECK_NEAR(grid.ink(), static_cast<double>(columns), 1.0);
PLOT_CHECK_NEAR(grid.peak(), 1.0, 1e-4);
// ... and all of it in one row, the one y = 0 maps to.
const plot::Transform ty = f.plot.y_transform(f.ay);
const int row = static_cast<int>(std::floor((ty.to_screen_y(0.0) - axes.y) * sy));
plot::u32 lit = 0;
for (plot::u32 r = 0; r < grid.rows(); ++r) {
for (plot::u32 c = 0; c < grid.columns(); ++c) {
if (grid.at(c, r) <= 0.0f) continue;
++lit;
PLOT_CHECK(static_cast<int>(r) == row);
}
}
PLOT_CHECK(lit == columns);
// The accumulation is a *screen* image: a plot that has moved to another axes rect gets a
// grid of that size and nothing carried over from the old one -- and that rect is under the
// cap, so the grid is it *exactly*: one cell a device pixel, no scaling at all.
f.plot.set_viewport({0.0f, 0.0f, 320.0f, 240.0f});
f.plot.layout();
f.plot.accumulate_density();
const plot::DensityGrid& small = f.plot.density_grid(0u);
const plot::Rect small_axes = f.plot.layout().axes;
PLOT_CHECK(small.columns() == static_cast<plot::u32>(std::ceil(small_axes.w)));
PLOT_CHECK(small.rows() == static_cast<plot::u32>(std::ceil(small_axes.h)));
PLOT_CHECK(small.cell_count() <= plot::kDensityCellCap);
PLOT_CHECK(small.columns() < columns);
PLOT_CHECK_NEAR(small.ink(), static_cast<double>(small.columns()), 1.0);
}
/// The cap is what keeps the resolution from following the window: a grid is never more than
/// `kDensityCellCap` cells, whatever the axes rect is, so the memory a display holds and the frame
/// pass over it are numbers and not functions of how wide the window was dragged. The scaling is
/// **uniform**, so a cell stays square and the picture keeps the axes' aspect -- the reading that
/// separates this from a cap on one axis, which would stretch every cell by the other one's ratio.
PLOT_TEST(a_density_grid_is_capped_whatever_the_window) {
DensityFixture f;
f.plot.set_viewport({0.0f, 0.0f, 3840.0f, 2160.0f});
f.plot.layout();
plot::DensityConfig cfg;
cfg.enabled = true;
f.plot.set_density(cfg);
f.plot.accumulate_density();
const plot::DensityGrid& grid = f.plot.density_grid(0u);
const plot::Rect axes = f.plot.layout().axes;
PLOT_CHECK(static_cast<plot::u64>(grid.columns()) * grid.rows() <= plot::kDensityCellCap);
PLOT_CHECK(grid.cell_count() * sizeof(plot::f32) <= 1'048'576u);
// ... and it is as large as the cap allows: flooring a uniform scale costs at most the two
// columns and rows the truncation drops, so the grid is within a hair of the whole budget.
PLOT_CHECK(static_cast<plot::f64>(grid.cell_count()) >
0.99 * static_cast<plot::f64>(plot::kDensityCellCap));
// Uniform: the grid's aspect is the axes rect's, to the rounding of the two floors.
PLOT_CHECK_NEAR(static_cast<plot::f64>(grid.columns()) / grid.rows(),
static_cast<plot::f64>(axes.w) / axes.h, 0.01);
// A cap on the *cells* also returns a full sweep's worth of ink: one flat pass over the window
// is one per column however fine or coarse the grid is.
PLOT_CHECK_NEAR(grid.ink(), static_cast<double>(grid.columns()), 1.0);
PLOT_CHECK_NEAR(grid.peak(), 1.0, 1e-4);
std::printf("[density] capped grid=%ux%u cells=%zu bytes=%zu of a %ux%u axes\n", grid.columns(),
grid.rows(), grid.cell_count(), grid.cell_count() * sizeof(plot::f32),
static_cast<plot::u32>(std::ceil(axes.w)), static_cast<plot::u32>(std::ceil(axes.h)));
}
/// Per acquisition the image is multiplied by the factor *before* the new acquisition is
/// deposited, so the sweep on screen is the brightest and the one behind it is the factor
/// down: the schedule a phosphor's afterglow has, and the reading that says which way round
/// the halving goes.
PLOT_TEST(a_density_halves_per_acquisition_and_keeps_the_newest_brightest) {
DensityFixture f;
plot::DensityConfig cfg;
cfg.enabled = true;
cfg.decay = plot::DensityDecay::PerAcquisition;
cfg.per_acquisition = 0.5f;
f.plot.set_density(cfg);
f.plot.accumulate_density();
// The grid is sized by the first accumulation, so the width is read after it -- and the ink
// of one pass *is* the width: one segment across the window, one cell per column.
const double width = static_cast<double>(f.plot.density_grid(0u).columns());
const double one = f.plot.density_grid(0u).ink();
PLOT_CHECK_NEAR(one, width, 1.0);
f.plot.accumulate_density();
const double two = f.plot.density_grid(0u).ink();
PLOT_CHECK_NEAR(two, width * 1.5, 2.0);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).peak(), 1.5f, 1e-3);
f.plot.accumulate_density();
const double three = f.plot.density_grid(0u).ink();
PLOT_CHECK_NEAR(three, width * 1.75, 2.0);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).peak(), 1.75f, 1e-3);
// The steady state of a stream that keeps sweeping: 1 + 1/2 + 1/4 + ... = 2 passes.
for (int i = 0; i < 40; ++i) f.plot.accumulate_density();
PLOT_CHECK_NEAR(f.plot.density_grid(0u).peak(), 2.0f, 1e-3);
// A factor of 0 is "only the newest acquisition is on screen": the grid is the last pass.
cfg.per_acquisition = 0.0f;
f.plot.set_density(cfg);
f.plot.accumulate_density();
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), width, 1.0);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).peak(), 1.0f, 1e-3);
std::printf("[density] per-acquisition ink: 1=%.0f 2=%.0f 3=%.0f peak_steady=%.3f\n", one, two,
three, 2.0);
}
/// The time constant is the other question: the image fades while you watch, by the frame's own
/// `dt` against a constant stated in seconds, and it is the *caller* who advances it -- there is
/// no clock in the library, which is what makes the reading reproducible.
PLOT_TEST(a_density_fades_by_its_time_constant) {
DensityFixture f;
plot::DensityConfig cfg;
cfg.enabled = true;
cfg.decay = plot::DensityDecay::TimeConstant;
cfg.time_constant_s = 2.0f;
f.plot.set_density(cfg);
f.plot.accumulate_density();
const double full = f.plot.density_grid(0u).ink();
// A frame that has not been measured yet (dt <= 0) does not wipe the grid, and neither does
// a frame under `PerAcquisition`, which fades in `accumulate_density()` instead.
f.plot.decay_density(0.0);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), full, 1e-6);
f.plot.decay_density(-1.0);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), full, 1e-6);
// Quarter of a time constant: exp(-0.25).
f.plot.decay_density(0.5);
const double quarter = full * std::exp(-0.25);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), quarter, quarter * 1e-4);
// Ten more constants and the display is gone to within a rounding: 4.5e-5 of the ink.
for (int i = 0; i < 10; ++i) f.plot.decay_density(2.0);
PLOT_CHECK(f.plot.density_grid(0u).ink() < quarter * 1e-3);
PLOT_CHECK(f.plot.density_grid(0u).peak() < 1e-3f);
// A non-positive constant freezes the grid rather than fading it.
cfg.time_constant_s = 0.0f;
f.plot.set_density(cfg);
f.plot.accumulate_density();
const double frozen = f.plot.density_grid(0u).ink();
f.plot.decay_density(100.0);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), frozen, 1e-6);
// And `PerAcquisition` fades in `accumulate_density()` alone: a frame that only passed
// changes nothing.
cfg.decay = plot::DensityDecay::PerAcquisition;
f.plot.set_density(cfg);
const double before = f.plot.density_grid(0u).ink();
f.plot.decay_density(1.0);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), before, 1e-6);
std::printf("[density] time-constant ink: full=%.0f quarter=%.1f frozen=%.0f\n", full, quarter,
frozen);
}
/// Switching off drops the image at once -- not at the next acquisition, which a trigger that
/// has stopped firing never sends -- and turning it back on starts from an empty grid.
PLOT_TEST(switching_a_density_off_drops_the_image_at_once) {
DensityFixture f;
plot::DensityConfig cfg;
cfg.enabled = true;
f.plot.set_density(cfg);
f.plot.accumulate_density();
PLOT_CHECK(f.plot.density_grid(0u).ink() > 0.0);
f.plot.clear_density();
PLOT_CHECK(f.plot.density_grid(0u).ink() == 0.0);
PLOT_CHECK(f.plot.density_grid(0u).peak() == 0.0f);
// The clear keeps the allocation -- the grid is still the screen's -- so the next
// accumulation does not have to reallocate it.
PLOT_CHECK(f.plot.density_grid(0u).cell_count() > 0u);
f.plot.accumulate_density();
PLOT_CHECK(f.plot.density_grid(0u).ink() > 0.0);
cfg.enabled = false;
f.plot.set_density(cfg);
PLOT_CHECK(f.plot.density_grid(0u).cell_count() == 0u);
PLOT_CHECK(f.plot.density_grid(0u).ink() == 0.0);
// ... and an accumulation while it is off deposits nothing (it is not a hidden build-up).
f.plot.accumulate_density();
PLOT_CHECK(f.plot.density_grid(0u).cell_count() == 0u);
}
/// What is deposited is where the *trace* is, and nowhere else: a trace off the visible y range
/// leaves no ink rather than a band along the edge it left by, and a NaN sample is a gap that
/// joins nothing.
PLOT_TEST(a_density_deposits_only_where_the_trace_is) {
// y = 5 against a [-1, 1] axis: the segment's whole column span is above the axes.
DensityFixture off(std::vector<f32>{5.0f, 5.0f});
plot::DensityConfig cfg;
cfg.enabled = true;
off.plot.set_density(cfg);
off.plot.accumulate_density();
PLOT_CHECK_NEAR(off.plot.density_grid(0u).ink(), 0.0, 1e-9);
// ... and the same series inside the axis deposits one cell per column, in two rows: the
// first half at y = -1 and the second at y = +1, joined by a diagonal.
DensityFixture split(std::vector<f32>{-1.0f, 1.0f});
split.plot.set_density(cfg);
split.plot.accumulate_density();
const plot::DensityGrid& grid = split.plot.density_grid(0u);
PLOT_CHECK(grid.ink() >= static_cast<double>(grid.columns()));
const plot::Transform ty = split.plot.y_transform(split.ay);
const plot::Rect axes = split.plot.layout().axes;
// The row a device y lands in is the grid's own mapping: the axis rect scaled by the grid's
// cells-per-pixel (1 while the rect is under `kDensityCellCap`, less past it).
const plot::f32 sy =
axes.h > 0.0f ? static_cast<plot::f32>(grid.rows()) / std::ceil(axes.h) : 1.0f;
// The deposit keeps one ULP of the bottom edge off its row, so `y = -1` -- which maps *onto*
// the bottom edge -- lands in the last row rather than one past the end.
const auto row_of = [&](plot::f64 y) {
return static_cast<plot::u32>(std::clamp(
static_cast<int>(std::floor((ty.to_screen_y(y) - axes.y) * sy)), 0,
static_cast<int>(grid.rows()) - 1));
};
PLOT_CHECK(grid.at(0u, row_of(-1.0)) > 0.0f);
PLOT_CHECK(grid.at(grid.columns() - 1u, row_of(1.0)) > 0.0f);
// Inverted on screen: y = +1 is the *lower* row index, which is the one y flip of the mapping.
PLOT_CHECK(row_of(1.0) < row_of(-1.0));
// A NaN in the middle is a gap: it joins nothing, so the trace is deposited as two runs and
// the columns where the gap is stay empty -- where the same series with the sample filled in
// lights every one of them. Five samples at x = 0, 0.25, 0.5, 0.75, 1.0.
const auto column_ink = [](const plot::DensityGrid& g, plot::u32 column) {
double sum = 0.0;
for (plot::u32 r = 0; r < g.rows(); ++r) sum += static_cast<double>(g.at(column, r));
return sum;
};
DensityFixture whole(std::vector<f32>{0.0f, 0.0f, 0.0f, 0.0f, 0.0f}, 0.25);
whole.plot.set_density(cfg);
whole.plot.accumulate_density();
DensityFixture gap(
std::vector<f32>{0.0f, 0.0f, std::numeric_limits<f32>::quiet_NaN(), 0.0f, 0.0f}, 0.25);
gap.plot.set_density(cfg);
gap.plot.accumulate_density();
const plot::DensityGrid& whole_grid = whole.plot.density_grid(0u);
const plot::DensityGrid& gap_grid = gap.plot.density_grid(0u);
const plot::Transform tx = gap.plot.x_transform(gap.ax);
const plot::Rect gap_axes = gap.plot.layout().axes;
const plot::f32 gsx = gap_axes.w > 0.0f
? static_cast<plot::f32>(gap_grid.columns()) / std::ceil(gap_axes.w)
: 1.0f;
const plot::u32 column = static_cast<plot::u32>(std::clamp(
static_cast<int>(std::floor((tx.to_screen_x(0.5) - gap_axes.x) * gsx)), 0,
static_cast<int>(gap_grid.columns()) - 1));
PLOT_CHECK(column_ink(whole_grid, column) > 0.0); // the sample is there: the column is lit
PLOT_CHECK(column_ink(gap_grid, column) == 0.0); // the NaN is a gap: nothing joins across it
PLOT_CHECK(gap_grid.ink() < whole_grid.ink());
std::printf("[density] off-range ink=%.0f split ink=%.0f gap ink=%.0f whole ink=%.0f\n",
off.plot.density_grid(0u).ink(), grid.ink(), gap_grid.ink(), whole_grid.ink());
}
/// **One grid per trace.** A plot of two signals holds two images, each deposited from its own
/// trace and drawn in its own colour (the colour is the renderer's, and here the *count* is what is
/// read): the two traces at two levels leave ink in two different rows, so one grid holding one
/// trace's ink and not the other's is the reading that says the images are not shared.
PLOT_TEST(a_density_holds_one_grid_per_trace) {
plot::data::ArraySeries lo(std::vector<plot::f32>{-0.5f, -0.5f}, 0.0, 1.0);
plot::data::ArraySeries hi(std::vector<plot::f32>{0.5f, 0.5f}, 0.0, 1.0);
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 640.0f, 480.0f});
const plot::u32 ax = p.add_x_axis({"t", {0.0, 1.0}, false, 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({&lo, plot::Color::hex(0x4FA3FF), 1.5f, ax, ay, "lo"}) == 0u);
PLOT_CHECK(p.add_trace({&hi, plot::Color::hex(0xFF8A4F), 1.5f, ax, ay, "hi"}) == 1u);
p.set_x_range(ax, {0.0, 1.0});
p.set_y_range(ay, {-1.0, 1.0});
p.layout();
plot::DensityConfig cfg;
cfg.enabled = true;
p.set_density(cfg);
p.accumulate_density();
PLOT_CHECK(p.trace_count() == 2u);
const plot::DensityGrid& g0 = p.density_grid(0u);
const plot::DensityGrid& g1 = p.density_grid(1u);
// Both are sized to the one axes rect, and each holds exactly its own trace's one pass: the ink
// is the width in each grid, so neither picked up the other trace's ink (two traces would be two
// widths in a shared grid).
PLOT_CHECK(g0.cell_count() > 0u);
PLOT_CHECK(g1.cell_count() == g0.cell_count());
PLOT_CHECK_NEAR(g0.ink(), static_cast<double>(g0.columns()), 1.0);
PLOT_CHECK_NEAR(g1.ink(), static_cast<double>(g1.columns()), 1.0);
const plot::Transform ty = p.y_transform(ay);
const plot::Rect axes = p.layout().axes;
const plot::f32 sy =
axes.h > 0.0f ? static_cast<plot::f32>(g0.rows()) / std::ceil(axes.h) : 1.0f;
const auto row_of = [&](plot::f64 y) {
return static_cast<plot::u32>(std::floor((ty.to_screen_y(y) - axes.y) * sy));
};
const plot::u32 lo_row = row_of(-0.5);
const plot::u32 hi_row = row_of(0.5);
PLOT_CHECK(lo_row != hi_row);
PLOT_CHECK(g0.at(0u, lo_row) > 0.0f);
PLOT_CHECK(g1.at(0u, hi_row) > 0.0f);
PLOT_CHECK(g0.at(0u, hi_row) == 0.0f); // no trace but `lo` is in the lower grid
PLOT_CHECK(g1.at(0u, lo_row) == 0.0f);
// An out-of-range trace reads an empty image rather than anything borrowed.
PLOT_CHECK(p.density_grid(2u).cell_count() == 0u);
PLOT_CHECK(p.density_grid(9u).ink() == 0.0);
std::printf("[density] per-trace grids: cells=%zu each ink=%.0f/%.0f rows lo=%u hi=%u\n",
g0.cell_count(), g0.ink(), g1.ink(), lo_row, hi_row);
}
+464 -11
View File
@@ -5059,6 +5059,52 @@ PLOT_TEST(figure_x_title_is_drawn_on_the_bottom_row_only) {
ctx.destroy();
}
/// A split row that shares one y axis draws it once, on its first cell: every later cell is
/// `YStrip::None`, reserves no y strip, and the renderer must stage no y tick labels for it.
/// Staged text is not observable through `FrameStats`, so this reads the pixels. The column gap
/// is wide and empty chrome, and a label staged for the second cell would be right-aligned
/// against that cell's (zero-width) label area, i.e. land in the gap: the gap holds no ink.
PLOT_TEST(figure_shared_y_second_cell_stages_no_y_labels) {
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));
const std::vector<plot::u32> cells{2u};
plot::Figure figure{std::span<const plot::u32>(cells)};
figure.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
for (plot::u32 i = 0; i < 2u; ++i) {
plot::Plot& p = figure.plot(i);
p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
}
figure.set_col_gap(80.0f);
figure.set_share_row_y(true);
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(figure);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
const plot::FigureLayout layout = figure.layout();
const plot::Color ink = r.style().axis_text;
const plot::PlotLayout& first = figure.plot(0u).layout();
const plot::PlotLayout& second = figure.plot(1u).layout();
PLOT_CHECK(second.y_label_area.w == 0.0f);
PLOT_CHECK(layout.cells[1].x - layout.cells[0].right() > 70.0f);
// The first cell still draws the row's one axis ...
PLOT_CHECK(scan_rect_ink(ctx, first.y_label_area, ink).pixels > 0u);
// ... and nothing is staged in the gap on the second cell's left.
// Inset a few pixels each side: the cells' own frame lines sit on the gap's two edges.
const plot::Rect gap{layout.cells[0].right() + 4.0f, first.axes.y,
layout.cells[1].x - layout.cells[0].right() - 8.0f, first.axes.h};
PLOT_CHECK(scan_rect_ink(ctx, gap, ink).pixels == 0u);
ctx.destroy();
}
// ---------------------------------------------------- the axes clip (spec §13)
// The reported defect this section is about: a trace is staged in *data* space and
@@ -5704,17 +5750,22 @@ PLOT_TEST(horizontal_ruler_label_reads_its_value) {
const InkExtent ink = ink_extent(frame, kW, kH, ruler_pixel);
PLOT_CHECK(ink.pixels > 0u);
// One horizontal run: shallow in rows, spanning essentially the axes' own width.
PLOT_CHECK(ink.rows() <= 3u);
// One horizontal run: the stroke itself is shallow, but the label's backplate is drawn in
// the ruler's own colour too (see the clustered cursor labels), and its border spans one
// text line plus its padding above and below the stroke -- measured 28 rows on the suite's
// own font. The bound is that panel with a stroke's worth of slack, not the old bare-text
// run of three.
PLOT_CHECK(ink.rows() <= 32u);
PLOT_CHECK(ink.cols() + 2u >= static_cast<plot::u32>(axes.w));
// And at the row the transform says -- a ruler drawn at the wrong y, or a vertical column
// that happens to be inside the rect, cannot satisfy both of these.
PLOT_CHECK(static_cast<plot::f32>(ink.top) <= expected_row + 1.0f);
PLOT_CHECK(static_cast<plot::f32>(ink.bottom) >= expected_row - 1.0f);
// The label: text ink just under the ruler at its left end, inside the axes. The tick
// The label: text ink centred on the ruler at its left end, inside the axes. The tick
// labels are outside `axes`, in their own strips, so this can only be the ruler's value.
const plot::Rect label{axes.x + 1.0f, expected_row, 60.0f, 16.0f};
// Centred, not below: the badge straddles the ruler's own row (see the clustered labels).
const plot::Rect label{axes.x + 1.0f, expected_row - 9.0f, 60.0f, 18.0f};
PLOT_CHECK(predicate_pixels(frame, kW, label, text_pixel) > 0u);
// The clip: every ruler-coloured pixel of the frame is inside `axes`.
@@ -6050,15 +6101,65 @@ PLOT_TEST(crosshair_ink_stays_inside_the_axes) {
PLOT_CHECK(crosshair.rows[2].marker.x < axes.x);
PLOT_CHECK(crosshair.rows[2].marker.x >= layout.y_label_area.x);
// Every crosshair-coloured pixel of the frame is inside `axes`, and there are some: not one in a
// label or title strip, not one over the host's surface outside the viewport.
// Every crosshair-coloured pixel of the frame is inside `axes` or inside one of the two
// axis badges, and there are some. The badges are the deliberate exception -- the
// crosshair's own axis readings, drawn in the strips the ticks are written in (see
// `Crosshair::axis_labels`) -- and the layout named their rects, so the exception is
// checked against exactly what was placed, not against a loose region.
PLOT_CHECK(crosshair.y_axis_badge.w > 0.0f);
PLOT_CHECK(crosshair.x_axis_badge.w > 0.0f);
PLOT_CHECK(crosshair.y_axis_badge.right() <= axes.x + 0.5f); // beside the y ticks
PLOT_CHECK(crosshair.x_axis_badge.y >= axes.bottom() - 0.5f); // beside the x ticks
const plot::u32 inside = predicate_pixels(frame, kW, axes, crosshair_pixel);
// The badge rects are the panels' own frames, and a hairline's anti-aliased fringe can sit
// half a pixel outside one -- the badge's own ink either way, so the counts read over the
// rects inflated by a pixel. The inflation is held out of the axes: a badge sits *against*
// an axis line, and a fringe column read into the axes' own pixels would be counted twice.
const auto inflate_badge = [&axes](const plot::Rect& rect, bool toward_axes) {
plot::Rect out{rect.x - 1.0f, rect.y - 1.0f, rect.w + 2.0f, rect.h + 2.0f};
if (toward_axes && out.right() > axes.x) out.w -= out.right() - axes.x;
if (!toward_axes && out.y < axes.bottom()) {
const f32 d = axes.bottom() - out.y;
out.y += d;
out.h -= d;
}
return out;
};
const plot::Rect y_badge_rect = inflate_badge(crosshair.y_axis_badge, true);
const plot::Rect x_badge_rect = inflate_badge(crosshair.x_axis_badge, false);
const plot::u32 y_badge = predicate_pixels(frame, kW, y_badge_rect, crosshair_pixel);
const plot::u32 x_badge = predicate_pixels(frame, kW, x_badge_rect, crosshair_pixel);
PLOT_CHECK(inside > 0u);
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, crosshair_pixel) == inside);
PLOT_CHECK(predicate_pixels(frame, kW, layout.y_label_area, crosshair_pixel) == 0u);
PLOT_CHECK(predicate_pixels(frame, kW, layout.y_title_area, crosshair_pixel) == 0u);
PLOT_CHECK(predicate_pixels(frame, kW, layout.x_label_area, crosshair_pixel) == 0u);
PLOT_CHECK(predicate_pixels(frame, kW, layout.x_title_area, crosshair_pixel) == 0u);
PLOT_CHECK(y_badge > 0u);
PLOT_CHECK(x_badge > 0u);
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, crosshair_pixel) ==
inside + y_badge + x_badge);
// The badges may reach past the label strip into the title strip beside it -- a badge is as
// wide as its own text and is capped by nothing but the strips' union -- so the title
// strips' rule is: **every crosshair-coloured pixel is the badge's own**, i.e. within one
// badge rect's one-pixel fringe. A stray pixel there is a second drawing in a strip that
// belongs to the axis title.
const auto title_stray = [&](const plot::Rect& strip, const plot::Rect& badge) {
const plot::Rect frame_rect{badge.x - 1.0f, badge.y - 1.0f, badge.w + 2.0f, badge.h + 2.0f};
plot::u32 stray = 0;
for (plot::i32 y = static_cast<plot::i32>(strip.y);
y < static_cast<plot::i32>(strip.bottom()); ++y) {
for (plot::i32 x = static_cast<plot::i32>(strip.x);
x < static_cast<plot::i32>(strip.right()); ++x) {
const plot::f32 fx = static_cast<plot::f32>(x);
const plot::f32 fy = static_cast<plot::f32>(y);
const plot::u8* pixel =
&frame[(static_cast<plot::usize>(y) * kW + static_cast<plot::usize>(x)) * 4u];
if (!crosshair_pixel(pixel)) continue;
const bool own = fx >= frame_rect.x && fx < frame_rect.x + frame_rect.w &&
fy >= frame_rect.y && fy < frame_rect.y + frame_rect.h;
if (!own) ++stray;
}
}
return stray;
};
PLOT_CHECK(title_stray(layout.y_title_area, crosshair.y_axis_badge) == 0u);
PLOT_CHECK(title_stray(layout.x_title_area, crosshair.x_axis_badge) == 0u);
plot::u8 host[4] = {};
PLOT_CHECK(ctx.read_pixel(4u, kH - 4u, host));
PLOT_CHECK(host[0] > 100 && host[0] < 160 && host[1] > 100 && host[2] > 100);
@@ -6087,6 +6188,21 @@ PLOT_TEST(crosshair_ink_stays_inside_the_axes) {
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, magenta_pixel) ==
predicate_pixels(frame, kW, axes, magenta_pixel));
// The same plot with the badges off: the layout places none, and the strips go back to
// being clean of the crosshair's colour -- the flag is the whole difference between the
// two frames, and this leg is what makes it a setting rather than a behaviour.
p.set_crosshair_axis_labels(false);
p.layout();
r.begin_frame(kW, kH, vp);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
const std::vector<plot::u8> off_frame = read_frame(ctx);
PLOT_CHECK(p.layout().crosshair.y_axis_badge.w == 0.0f);
PLOT_CHECK(p.layout().crosshair.x_axis_badge.w == 0.0f);
PLOT_CHECK(predicate_pixels(off_frame, kW, layout.y_label_area, crosshair_pixel) == 0u);
PLOT_CHECK(predicate_pixels(off_frame, kW, layout.x_label_area, crosshair_pixel) == 0u);
ctx.destroy();
}
@@ -6232,6 +6348,13 @@ PLOT_TEST(crosshair_dash_is_configurable_and_zero_off_means_solid) {
// 4 on / 4 off over the axes' width: many dashes, not one line.
const plot::u32 dashed = runs(4.0f);
PLOT_CHECK(dashed > 4u);
// The y axis badge is **centred** on the line it names, the way the x badge is centred on
// its column -- its middle is the line's own screen row. A badge hung from the line (top
// edge on it) is half a badge off, which is exactly what this reads for.
const plot::Rect badge = p.layout().crosshair.y_axis_badge;
PLOT_CHECK(badge.w > 0.0f);
PLOT_CHECK_NEAR(badge.y + 0.5f * badge.h, cyp, 0.6f);
// Off == 0 is solid: one run, the whole width, no gap anywhere.
PLOT_CHECK(runs(0.0f) == 1u);
// A longer gap is a longer period, so fewer dashes over the same width.
@@ -7519,3 +7642,333 @@ PLOT_TEST(the_box_zoom_frame_lands_exactly_where_it_says) {
PLOT_CHECK(outside_after == outside_before);
p.clear_selection_rect();
}
/// The phosphor density **is the trace**, in the trace's own colour: it covers the axes rect in
/// `Trace::color`, the newest acquisition is deposited into it like every other -- so it fades, and
/// no solid polyline is drawn over the ink -- and it costs an upload on the frame a grid moved and
/// nothing on the frame it did not.
///
/// Three things are read off the *screen*, because neither the grid's counts nor a tint alone can
/// make them: that a saturated cell is the trace's own colour and not a monochrome wash; that a
/// cell a quarter of the way up the scale is dimmer *and the same hue*; and that the acquisition
/// just deposited fades -- which is only true if it is *inside* the image and not a stroke drawn
/// on top of its own faint ghost (the "persistent image + last acquisition" report).
PLOT_TEST(a_phosphor_density_is_the_trace_in_its_own_colour) {
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(std::vector<plot::f32>{0.0f, 0.0f}, 0.0, 1.0);
plot::Plot p;
p.set_viewport({0.0f, 0.0f, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
const plot::u32 ax = p.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
const plot::Color trace_color = plot::Color::hex(0x00E070);
PLOT_CHECK(p.add_trace({&s, trace_color, 1.5f, ax, ay, "flat"}) == 0u);
p.set_x_range(ax, {0.0, 1.0});
p.set_y_range(ay, {-1.0, 1.0});
// The baseline: no density at all, and no grid to draw one from.
r.begin_frame(kW, kH, kFrameRect);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
PLOT_CHECK(r.stats().density_cells == 0u);
PLOT_CHECK(r.stats().density_upload_bytes == 0u);
const std::vector<plot::u8> without = read_frame(ctx);
const auto channel = [&](const std::vector<plot::u8>& f, plot::u32 x, plot::u32 y, int c) {
return static_cast<int>(f[(plot::usize(y) * kW + x) * 4u + static_cast<plot::usize>(c)]);
};
const plot::Transform tx = p.x_transform(ax);
const plot::Transform ty = p.y_transform(ay);
const plot::u32 mid_x = static_cast<plot::u32>(tx.to_screen_x(0.5));
const plot::u32 trace_row = static_cast<plot::u32>(ty.to_screen_y(0.0));
// A row well away from the sweep, and away from the trace in the no-density frame: the
// background to read every cell against, rather than a guessed one.
const plot::u32 blank_row = trace_row + 40u < kH ? trace_row + 40u : trace_row - 40u;
// **The newest acquisition is in the image, and it fades.** A time fade from the first deposit:
// one time constant must dim the same pixel, which a stroke drawn over the density could not do.
plot::DensityConfig cfg;
cfg.enabled = true;
cfg.decay = plot::DensityDecay::TimeConstant;
cfg.time_constant_s = 1.0f; // the ceiling stays the library's one sweep -- the trace's own
p.set_density(cfg);
p.accumulate_density(); // the acquisition on screen, deposited like any other
r.begin_frame(kW, kH, kFrameRect);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
dump_if_requested(r, "density.ppm");
const plot::FrameStats first = r.stats();
const std::vector<plot::u8> fresh = read_frame(ctx);
// The image is drawn -- it changed pixels -- and every pixel it changed is inside the axes:
// a density reserves no layout space and paints nothing over a label strip.
const plot::Rect axes = p.layout().axes;
plot::u32 lo_x = kW, lo_y = kH, hi_x = 0u, hi_y = 0u, differing = 0u;
for (plot::u32 y = 0u; y < kH; ++y) {
for (plot::u32 x = 0u; x < kW; ++x) {
const plot::usize at = (plot::usize(y) * kW + x) * 4u;
if (fresh[at] == without[at] && fresh[at + 1u] == without[at + 1u] &&
fresh[at + 2u] == without[at + 2u]) {
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);
}
}
PLOT_CHECK(differing > 0u);
PLOT_CHECK(static_cast<plot::f32>(lo_x) >= axes.x - 1.0f);
PLOT_CHECK(static_cast<plot::f32>(hi_x) <= axes.right() + 1.0f);
PLOT_CHECK(static_cast<plot::f32>(lo_y) >= axes.y - 1.0f);
PLOT_CHECK(static_cast<plot::f32>(hi_y) <= axes.bottom() + 1.0f);
// One trace, so one grid and one upload of one byte a cell -- and the *same* grid the plot
// reports, so the picture and the numbers are one derivation.
const plot::DensityGrid& grid = p.density_grid(0u);
PLOT_CHECK(first.density_cells == static_cast<plot::u32>(grid.cell_count()));
PLOT_CHECK(first.density_upload_bytes == static_cast<plot::u32>(grid.cell_count()));
// **The colour**: the cell the sweep is in is the trace's own colour at full strength -- one
// sweep is the ceiling, so a saturated cell *is* the trace -- and not the near-white monochrome
// the density used to draw in. The trace has no red and plenty of green and blue; a monochrome
// wash at full ink had all three high and near equal.
const int bg_g = channel(fresh, mid_x, blank_row, 1);
const int cell_r = channel(fresh, mid_x, trace_row, 0);
const int cell_g = channel(fresh, mid_x, trace_row, 1);
const int cell_b = channel(fresh, mid_x, trace_row, 2);
PLOT_CHECK(cell_r < 40); // the trace's colour has no red in it
PLOT_CHECK(cell_g > 150); // ... and its green is the ink
PLOT_CHECK(cell_g > cell_b && cell_b > cell_r); // the hue is the trace's, not a neutral
PLOT_CHECK(cell_g > bg_g + 40);
// A saturated cell **is** the trace's colour: one sweep at the ceiling draws the trace's own
// rgba, so the pixel matches it exactly rather than merely resembling it.
const plot::u8* cell_px = fresh.data() + (plot::usize(trace_row) * kW + mid_x) * 4u;
PLOT_CHECK(pixel_is(cell_px, trace_color));
// One time constant of the same pixel: dimmer, and still the trace's hue. This is the assertion
// that the current acquisition is inside the density -- the fade `exp(-dt/tau)`.
const double before = grid.ink();
p.decay_density(1.0); // dt = tau, so the whole image is e^-1 of what it was
const double after = grid.ink();
PLOT_CHECK_NEAR(after / before, std::exp(-1.0), 1e-4);
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> faded = read_frame(ctx);
const int faded_g = channel(faded, mid_x, trace_row, 1);
const int faded_r = channel(faded, mid_x, trace_row, 0);
PLOT_CHECK(faded_g < cell_g - 20); // the newest acquisition faded
PLOT_CHECK(faded_r < 40); // and it faded *as the trace's colour*, not toward a neutral
PLOT_CHECK(faded_g > bg_g); // ... to more than the background: still on screen
// **No solid trace over the image.** Move the series to y = +0.5 and draw *without* depositing:
// the plot draws no polyline while the density is on, so the +0.5 row is background and the only
// ink on screen is the sweep already in the image. A trace drawn over its own ghost -- the
// reported "persistent image + last acquisition" -- would put its colour at +0.5 here.
s.reset(std::vector<plot::f32>{0.5f, 0.5f}, 0.0, 1.0);
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> moved = read_frame(ctx);
const plot::u32 moved_row = static_cast<plot::u32>(ty.to_screen_y(0.5));
PLOT_CHECK(moved_row != trace_row);
// At +0.5 there is only the grid: the frame with the density is *identical* to the frame without
// one there, so nothing was drawn at the moved trace's own row -- no stroke over the image. (The
// row the sweep is in still holds its ink, so the image itself did not go away.)
PLOT_CHECK(channel(moved, mid_x, moved_row, 1) == channel(without, mid_x, moved_row, 1));
PLOT_CHECK(channel(moved, mid_x, moved_row, 0) == channel(without, mid_x, moved_row, 0));
PLOT_CHECK(channel(moved, mid_x, trace_row, 1) > bg_g + 20); // the image is still there
s.reset(std::vector<plot::f32>{0.0f, 0.0f}, 0.0, 1.0);
// **The steady state and a quarter of it, in the signal's colour.** Many acquisitions of a
// per-acquisition fade settle at `1/(1-f)` sweeps, which the one-sweep ceiling clamps to the
// trace's colour; a decay to a quarter of that ink is dimmer and keeps the hue.
plot::DensityConfig steady_cfg = cfg;
steady_cfg.decay = plot::DensityDecay::PerAcquisition;
steady_cfg.per_acquisition = 0.5f;
p.set_density(steady_cfg);
for (int i = 0; i < 24; ++i) p.accumulate_density();
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> steady = read_frame(ctx);
const int steady_g = channel(steady, mid_x, trace_row, 1);
const int steady_r = channel(steady, mid_x, trace_row, 0);
PLOT_CHECK(steady_g > bg_g + 60);
PLOT_CHECK(steady_r < 40);
// A quarter of the ink: `dt = tau*ln(4)` is one step of the time fade to a quarter.
plot::DensityConfig quarter_cfg = steady_cfg;
quarter_cfg.decay = plot::DensityDecay::TimeConstant;
quarter_cfg.time_constant_s = 1.0f;
p.set_density(quarter_cfg);
const double steady_ink = grid.ink();
p.decay_density(std::log(4.0));
PLOT_CHECK_NEAR(grid.ink() / steady_ink, 0.25, 1e-6);
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> quarter = read_frame(ctx);
const int quarter_g = channel(quarter, mid_x, trace_row, 1);
const int quarter_r = channel(quarter, mid_x, trace_row, 0);
PLOT_CHECK(steady_g > quarter_g + 20);
PLOT_CHECK(quarter_g > bg_g + 10);
PLOT_CHECK(quarter_r < 40); // still the signal's colour, not a neutral wash
// A frame with nothing new to deposit: the grid did not move, so the conversion and the upload
// are skipped -- the frame costs the one draw call -- and the picture is bit-identical.
r.begin_frame(kW, kH, kFrameRect);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
PLOT_CHECK(r.stats().density_cells > 0u);
PLOT_CHECK(r.stats().density_upload_bytes == 0u);
const std::vector<plot::u8> again = read_frame(ctx);
PLOT_CHECK(again == quarter);
std::printf("[density gl] cells=%u uploaded=%u differing=%u trace row=%u blank row=%u"
" axes=%.0f,%.0f %.0fx%.0f\n",
first.density_cells, first.density_upload_bytes, differing, trace_row, blank_row,
axes.x, axes.y, axes.w, axes.h);
std::printf("[density gl] green: bg=%d full=%d faded=%d steady=%d quarter=%d"
" (fresh ink %.0f -> %.0f)\n",
bg_g, cell_g, faded_g, steady_g, quarter_g, before, after);
ctx.destroy();
}
/// A grid **past `kDensityCellCap`** is the axes rect scaled down, and the frame magnifies the image
/// back over that rect: this is the screen-side half of the bound --
/// `a_density_grid_is_capped_whatever_the_window` reads the counts of a capped grid, and the two
/// agree only if the deposit and the draw use the same footprint, the axes rect in *whole* device
/// pixels.
///
/// What is read here is what a wrong scale moves: the image covers the rect's own columns rather
/// than the smaller grid's (the assertion a quad drawn at the grid's size fails), it stays inside
/// the rect, and the sweep is on the row its own transform maps y = 0 to, in the trace's colour.
PLOT_TEST(a_capped_density_is_magnified_over_the_axes_rect) {
const plot::u32 w = 900, h = 720;
plottest::HeadlessContext ctx(w, h);
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(std::vector<plot::f32>{0.0f, 0.0f}, 0.0, 1.0);
plot::Plot p;
p.set_viewport({0.0f, 0.0f, static_cast<plot::f32>(w), static_cast<plot::f32>(h)});
const plot::u32 ax = p.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
const plot::Color colour = plot::Color::hex(0x00E070);
PLOT_CHECK(p.add_trace({&s, colour, 1.5f, ax, ay, "flat"}) == 0u);
p.set_x_range(ax, {0.0, 1.0});
p.set_y_range(ay, {-1.0, 1.0});
const plot::Rect frame{0.0f, 0.0f, static_cast<plot::f32>(w), static_cast<plot::f32>(h)};
r.begin_frame(w, h, frame);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
const std::vector<plot::u8> without = read_frame(ctx);
plot::DensityConfig cfg;
cfg.enabled = true;
p.set_density(cfg);
p.accumulate_density();
const plot::DensityGrid& grid = p.density_grid(0u);
const plot::Rect axes = p.layout().axes;
// The rect is past the cap, so the grid is smaller than it and the frame has to scale the image
// up -- and the one flat sweep still lights every column, once.
PLOT_CHECK(grid.cell_count() > 0u);
PLOT_CHECK(grid.cell_count() <= plot::kDensityCellCap);
PLOT_CHECK(static_cast<plot::u64>(grid.columns()) < static_cast<plot::u64>(std::ceil(axes.w)));
PLOT_CHECK(static_cast<plot::u64>(grid.rows()) < static_cast<plot::u64>(std::ceil(axes.h)));
PLOT_CHECK_NEAR(grid.ink(), static_cast<double>(grid.columns()), 1.0);
r.begin_frame(w, h, frame);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
const plot::FrameStats stats = r.stats();
PLOT_CHECK(stats.density_cells == static_cast<plot::u32>(grid.cell_count()));
PLOT_CHECK(stats.density_upload_bytes == static_cast<plot::u32>(grid.cell_count()));
const std::vector<plot::u8> capped = read_frame(ctx);
const auto differs = [&](plot::u32 x, plot::u32 y) {
const plot::usize k = (plot::usize(y) * w + x) * 4u;
return capped[k] != without[k] || capped[k + 1u] != without[k + 1u] ||
capped[k + 2u] != without[k + 2u];
};
const auto at = [&](const std::vector<plot::u8>& f, plot::u32 x, plot::u32 y, int c) {
return static_cast<int>(f[(plot::usize(y) * w + x) * 4u + static_cast<plot::usize>(c)]);
};
// **The image covers the rect's columns, not the grid's.** A quad drawn at the grid's own size --
// the temptation once a cell is no longer a pixel -- would paint the left `columns` of the rect
// and leave the rest of it blank, which this counts.
plot::u32 changed_cols = 0;
plot::u32 lo_x = w, hi_x = 0, lo_y = h, hi_y = 0;
for (plot::u32 x = 0; x < w; ++x) {
bool any = false;
for (plot::u32 y = 0; y < h; ++y) {
if (!differs(x, y)) continue;
any = true;
lo_x = std::min(lo_x, x);
hi_x = std::max(hi_x, x);
lo_y = std::min(lo_y, y);
hi_y = std::max(hi_y, y);
}
if (any) ++changed_cols;
}
PLOT_CHECK(changed_cols > 0u);
PLOT_CHECK(changed_cols + 2u >= static_cast<plot::u32>(axes.w));
// ... and it stays inside the rect it is an image of.
PLOT_CHECK(static_cast<plot::f32>(lo_x) >= axes.x - 1.0f);
PLOT_CHECK(static_cast<plot::f32>(hi_x) <= axes.right() + 1.0f);
PLOT_CHECK(static_cast<plot::f32>(lo_y) >= axes.y - 1.0f);
PLOT_CHECK(static_cast<plot::f32>(hi_y) <= axes.bottom() + 1.0f);
// The sweep is on the row its own transform maps y = 0 to, in the trace's own colour: the cell's
// one sweep is spread over the block of device pixels it was magnified into, so what is asserted
// is that the ink is *there* -- the brightest row of the rect is that row -- and that its hue is
// the trace's rather than the background's.
const plot::u32 mid_x = static_cast<plot::u32>(p.x_transform(ax).to_screen_x(0.5));
const plot::u32 trace_row = static_cast<plot::u32>(p.y_transform(ay).to_screen_y(0.0));
// The background to read the ink against is a row of the *without* frame away from the sweep's
// own: the trace itself is drawn there (the density is off in that frame), so the trace's own row
// is the trace's colour and not a background at all.
const plot::u32 blank_row = trace_row + 40u < h ? trace_row + 40u : trace_row - 40u;
const int bg_g = at(without, mid_x, blank_row, 1);
int band_g = 0;
int band_y = 0;
for (plot::u32 y = static_cast<plot::u32>(axes.y); y < static_cast<plot::u32>(axes.bottom()); ++y) {
const int g = at(capped, mid_x, y, 1);
if (g > band_g) {
band_g = g;
band_y = static_cast<int>(y);
}
}
PLOT_CHECK(band_g > bg_g + 40); // the sweep is on screen at all
PLOT_CHECK(at(capped, mid_x, trace_row, 0) < 40); // ... in the trace's own hue ...
PLOT_CHECK(at(capped, mid_x, trace_row, 1) > bg_g + 40);
PLOT_CHECK(std::abs(band_y - static_cast<int>(trace_row)) <= 2);
std::printf("[density gl] capped grid=%ux%u cells=%zu of a %.0fx%.0f axes: changed cols=%u"
" brightest row=%d (transform says %u), green=%d against bg=%d\n",
grid.columns(), grid.rows(), grid.cell_count(), axes.w, axes.h, changed_cols, band_y,
trace_row, band_g, bg_g);
ctx.destroy();
}
+37
View File
@@ -21,6 +21,43 @@ PLOT_TEST(ring_clear_resets) {
PLOT_CHECK_NEAR(r[0], 42.0);
}
PLOT_TEST(ring_clear_to_resumes_the_absolute_count) {
// A recompute: a derived ring is emptied and refilled from the absolute origin its source
// retains, so it has to come back numbered as the source is. `clear()` restarts at 0, which
// leaves a refilled ring reporting a produced() a whole window behind an identically fed one.
plot::data::RingBuffer<f32> src(8);
plot::data::RingBuffer<f32> derived(8);
for (int i = 0; i < 20; ++i) {
src.push(f32(i));
derived.push(f32(i) * 2.0f);
}
PLOT_CHECK(src.base() == 12);
derived.clear_to(src.base());
for (u64 i = 0; i < src.size(); ++i) derived.push(src[i] * 3.0f);
// Numbered as the source: same produced(), same base(), and the retained windows pair up.
PLOT_CHECK(derived.produced() == src.produced());
PLOT_CHECK(derived.base() == src.base());
PLOT_CHECK(derived.size() == src.size());
PLOT_CHECK_NEAR(derived[0], 36.0); // 12 * 3
PLOT_CHECK_NEAR(derived[7], 57.0); // 19 * 3
// And the absolute reader agrees with the windowed one, which is what a snapshot reads.
f32 at = 0.0f;
derived.copy_from(19, 1, &at);
PLOT_CHECK_NEAR(at, 57.0);
}
PLOT_TEST(ring_clear_to_zero_is_clear) {
plot::data::RingBuffer<f32> r(8);
for (int i = 0; i < 5; ++i) r.push(f32(i));
r.clear_to(0);
PLOT_CHECK(r.size() == 0);
PLOT_CHECK(r.produced() == 0);
r.push(42.0f);
PLOT_CHECK_NEAR(r[0], 42.0);
}
PLOT_TEST(ring_capacity_rounds_up_to_a_power_of_two) {
// The capacity is rounded up so slot addressing stays a mask, and capacity()
// reports the rounded value -- the one callers must size for (R4).