Compare commits

...
7 Commits
Author SHA1 Message Date
Martino Ferrari faef19ba60 fix(figure): a following group keeps its whole window on the data
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
The offset of a following group was clamped to `retained` -- measured from the newest sample -- so
a pan (or a zoom-in anchored near an edge) could put the window's *right* edge exactly on the oldest
retained sample. That leaves the window's entire span to its left over data the scope never had: an
empty margin as wide as the view. The bound is about the whole window, so it is `retained - span` --
but only while the window *can* sit on the data. When the members retain less than a window's worth
(a ring still filling, or one just trimmed) nothing avoids a margin, and the older placement is the
honest one and stays: right edge on the newest sample, the shortfall on the left.

Test: a_following_group_keeps_its_whole_window_on_the_data -- asks to scroll ten spans past the
oldest sample and asserts the window is still a window, with its left edge on the data. The
existing `a_following_log_group_holds_decades` codified the old bound (`max == edge/10` after asking
for a decade of pan with far less than that retained); its log-space subject is unchanged and the
offset assertion is now the invariant above.
2026-10-03 15:58:52 +02:00
Martino Ferrari 7dbe4e7183 feat(render): mark the samples of a window sparse enough to see them
`Style::marker_below_samples` (50 by default, `0` for never) draws a `marker_px` dot on every
*raw* sample when the visible window holds at most that many, in the trace's own colour. Zoomed
in far enough to see individual samples, the polyline between them stops being the shape a reader
is looking at -- a handful of points across a whole panel ARE the picture -- and a dot makes each
one a place rather than a bend in a line. Level 0 only: above it the geometry is pyramid buckets,
which are not points anyone can find.

Test: a_sparse_window_marks_its_samples -- the same flat series at ten samples and at a hundred.
Ten gives exactly ten dots, evenly spaced (their centres are checked against the panel's own
step, so "they are the samples" is asserted rather than "something was painted"); a hundred is
past the threshold and gives none. The dots *group* into runs of columns (a marker is `marker_px`
wide), which is how the test reads one marker per sample -- counting columns read three.
2026-10-03 15:39:49 +02:00
Martino Ferrari fba8d985cf feat(cursor): a cursor's stroke is its own -- width and dash
Cursors took `Style::line_width_px` and were always solid, so a host had no way to place a
*readout mark* on the data (a measured level, a peak, a pulse's edge) without it reading as
another instrument framing the plot. `set_cursor_stroke(id, width, dash)` gives it its own, the
same shape as `set_cursor_color()`: per cursor, because one plot holds both kinds.

`width <= 0` means the style's default for that cursor (`handle_width_px` while hovered or
grabbed, `line_width_px` otherwise), and a hover can only make the stroke *thicker* -- the cue
that the pointer has hold of something must not be the thing that hides it. `dash.off <= 0` is
solid, the convention a trace's, the crosshair's and the grid's dash already share.

Test: a_cursor_stroke_can_be_thinner_and_dashed -- a default cursor and a thin dashed one in the
same frame. The dashed column alternates ink and gap (153/151 at the 4/4 the test asks for)
while the solid one has no gaps; the ink laid down over each stroke's whole run of columns is
less for the thin one. That last assertion is deliberately *not* a single-column peak: a cursor
is a data position and is not snapped, so the solid stroke's ink split across two columns and
read *lighter* than the thin one -- which is how this test first failed.
2026-10-03 15:22:01 +02:00
Martino Ferrari c4c0a49466 style(grid): brighter major grid
grid_major 0x2A3038 -> 0x39414C, still well under axis_line's 0x8A94A6, so the three-way
hierarchy the eye reads stays minor < major < axis. Already fully opaque, so "more
prominent" meant a brighter colour and nothing else.

The tests that classify grid pixels take their colours from the style, so they followed
the change; the one hardcoded mention (grid_pixel()'s doc comment) is updated, and the
minor's relationship to the major is now stated as an order of magnitude rather than the
fifth the old colours happened to give. 361 cases, 0 failed.
2026-10-03 14:42:06 +02:00
Martino Ferrari 01e410bd74 style(grid): the minor grid is thinner, dashed and blended into the background
Subdivisions used to be drawn exactly like the labelled lines -- same width, same
opacity -- so a plot dense enough to need them read as two competing sets of ticks.
The minor class is now three things the style states explicitly:

- grid_minor_width 1.0 -> 0.75, a sub-pixel logical width. At 1x that is expressed as
  reduced coverage through the anti-aliased ramp; on a HiDPI display the line is
  literally narrower.
- grid_minor_dash_on/_off (new, 3/3): dashed by default, off == 0 draws it solid, the
  same convention as the crosshair's and a trace's dash. The dash is a fragment-shader
  discard, so no geometry is added -- the vertices are the line either way.
- grid_minor carries alpha (0x1C2126 at 50%), and the line shader applies u_color.a,
  so it blends into the background instead of sitting on it.

Together that is about a fifth of the ink a major line leaves along its length, which
is what gives the eye one grid to read instead of two. The major class is untouched:
it stays the structure.

Tests: a new pixel test (the_minor_grid_is_thinner_dashed_and_dimmer_than_the_major)
asserts a major column is inked top-to-bottom while a minor one alternates, and that
the minor's ink sits closer to the background than the major's. Three existing tests
counted the minor class as "most of eight rows" of its exact colour; two now take
their floor from the style (minor_line_ink: width x alpha x duty), and the width test
states the dash and alpha away because widths are its subject. 361 cases, 0 failed.
2026-10-03 14:31:57 +02:00
Martino Ferrari f3fb500068 fix(render): map the visible range through the series' own x
Renderer::draw_traces picked the sample window with (x - x0()) / dx(), and dx() is
documented as a *nominal* spacing -- for a series whose x is a streamed timestamp it
is only the mean of the retained window. The window it computed therefore landed
inside the visible range, and the two end segments, the ones that enter and leave the
frame, were never emitted: the left and right margins of a zoomed-in view came out
empty. A wide enough drift dropped the trace entirely (last <= first).

It asks Series::neighbours() now -- a binary search over the real x, the same call the
crosshair reads through, costing the same. A uniform series answers the same question
through the same call, so it is one path. dx is still read for the samples-per-pixel
density estimate, where a nominal spacing is exactly right.

Test: a_sparse_window_between_samples_still_inks_both_margins -- a series whose dx()
lies, with the window strictly between two samples, so nothing inside the window is
sampled and only the crossing segments can ink it. With the grid walk reverted it
stages nothing at all (visible=0, gathered=0, covered=0/24); with the fix it stages the
three bracketing samples and covers 24/24 columns.
2026-10-03 14:20:11 +02:00
Martino Ferrari 2dcd1f1864 feat(plot): an axis can write its labels relative to an origin, and time scales by prefix
A host zooming into a sub-second window cannot label it absolutely: Time
formatting uses three significant digits, so 12.345671s and 12.345672s are the
same string and a 1 ms step around a multi-second time is unlabelled. The axis
gains an `origin`: every value it writes is `v - origin`, and the ticks are
generated on the shifted grid, so the window reads 0, 500us, 1ms.
`origin_follows_range` re-takes the origin from the axis' own range on every
refresh -- the mode a *moving* window needs, since the edge is derived from the
newest sample at layout time and a value the host wrote a frame earlier is a
frame of scroll stale.

`format_time` now scales its sub-second values by prefix, the way `format_si`
does on a y axis: the unit that keeps the mantissa in [1, 1000). A fixed
millisecond unit printed 0.005ms where 5us is the reading, and 1e-05ms where
10ns is. An exact zero prints `0`, the same convention `format_si` states. The
origin subtraction's dust is snapped by a *relative* epsilon, so a tick on the
origin reads 0 rather than 1.78e-12ms.

Tested by `ticks_are_written_relative_to_an_axis_origin`,
`a_range_origin_puts_the_zero_tick_on_the_left_edge` and the extended
`format_time_shapes`; the suite is 359 cases, 0 failed.
2026-10-03 12:14:42 +02:00
16 changed files with 899 additions and 60 deletions

No files matched your search

+14 -1
View File
@@ -14,6 +14,11 @@ built on top of it.
| Qt6 | `plot::qt6` | `adapters/qt6/` |
| GTK4 | `plot::gtk4` | `adapters/gtk4/` |
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
timestamp would otherwise have its draw window land inside the visible range and lose the two
segments that enter and leave the frame — the empty left and right margins of a zoomed-in view.
Per-frame rendering cost is O(visible pixels + samples produced since the last frame),
not O(samples): a trace denser than one sample per pixel is drawn from a min/max LOD
pyramid rather than from raw samples. The `plot_multiplot_demo` run stages **20,901 GPU
@@ -24,8 +29,16 @@ run touched a GPU, so read it as an upper bound and a record of the software pat
## Features
- a following group's window stays on its members' data: while the members retain at least a
window's worth, the scroll offset is bounded by `retained - span`, so a pan or a zoom-in near an
edge cannot leave the window's own span over data the scope never had (a window *wider* than the
data keeps the older placement -- right edge on the newest sample);
- 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`,
`grid_minor_visible`);
`grid_minor_visible`), each with its own colour, width and (for the minors) dash — the
subdivisions default to thinner, dashed and alpha-blended, so they read as texture under the
labelled grid rather than as a second set of ticks;
- x and y axes, with a vertical (bottom-to-top) y-axis title;
- cursors in both orientations — vertical markers and horizontal rulers;
- draggable regions and measurement bands with exact interval statistics;
+30 -3
View File
@@ -36,8 +36,8 @@ Colours (neither logical nor device — nothing to scale):
| field | default | meaning |
|---|---|---|
| `background` | `0x101418` | frame clear colour and legend/readout panel base |
| `grid_major` | `0x2A3038` | major (labelled-tick) grid lines |
| `grid_minor` | `0x1C2126` | minor (subdivision) grid lines |
| `grid_major` | `0x39414C` | major (labelled-tick) grid lines |
| `grid_minor` | `0x1C2126` at 50% alpha | minor (subdivision) grid lines |
| `axis_line` | `0x8A94A6` | axis lines |
| `axis_text` | `0xC8D0DC` | tick labels and axis titles |
| `cursor` | `0xFFB020` | cursor markers; also the hovered/grab highlight |
@@ -56,8 +56,12 @@ Logical metrics (scaled by `device()`):
|---|---|---|
| `axis_font_px` | `12.0` | logical em the axis text is drawn at |
| `grid_label_pad` | `4.0` | gap between a tick label and the axes, and the inset inside a title strip |
| `marker_below_samples` | `50` | a dot per raw sample when the window holds at most this many (`0` = never) |
| `marker_px` | `3.0` | logical size of that dot |
| `grid_major_width` | `1.0` | width of a major grid line |
| `grid_minor_width` | `1.0` | width of a minor grid line |
| `grid_minor_width` | `0.75` | width of a minor grid line (sub-pixel on purpose) |
| `grid_minor_dash_on` | `3.0` | minor grid dash: ink length in logical pixels |
| `grid_minor_dash_off` | `3.0` | minor grid dash: gap; `0` draws the minor grid solid |
| `grid_major_visible` | `true` | stage the major grid at all |
| `grid_minor_visible` | `true` | stage the minor grid at all |
| `line_width_px` | `1.5` | width of the strokes framing the data (axis lines, cursor markers) |
@@ -91,6 +95,29 @@ transparent. `Grid` therefore carries both classes' geometry whatever the style
caller that draws the grid itself applies the same two switches. `Grid::vertical` and
`Grid::horizontal` (both `true`) control which directions emit lines at all.
The labelled class is the **structure** and the subdivisions are **texture**, and the minor
grid's three defaults are what makes that read: it is thinner than the major hairline
(`grid_minor_width`, a sub-pixel width that at 1x is expressed as coverage and on a HiDPI
display as a narrower line), its colour carries an **alpha** so it blends into the
background instead of sitting on it, and it is **dashed** (`grid_minor_dash_on`/`_off`) so an
unlabelled line at an unlabelled position is never mistaken for a labelled one. Together
those put a minor line at an order of magnitude less ink than a major one — at full opacity and the
same width a dense minor grid reads as a second set of ticks. The dash is a fragment-shader
discard, not extra geometry: the vertices are the line either way. Set
`grid_minor_dash_off = 0` for the old solid subdivision.
## A cursor's stroke
`set_cursor_color()` gives a cursor its own colour and `set_cursor_stroke()` its own width and
dash (`Cursor::width`, `Cursor::dash`). Per cursor rather than per plot, because a host uses
cursors for two different jobs: the instruments that frame a reading — a marker at an edge, a pull
handle — and the *readout marks* a computation places on the data (a measured level, a peak, a
pulse's edges). Those want different weights. `width <= 0` means the style's default for that
cursor (`handle_width_px` while it is hovered or grabbed, `line_width_px` otherwise), and a hover
can only make the stroke *thicker*: the cue that the pointer has hold of something must not be the
thing that hides it. `dash.off <= 0` (the default) is solid — the same convention as a trace's,
the crosshair's and the grid's.
## The crosshair's dash
`crosshair_dash_on`/`crosshair_dash_off` are the fragment shader's discard pattern, so no
+17
View File
@@ -52,6 +52,23 @@ struct Axis {
AxisPlace place = AxisPlace::Bottom;
bool log = false;
TickFormat format = TickFormat::Si;
/// The value the labels are written *relative to*: every value on this axis is formatted as
/// `v - origin`, and the ticks are generated on that shifted grid. A host uses it to read a
/// zoomed-in window against its own left edge -- the labels then say `0, 0.5ms, 1ms` with the
/// window's start printed once beside the axis, instead of `12.345671s, 12.345672s` (which
/// three significant digits cannot even distinguish). 0 by default, which is the plain
/// absolute axis; the *positions* of the ticks are unchanged by it in the sense that only
/// their printed value shifts.
f64 origin = 0.0;
/// When set, `origin` is re-taken from the axis' own range on every `refresh()` -- the labels
/// then count from whatever the left edge currently is, and the tick on that edge reads zero
/// exactly, without a host having to predict where the follow will place the window (which it
/// cannot: the window moves with the samples, and a value written a frame earlier is a frame
/// of scroll stale). The window-start label a host draws beside the axis is then
/// `origin` *after* the frame, which is the value these labels were built with.
bool origin_follows_range = false;
/// Whether this axis follows the newest sample. Only meaningful on an x axis --
/// a y axis has no live edge -- and `Off` on both, so nothing changes for a
/// caller who never asks for it. See XFollow.
+14
View File
@@ -6,6 +6,7 @@
#include <plot/core/color.hpp>
#include <plot/core/types.hpp>
#include <plot/math/vec2.hpp>
namespace plot {
@@ -92,6 +93,19 @@ struct Cursor {
/// hit radius it would have taken is left to whatever is behind it. Drawn exactly as a
/// visible one is: this is about the gesture, not the ink.
bool selectable = true;
/// The stroke's logical width; `<= 0` means the style's default for this cursor
/// (`Style::handle_width_px` while it is hovered or grabbed, `Style::line_width_px`
/// otherwise). Per cursor, like the colour, because a host uses cursors for two different
/// jobs: the instruments that frame a reading (a marker at an edge, a pull handle) and the
/// *readout marks* a computation places on the data (a measured level, a peak). Those want
/// different weights, and a hover can only make the stroke thicker, never thinner -- the cue
/// that the pointer has hold of something must not be the thing that hides it.
f32 width = 0.0f;
/// The dash, logical pixels: ink length and gap. `off <= 0` (the default) is a solid stroke --
/// the same convention as a trace's, the crosshair's and the grid's. A dashed readout mark is
/// distinguishable by *form* from the solid chrome around it, which is what an instrument that
/// reports a number should be.
Vec2 dash{};
/// True when this is a horizontal ruler (spans the axes' width, reads its `y`). The one
/// place the orientation becomes a branch, so every consumer takes the same decision and
+19
View File
@@ -484,10 +484,29 @@ class Plot {
/// be told apart from its neighbours, and its colour is how it says which readout it
/// 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 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);
/// Makes a cursor unselectable (see `Cursor::selectable`): the pointer cannot hover or
/// grab it, which is what a host wants for a marker it draws as a *readout*. An id that
/// names no cursor is ignored, like every other setter here.
void set_cursor_selectable(u32 id, bool selectable);
/// Sets the value an axis' labels are written relative to (see `Axis::origin`). The ticks
/// are generated on that shifted grid too, so a host that zooms into a window can label it
/// from its own left edge. An id that names no axis of that orientation is ignored, like the
/// other setters here -- x and y ids are separate sequences, so the orientation is part of
/// the call.
void set_x_axis_origin(u32 axis, f64 origin);
void set_y_axis_origin(u32 axis, f64 origin);
/// The origin the matching setter stored (0 for an axis that never had one).
f64 x_axis_origin(u32 axis) const;
f64 y_axis_origin(u32 axis) const;
/// Makes an axis' label origin follow its own range (see `Axis::origin_follows_range`): the
/// labels then count from the left edge the frame actually has, which is the only way a
/// host can label a *moving* window from its start without being a frame behind.
void set_x_axis_origin_from_range(u32 axis, bool on);
void set_y_axis_origin_from_range(u32 axis, bool on);
/// 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
+5 -2
View File
@@ -48,7 +48,10 @@ void format_si(f64 v, char* buf, usize n, const char* unit = nullptr);
/// field is derived from that one number, so no field can ever reach its radix:
/// 119.5 s is `2:00`, not `1:60`, and 3599.5 s is `1:00:00`.
///
/// Shapes, by magnitude: below 1 s `500ms`; below 60 s `1.5s`; below 1 h `1:30`;
/// Shapes, by magnitude: below 1 s the prefixed unit that keeps the mantissa in
/// `[1, 1000)` -- `500ms`, `5ms`, `500us`, `5us`, `5ns`, `5ps` (the micro sign is
/// UTF-8 U+00B5, as in `format_si`) -- with an exact zero printed as `0` rather
/// than as a zero of some unit; below 60 s `1.5s`; below 1 h `1:30`;
/// at or above 24 h, as a decimal day count `2.5d`. Between 1 h and 24 h the
/// shape is `1:01:01` -- and because a value that rounds up to 24 h takes the day
/// shape, the hour field of that shape is always below 24. The 60 s and 1 h
@@ -87,6 +90,6 @@ inline constexpr usize kMaxTicks = 256;
/// single-sample series), a non-positive `px` or `min_px`, and a span whose step
/// overflows to infinity.
void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out,
bool include_minors = false);
bool include_minors = false, f64 origin = 0.0);
} // namespace plot
+35 -3
View File
@@ -38,8 +38,19 @@ struct Style {
// --- colours: neither logical nor device, so nothing to scale ------------
Color background = Color::hex(0x101418);
Color grid_major = Color::hex(0x2A3038);
Color grid_minor = Color::hex(0x1C2126);
/// The labelled grid. Brighter than the subdivisions and still well under `axis_line`
/// (0x8A94A6): the grid is structure the eye can read, the axis is the frame, and the
/// hierarchy between the three is what keeps a dense plot legible. It is already fully
/// opaque, so "more prominent" means a brighter colour and nothing else.
Color grid_major = Color::hex(0x39414C);
/// The minor grid's colour, **with an alpha** so it blends into the background rather than
/// sitting on it: a subdivision is a reading aid, and at full opacity a dense minor grid
/// reads as a second set of ticks competing with the labelled one. The line shader applies
/// `u_color.a` (see `src/render/shaders.cpp`), so this one blends and the major -- opaque at
/// 0x39414C -- stays the structure the eye picks out. Together with `grid_minor_width` and
/// the dash below, the defaults put a minor line at an order of magnitude less ink than a
/// major one.
Color grid_minor = Color::hex(0x1C2126, 0.5f);
Color axis_line = Color::hex(0x8A94A6);
Color axis_text = Color::hex(0xC8D0DC);
Color cursor = Color::hex(0xFFB020);
@@ -96,10 +107,31 @@ struct Style {
/// skips it before the first segment is staged, so a dense plot's minor grid
/// costs nothing rather than being drawn transparent (spec §11). The colours
/// above are already per class.
/// When a trace's window holds at most this many raw samples, each one is also drawn as a dot
/// `marker_px` logical pixels across, in the trace's own colour. `0` draws none. Zoomed in far
/// enough to see individual samples -- a handful of them across a whole panel -- the polyline
/// *between* them stops being the shape, and the samples themselves are what a reader is looking
/// at; a marker makes each one a place rather than a bend in a line.
f32 marker_below_samples = 50.0f;
f32 marker_px = 3.0f;
f32 grid_major_width = 1.0f; ///< logical width of a major (labelled-tick) line.
f32 grid_minor_width = 1.0f; ///< logical width of a minor (subdivision) line.
/// Logical width of a minor (subdivision) line: **thinner than the major hairline**, and
/// sub-pixel on purpose. At 1x that is expressed as *coverage* -- the anti-aliased ramp
/// (`line_aa_px`) lights one column at roughly three quarters of the ink -- and on a HiDPI
/// display as a line that is literally narrower. The point is hierarchy: the labelled grid is
/// structure, the subdivisions are texture.
f32 grid_minor_width = 0.75f;
bool grid_major_visible = true; ///< stage the major grid at all.
bool grid_minor_visible = true; ///< stage the minor grid at all.
/// The minor grid's dash, in logical pixels: ink length and gap. **`off == 0` draws it
/// solid** -- the same convention as the crosshair's and a trace's dash -- which is what a
/// caller that wants the old solid subdivision states. Dashed is the default because an
/// unlabelled line at an unlabelled position should never be mistaken for a labelled one, and
/// because a broken line carries less weight over data than a continuous one. The dash is a
/// fragment discard, never extra geometry, so a dense minor grid costs no more vertices for
/// being dashed.
f32 grid_minor_dash_on = 3.0f;
f32 grid_minor_dash_off = 3.0f;
f32 line_width_px = 1.5f; ///< logical width of the strokes framing the data
///< (axis lines, cursor markers).
/// Logical half-width of the anti-aliased fringe the line shader blends at
+17 -3
View File
@@ -3,18 +3,32 @@
#include <plot/plot/axis.hpp>
#include <cmath>
namespace plot {
void Axis::format_value(f64 v, char* buf, usize n) const {
// The one place a value on this axis becomes text, so the origin is applied here and
// everything that goes through it -- tick labels, and the crosshair's readout -- reads in
// the same frame of reference as the axis itself.
// The subtraction leaves a residue when the value and the origin are the same distant number
// (a tick exactly on the origin then formatted as `1.78e-12ms` instead of `0ms`). Snapping it
// is a *relative* test, so a genuinely small value on a large axis is not swallowed -- what
// this removes is arithmetic dust, not data.
f64 shown = v - origin;
if (std::fabs(shown) < 1.0e-12 * std::max(1.0, std::fabs(v))) shown = 0.0;
if (format == TickFormat::Time) {
format_time(v, buf, n);
format_time(shown, buf, n);
return;
}
format_si(v, buf, n);
format_si(shown, buf, n);
}
void Axis::refresh(f32 px, f32 min_px, bool include_minors) {
generate_ticks(range, px, min_px, ticks, include_minors);
// The range first, so a following origin is this frame's edge and not the last one's: the
// zero tick then sits exactly on the window start, which is the whole point of asking.
if (origin_follows_range) origin = range.min;
generate_ticks(range, px, min_px, ticks, include_minors, origin);
if (format != TickFormat::Time) return;
// generate_ticks labels with format_si; a time axis relabels its majors here,
// because the tick generator has no business knowing about a clock -- through the one
+14 -3
View File
@@ -4,6 +4,7 @@
#include <plot/plot/figure.hpp>
#include <algorithm>
#include <cstdio>
#include <cmath>
#include <cstddef>
@@ -277,13 +278,23 @@ bool Figure::place_live_window(XLink& group) {
// The offset can outlive the window it names: a ring that dropped samples moves
// the oldest x forward under it, so it is clamped against what the members hold
// *now* -- measured in the same space, or a log group could hold an offset larger
// than everything it retains. An offset past that would put the window's right
// edge behind the oldest sample left, i.e. over data that is gone.
// than everything it retains.
//
// The bound is about the whole *window*, not just its right edge -- but only when the window
// *can* sit on the data. `retained >= span` is that case, and there the offset may not exceed
// `retained - span`: with the loose bound a scroll could put the window's right edge exactly on
// the oldest sample, which leaves the entire span to its left over data the scope never had --
// an empty margin as wide as the view, reached by a pan or by a zoom-in near that edge. When the
// window is *wider* than what the members retain (a ring still filling, or one just trimmed)
// nothing can avoid a margin, and the honest placement is the old one: the window's right edge
// on the newest sample, the shortfall on the left, which is what a scope that just started
// should look like.
const bool log = !plots_.empty() && plots_.back().x_axis(0u).log;
const f64 newest_space = to_link_space(live.max, log);
const f64 retained =
std::max(0.0, newest_space - to_link_space(live.min, log));
group.offset = std::clamp(group.offset, 0.0, retained);
const f64 reach = retained >= group.span ? retained - group.span : retained;
group.offset = std::clamp(group.offset, 0.0, std::max(0.0, reach));
group.live_newest = live.max;
group.has_live = true;
const f64 right = newest_space - group.offset;
+34
View File
@@ -311,11 +311,45 @@ void Plot::set_cursor_color(u32 id, Color color) {
cursors_[id].color = color;
}
void Plot::set_cursor_stroke(u32 id, f32 width, Vec2 dash) {
if (id >= cursors_.size()) return;
cursors_[id].width = width;
cursors_[id].dash = dash;
}
void Plot::set_cursor_selectable(u32 id, bool selectable) {
if (id >= cursors_.size()) return;
cursors_[id].selectable = selectable;
}
void Plot::set_x_axis_origin(u32 axis, f64 origin) {
if (axis >= x_axes_.size()) return;
x_axes_[axis].origin = origin;
}
void Plot::set_y_axis_origin(u32 axis, f64 origin) {
if (axis >= y_axes_.size()) return;
y_axes_[axis].origin = origin;
}
void Plot::set_x_axis_origin_from_range(u32 axis, bool on) {
if (axis >= x_axes_.size()) return;
x_axes_[axis].origin_follows_range = on;
}
void Plot::set_y_axis_origin_from_range(u32 axis, bool on) {
if (axis >= y_axes_.size()) return;
y_axes_[axis].origin_follows_range = on;
}
f64 Plot::x_axis_origin(u32 axis) const {
return axis < x_axes_.size() ? x_axes_[axis].origin : 0.0;
}
f64 Plot::y_axis_origin(u32 axis) const {
return axis < y_axes_.size() ? y_axes_[axis].origin : 0.0;
}
void Plot::set_annotation(u32 id, const Annotation& annotation) {
if (id >= annotations_.size()) return;
annotations_[id] = annotation;
+33 -9
View File
@@ -99,14 +99,34 @@ void format_time(f64 seconds, char* buf, usize n) {
const f64 t = std::fabs(seconds);
if (t < 1.0) {
// Sub-second: milliseconds. The 999.5 cut keeps %.3g out of scientific
// notation (it would print "1e+03ms") right below the one-second boundary.
const f64 ms = t * 1000.0;
if (ms < 999.5) {
std::snprintf(buf, n, "%s%.3gms", sign, ms);
} else {
std::snprintf(buf, n, "%s%.3gs", sign, t);
// Sub-second: the prefix that keeps the mantissa in [1, 1000) -- the same rule the SI
// formatter uses on a y axis, and the reading a zoomed-in window needs. A fixed
// millisecond unit printed `0.005ms` where `5us` is what the axis is showing, and
// `1e-05ms` where `10ns` is. The micro sign is the UTF-8 sequence the embedded font has a
// glyph for (see `format_si`), not a Latin-1 byte.
// Exactly zero carries no unit, the same convention `format_si` states: every prefixed
// form of it is "0" with a unit the value does not have.
if (t == 0.0) {
std::snprintf(buf, n, "0");
return;
}
static const struct {
f64 scale;
const char* unit;
} kUnits[] = {
{1.0e-3, "ms"},
{1.0e-6, "\u00b5s"},
{1.0e-9, "ns"},
{1.0e-12, "ps"},
};
for (const auto& u : kUnits) {
if (t < u.scale) continue;
std::snprintf(buf, n, "%s%.3g%s", sign, t / u.scale, u.unit);
return;
}
// Below a picosecond nothing on a time axis reads in prefixed units; plain seconds keep
// the fallback's own precision rather than inventing one.
std::snprintf(buf, n, "%s%.3gs", sign, t);
return;
}
@@ -131,7 +151,8 @@ void format_time(f64 seconds, char* buf, usize n) {
}
}
void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out, bool include_minors) {
void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out, bool include_minors,
f64 origin) {
out.clear();
const f64 lo = range.min;
@@ -157,7 +178,10 @@ void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out, boo
: 10.0);
if (!std::isfinite(step) || !(step > 0.0)) return; // span so large the step overflowed
const f64 first_major = std::ceil(lo / step) * step;
// The major walk starts on the *origin's* grid: with an origin the ticks a host reads are
// round numbers relative to it (0, 0.5ms, 1ms of the window it zoomed into), which is the
// whole point of having one. `origin = 0` is the plain absolute grid.
const f64 first_major = origin + std::ceil((lo - origin) / step) * step;
if (!std::isfinite(first_major)) return;
// Minor ticks subdivide the major step: by 4 when the step's leading digit is
+63 -26
View File
@@ -42,15 +42,6 @@ bool fail(Status* st, ErrorCode code, const char* message) noexcept {
/// clamp, so it lands off-scale rather than as NaN.
f64 space(f64 v, bool log) noexcept { return log ? Transform::log10_positive(v) : v; }
/// Sample index for a (possibly fractional, possibly non-finite) position in a
/// series' index space, clamped to [0, n]: NaN and -inf to 0, +inf to n, so a
/// range the axis cannot map still yields a valid window.
u64 clamp_index(f64 v, u64 n) noexcept {
if (!(v > 0.0)) return 0;
if (v >= static_cast<f64>(n)) return n;
return static_cast<u64>(v);
}
/// One ASCII/UTF-8 line of the measurement readout. `format_si` leaves room for
/// three significant digits plus a prefix and sign.
constexpr usize kNumberChars = 24;
@@ -807,7 +798,7 @@ void Renderer::draw_grid(const Plot& plot) {
// to draw rather than being drawn transparent. Majors first, minors over them, as
// the single group order always did. `LineBatch::clear()` keeps its capacity, so
// the second pass re-uses the first one's storage.
const auto draw_class = [&](bool major, bool visible, Color color, f32 width) {
const auto draw_class = [&](bool major, bool visible, Color color, f32 width, Vec2 dash) {
if (!visible) {
return;
}
@@ -817,12 +808,21 @@ void Renderer::draw_grid(const Plot& plot) {
}
// Snapped: a grid line is chrome at a round *data* value, and a hairline that
// straddles two columns reads as a grey smear and shimmers as the view pans.
flush_lines(map, color, width, Vec2{}, true);
flush_lines(map, color, width, dash, true);
};
draw_class(true, style_.grid_major_visible, style_.grid_major,
style_.device(style_.grid_major_width));
style_.device(style_.grid_major_width), Vec2{});
// The minor grid's dash rides the same per-draw uniform the crosshair and a dashed trace use,
// and `off == 0` is the solid default. Each line starts its own pattern (the dash distance is
// per-segment), and every segment here is built from the range's minimum, so the dashes of the
// vertical lines line up with each other -- the grid reads as one texture rather than as a
// jumble of unrelated dashes.
const Vec2 minor_dash = style_.grid_minor_dash_off > 0.0f
? Vec2{style_.device(style_.grid_minor_dash_on),
style_.device(style_.grid_minor_dash_off)}
: Vec2{};
draw_class(false, style_.grid_minor_visible, style_.grid_minor,
style_.device(style_.grid_minor_width));
style_.device(style_.grid_minor_width), minor_dash);
}
// ------------------------------------------------------------- axis chrome
@@ -994,15 +994,26 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
const u64 n = series.size();
if (!(dx > 0.0) || n == 0) continue;
// Map the visible x range onto sample indices. One sample of slack on each
// side covers a partially visible end bucket; the range is clamped to the
// series, so a zoomed-out view never asks for more than exists.
// The visible x range onto sample indices, through the series' *own* x.
//
// Not the `(x - x0()) / dx()` grid: `dx()` is documented as a nominal spacing (the LOD
// level, a coarse snap) and for a series whose x is a streamed timestamp it is only the
// mean of the retained window. Walking that grid lands the window inside the visible range
// -- and the first and last *segments*, the ones that enter and leave the frame, are then
// not drawn at all, which is the empty margin at both edges of a zoomed-in view. A series
// with a uniform x answers the same question through the same call, so this is the one
// path. One sample of slack on each side keeps a partially visible end bucket whole, and
// the result is clamped to the series: a zoomed-out view never asks for more than exists,
// and a window that reaches past the data leaves the margin genuinely -- correctly -- empty.
const Range x_range = plot.x_range(trace.axis_x);
f64 lo = (x_range.min - series.x0()) / dx;
f64 hi = (x_range.max - series.x0()) / dx;
if (hi < lo) std::swap(lo, hi);
const u64 first = clamp_index(std::floor(lo) - 1.0, n);
const u64 last = clamp_index(std::ceil(hi) + 1.0, n);
// `dx` is still read above for the samples-per-pixel estimate -- a density, where a nominal
// spacing is exactly right -- and never for the window itself.
u64 first = 0;
u64 last = n;
u64 i0 = 0;
u64 i1 = 0;
if (series.neighbours(x_range.min, i0, i1)) first = i0 > 0 ? i0 - 1 : 0;
if (series.neighbours(x_range.max, i0, i1)) last = std::min(n, i1 + 1);
if (last <= first) continue;
// This is the whole performance contract: the window, never the series.
@@ -1017,6 +1028,29 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
if (gather_.size() < 4u) continue; // fewer than two points is no segment
stats_.visible_samples += visible;
// Markers for a window sparse enough that its samples are the shape: one dot per raw sample,
// in the trace's own colour. Level 0 only -- above it the geometry is *buckets*, which are not
// points a reader can find. Drawn after the polyline they sit on, so a dot is never half
// covered by its own line, and inside the axes only (a marker is a place, and a place outside
// the frame is not one).
if (level == 0u && style_.marker_below_samples > 0.0f && visible > 0u &&
visible <= static_cast<u64>(style_.marker_below_samples)) {
const auto& xt = plot.x_transform(trace.axis_x);
const auto& yt = plot.y_transform(trace.axis_y);
const f32 half = 0.5f * style_.device(style_.marker_px);
for (u32 k = 0; k + 1 < gather_.size(); k += 2) {
const f64 x_abs = series.x0() + static_cast<f64>(gather_[k]);
const f64 y_abs = static_cast<f64>(gather_[k + 1]);
if (!std::isfinite(x_abs) || !std::isfinite(y_abs)) continue;
const f32 sx = xt.to_screen_x(x_abs);
const f32 sy = yt.to_screen_y(y_abs);
if (!(sx >= layout.axes.x && sx <= layout.axes.right() && sy >= layout.axes.y &&
sy <= layout.axes.bottom())) {
continue;
}
fill_pixels(Rect{sx - half, sy - half, 2.0f * half, 2.0f * half}, trace.color);
}
}
if (level == 0u) {
++stats_.raw_traces;
} else {
@@ -1106,7 +1140,12 @@ void Renderer::draw_cursors(const Plot& plot, const PlotLayout& layout) {
// belongs to the plot, and `to_screen_y()` of a range maximum can undershoot `axes.y` by
// a floating-point ulp, which a bare `<` would mistake for "outside". Only the ones
// beyond that margin are skipped.
const f32 half = 0.5f * style_.device(on ? style_.handle_width_px : style_.line_width_px);
// The cursor's own width when it has one, else the style's; a hover or a grab can only make
// it thicker (the cue that the pointer has hold of something).
const f32 own = cursor.width > 0.0f ? style_.device(cursor.width)
: style_.device(style_.line_width_px);
const f32 width = on ? std::max(own, style_.device(style_.handle_width_px)) : own;
const f32 half = 0.5f * width;
if (horizontal ? (at < layout.axes.y - half || at > layout.axes.bottom() + half)
: (at < layout.axes.x - half || at > layout.axes.right() + half)) {
continue;
@@ -1130,15 +1169,13 @@ void Renderer::draw_cursors(const Plot& plot, const PlotLayout& layout) {
stage_segment(x_range.min, x_range.min, y_drawn, x_range.max, y_drawn, log_x,
plot.y_axis(cursor.axis_y).log);
flush_lines(mapping(plot, 0, cursor.axis_y, x_range.min),
on ? style_.cursor : cursor.color,
style_.device(on ? style_.handle_width_px : style_.line_width_px));
on ? style_.cursor : cursor.color, width, cursor.dash);
} else {
const f64 x_drawn = plot.x_transform(cursor.axis_x).to_data_x(static_cast<f64>(drawn));
stage_segment(x_drawn, x_drawn, y_range.min, x_drawn, y_range.max,
plot.x_axis(cursor.axis_x).log, log_y);
flush_lines(mapping(plot, cursor.axis_x, 0, x_drawn),
on ? style_.cursor : cursor.color,
style_.device(on ? style_.handle_width_px : style_.line_width_px));
on ? style_.cursor : cursor.color, width, cursor.dash);
}
draw_cursor_label(plot, layout, cursor, drawn);
}
+41
View File
@@ -1788,6 +1788,47 @@ PLOT_TEST(a_middle_drag_on_a_figures_bottom_row_zooms_the_shared_x_axis) {
// pointer) rather than on the live edge, and the window walked back off the newest sample
// by a fifth of its width. These two cases pin the fix from the two ends: the gesture keeps
// the edge, and the hint that makes it possible exists exactly while a group follows.
/// A following group's window stays **on the data**: the offset bound is `retained - span`, not
/// `retained`. With the loose bound, scrolling back put the window's *right* edge on the oldest
/// retained sample, which leaves the window's whole span to its left over data the scope never
/// had -- an empty margin as wide as the view, reached by a pan or by a zoom-in near that edge.
/// That is the "sometimes zooming in, the left or right margin becomes empty" report, and it is
/// why the bound has to be about the window and not about one edge of it.
PLOT_TEST(a_following_group_keeps_its_whole_window_on_the_data) {
plot::data::StreamSeries series(1u << 12, 0.0, 1.0 / 1000.0);
plot::f32 samples[512];
for (u32 i = 0; i < 512u; ++i) samples[i] = static_cast<plot::f32>(i % 7u);
series.push(samples, 512u);
series.acquire();
plot::Figure figure(1u);
figure.set_viewport({0.0f, 0.0f, 800.0f, 600.0f});
figure.plot(0u).add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
figure.plot(0u).add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
figure.plot(0u).add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, 0u, 0u, "s"});
const u32 group = figure.add_x_link();
PLOT_CHECK(figure.link_x(0u, group));
PLOT_CHECK(figure.set_x_link_range(group, {0.0, 0.25}));
PLOT_CHECK(figure.set_x_link_follow(group, plot::XFollow::Live));
figure.layout();
const plot::Range data = series.x_bounds();
const f64 span = figure.x_link_range(group).span();
PLOT_CHECK(span > 0.0);
// Ask to scroll far past the oldest sample, the way a drag or a zoom-in near the left edge does.
PLOT_CHECK(figure.set_x_link_range(group, {data.min - 10.0 * span, data.min - 9.0 * span}));
figure.layout();
const plot::Range after = figure.x_link_range(group);
std::printf("[follow-bound] data=[%.4f..%.4f] span=%.4f after=[%.4f..%.4f]\n", data.min,
data.max, span, after.min, after.max);
// The window is still a window, and its *left* edge is on the data: that is the assertion the
// loose bound failed, where `after.min` came out a whole span below `data.min`.
PLOT_CHECK_NEAR(after.span(), span, 1e-9 * span);
PLOT_CHECK(after.min >= data.min - 1e-9);
PLOT_CHECK(after.max <= data.max + 1e-9);
}
PLOT_TEST(a_wheel_on_a_following_figures_axis_strip_keeps_the_live_edge) {
constexpr u32 kFigureRows = 3u;
constexpr f64 kWheelDx = 1.0 / 1000.0;
+12 -3
View File
@@ -844,20 +844,29 @@ PLOT_TEST(a_following_log_group_holds_decades) {
// A member panned while the group follows: the adopted scroll state is the
// adopted range's span *in decades* -- two here (edge/1000 to edge/10 is a
// factor of 100) -- and the offset one decade behind the edge, both of which
// survive the next frames' advances.
// factor of 100) -- and the offset as far behind the edge as the retained data
// allows, both of which survive the next frames' advances.
//
// The pan *asks* for a full decade back, and the group may not take it: the offset is bounded by
// `retained - span`, so the window stops with its left edge on the oldest sample. Asserting
// `max == edge/10` here was asserting the *old* bound, which put a whole span of the window over
// data the members never held -- the empty-left-margin defect this bound exists for. The
// log-space assertions above are this test's subject and are unchanged.
const plot::f64 edge = left.x_bounds().max;
figure.plot(1u).set_x_range(0u, {edge / 1000.0, edge / 10.0});
figure.layout();
PLOT_CHECK_NEAR(decades_of(figure.plot(0u)), 2.0, 1e-9);
PLOT_CHECK_NEAR(decades_of(figure.plot(1u)), 2.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).max, edge / 10.0, 1e-8);
const plot::f64 oldest = std::min(left.x_bounds().min, right.x_bounds().min);
PLOT_CHECK(figure.plot(0u).x_range(0u).min >= oldest - 1e-6);
PLOT_CHECK(figure.plot(0u).x_range(0u).max <= edge + 1e-6);
for (int step = 0; step < 3; ++step) {
left.push(0.5f);
left.acquire();
figure.layout();
PLOT_CHECK_NEAR(decades_of(figure.plot(0u)), 2.0, 1e-9);
PLOT_CHECK_NEAR(decades_of(figure.plot(1u)), 2.0, 1e-9);
PLOT_CHECK(figure.plot(0u).x_range(0u).min >= oldest - 1e-6);
}
PLOT_CHECK(figure.x_link_follow(group) == plot::XFollow::Live);
}
+469 -7
View File
@@ -695,6 +695,125 @@ PLOT_TEST(renders_visible_trace_across_viewport) {
ctx.destroy();
}
/// A series whose `dx()` understates its spacing.
///
/// That is every ring-backed series: x is a streamed timestamp, so `dx()` is only a nominal
/// grid (an LOD level, a coarse snap) while `x_at()`/`neighbours()` answer from the real time
/// ring. The samples here sit ten apart and `dx()` claims one, with the plotted window
/// `[12, 18]` falling strictly *between* two of them -- no sample is inside the window at all,
/// so the only way to light its margins is the segment that crosses them.
class LyingGridSeries final : public plot::data::Series {
public:
LyingGridSeries() {
pyramid_.reserve(kN);
for (u64 i = 0; i < kN; ++i) pyramid_.append(kY);
}
u64 size() const override { return kN; }
f64 x0() const override { return 0.0; }
f64 dx() const override { return 1.0; } // the lie: the real spacing is x_at()'s ten
plot::Range x_bounds() const override { return {0.0, x_at(kN - 1u)}; }
plot::Range y_bounds() const override { return {kY, kY}; }
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
f64 x_at(u64 i) const override { return 10.0 * static_cast<f64>(i); }
bool neighbours(f64 x, u64& i0, u64& i1) const override {
if (x < 0.0 || x > x_at(kN - 1u)) return false;
i0 = static_cast<u64>(x / 10.0);
i1 = i0;
if (x_at(i0) != x && i0 + 1u < kN) i1 = i0 + 1u;
return true;
}
void gather(u32, u64 begin, u64 end, std::vector<f32>& out,
plot::data::GatherMode) const override {
++gather_calls_;
for (u64 i = begin; i < end; ++i) {
out.push_back(static_cast<f32>(x_at(i) - x0()));
out.push_back(kY);
}
}
u64 gather_calls() const noexcept { return gather_calls_; }
protected:
void copy_raw(u64 begin, u64 end, f32* out) const override {
for (u64 i = begin; i < end; ++i) out[i - begin] = kY;
}
private:
static constexpr u64 kN = 5u;
static constexpr f32 kY = 1.0f;
plot::data::MinMaxPyramid pyramid_;
mutable u64 gather_calls_ = 0;
};
/// The window falls between two samples: the trace must still be drawn to both margins.
///
/// Found by zooming in: the renderer mapped the visible x range onto sample indices with
/// `(x - x0()) / dx()`, and on a series whose `dx()` is nominal that lands *inside* the visible
/// range -- here it lands past the end of a five-sample series entirely, so `last <= first` and
/// the frame held no trace at all. The segment entering the window from the sample before it and
/// leaving to the one after are what draw the margins, and a series answers where they are
/// through `neighbours()`, over its own x.
PLOT_TEST(a_sparse_window_between_samples_still_inks_both_margins) {
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));
LyingGridSeries s;
plot::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
// `auto_scale` off on both: the window has to be the one the test names, or the axis takes
// the series' own bounds and the window falls outside the frame -- a test of nothing.
const plot::u32 ax = p.add_x_axis({"t", {12.0, 18.0}, false, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 3.0}, false, plot::AxisPlace::Left, false});
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(p);
r.end_frame();
dump_if_requested(r, "margins.ppm");
const plot::Rect a = p.layout().axes;
// The window's own evidence: the samples that bracket it are sampled even though neither is
// inside it, and the reverted grid walk staged *none* (`(12 - 0) / 1` is past the end of a
// five-sample series, so `last <= first` and the frame held no trace at all).
const plot::Range drawn_x = p.x_range(ax);
const plot::u32 covered = covered_columns(ctx, a, 24);
std::printf("[margins] window=[%.3f..%.3f] axes=[%.1f %.1f %.1f %.1f] visible=%llu "
"raw=%llu lod=%llu segments=%llu gathers=%llu trace_visible=%d covered=%u/24\n",
drawn_x.min, drawn_x.max, a.x, a.y, a.w, a.h,
static_cast<unsigned long long>(r.stats().visible_samples),
static_cast<unsigned long long>(r.stats().raw_traces),
static_cast<unsigned long long>(r.stats().lod_traces),
static_cast<unsigned long long>(r.stats().staged_segments),
static_cast<unsigned long long>(s.gather_calls()), int(p.trace(0).visible()),
covered);
PLOT_CHECK(r.stats().visible_samples >= 2u);
PLOT_CHECK(covered == 24u);
PLOT_CHECK(covered_columns(ctx, a, 24) == 24); // every column, margins included
// The margins by name, on the line's own row: the reported symptom was these two being blank
// while the middle drew. `y = 1` in the window `[-1, 3]` is the middle row of the band.
const plot::u32 row = static_cast<plot::u32>(a.y + a.h / 2.0f);
for (const plot::u32 x : {static_cast<plot::u32>(a.x) + 1u,
static_cast<plot::u32>(a.x + a.w) - 2u}) {
bool inked = false;
for (plot::u32 dy = 0; dy < 4u && !inked; ++dy) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - (row + dy), px));
inked = trace_pixel(px);
}
PLOT_CHECK(inked);
}
PLOT_CHECK(!r.last_frame_had_gl_error());
ctx.destroy();
}
PLOT_TEST(dense_and_sparse_paths_agree_on_extent) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
@@ -1031,6 +1150,326 @@ PLOT_TEST(draws_cursors_measure_band_and_annotations) {
// No trace and no minor grid, so the only thing drawn inside the rect is the major
// grid and the one colour it is drawn in; the rect is inset so the axis lines on
// its own edges, which are a different colour, stay out of the scan.
/// The minor grid is a *subdivision*, and the style says so three ways: thinner, dashed and
/// blended into the background. Found by reading a frame whose minor grid read as a second set
/// of ticks -- at full opacity and the same width as the labelled grid there was no hierarchy
/// to see.
///
/// The frame holds no trace, so inside `axes` the only elements are the two grid classes and
/// the background. A row off the horizontal lines then classifies every column by inspection:
/// background, an opaque major line (`pixel_is`, the same exactness `grid_hairlines_land_on_whole_pixels`
/// leans on), or the minor's blend -- which at these defaults is the only non-background pixel
/// in the frame that is neither. Width is checked as *coverage* rather than as a pixel count,
/// because at 1x a sub-pixel logical width is exactly a dimmer stroke: the ink is one column
/// either way, so `grid_minor_width` can only show up in how brightly that column is lit. On a
/// HiDPI display the same width is literally narrower.
PLOT_TEST(the_minor_grid_is_thinner_dashed_and_dimmer_than_the_major) {
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_CHECK(r.style().grid_minor_visible);
PLOT_CHECK(r.style().grid_minor_dash_off > 0.0f); // the default is a dash, not a solid line
plot::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
p.add_x_axis({"seconds", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"volts", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
const plot::Rect& a = p.layout().axes;
const plot::Color bg = r.style().background;
const plot::Color major = r.style().grid_major;
// Distance from the background, per channel, so "dimmer" means "closer to the background"
// whatever the two colours are.
const auto bg_distance = [&](const plot::u8 px[4]) {
const auto d = [](plot::u8 v, plot::f32 want) {
return std::abs(static_cast<int>(v) - static_cast<int>(want * 255.0f + 0.5f));
};
return d(px[0], bg.r) + d(px[1], bg.g) + d(px[2], bg.b);
};
// The row: the first one that holds all three kinds of pixel, so it is neither a horizontal
// grid line (everything major ink) nor a band the layout reserved for something else.
const plot::u32 first_row = static_cast<plot::u32>(a.y) + 3u;
const plot::u32 last_row = static_cast<plot::u32>(a.y + a.h) - 4u;
std::vector<plot::u32> major_cols;
std::vector<plot::u32> minor_cols;
plot::u32 scan_row = 0;
for (plot::u32 row = first_row; row < last_row; ++row) {
major_cols.clear();
minor_cols.clear();
plot::u32 background = 0;
for (plot::u32 cx = static_cast<plot::u32>(a.x) + 3u;
cx + 3u < static_cast<plot::u32>(a.x + a.w); ++cx) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(cx, ctx.height - 1u - row, px));
if (pixel_is(px, bg)) {
++background;
} else if (pixel_is(px, major)) {
major_cols.push_back(cx);
} else {
minor_cols.push_back(cx);
}
}
if (background > 0u && !major_cols.empty() && !minor_cols.empty()) {
scan_row = row;
break;
}
}
PLOT_CHECK(scan_row != 0u); // a row carrying both grid classes and the background
PLOT_CHECK(major_cols.size() >= 2u);
PLOT_CHECK(minor_cols.size() >= 2u);
// Solid against dashed, down the two columns: a major line is ink at every row, a minor one
// alternates -- which is the whole of "dashed" a pixel test can see. The duty cycle is
// checked as a range rather than as the exact 3/3 the style asks for, because the dash is a
// fragment discard and its ends land where they land.
const auto column_ink = [&](plot::u32 cx, plot::u32& ink, plot::u32& gaps) {
ink = 0;
gaps = 0;
for (plot::u32 row = first_row; row < last_row; ++row) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(cx, ctx.height - 1u - row, px));
if (pixel_is(px, bg)) {
++gaps;
} else {
++ink;
}
}
};
plot::u32 ink = 0;
plot::u32 gaps = 0;
column_ink(major_cols.front(), ink, gaps);
PLOT_CHECK(gaps == 0u); // the labelled grid is continuous
const plot::u32 major_ink = ink;
column_ink(minor_cols.front(), ink, gaps);
PLOT_CHECK(ink > 0u && gaps > 0u); // dashed, not solid
PLOT_CHECK(ink * 5u > major_ink && gaps * 5u > major_ink); // roughly half on, half off
// Dimmer: the minor's ink sits closer to the background than the major's. This is also the
// "thinner" half of the contract at 1x -- see the note above the test.
const auto ink_distance = [&](plot::u32 cx) {
int best = 0;
for (plot::u32 row = first_row; row < last_row; ++row) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(cx, ctx.height - 1u - row, px));
if (pixel_is(px, bg)) continue;
best = std::max(best, bg_distance(px));
}
return best;
};
const int major_bright = ink_distance(major_cols.front());
const int minor_bright = ink_distance(minor_cols.front());
PLOT_CHECK(minor_bright > 0);
PLOT_CHECK(minor_bright < major_bright);
ctx.destroy();
}
/// A cursor's stroke is its own: a host that places *readout marks* on the data -- a measured
/// level, a peak, a pulse's edges -- wants them lighter and broken, so they read as annotations
/// over the data rather than as more chrome around it. Two cursors in one frame, one default and
/// one given a thin dashed stroke, so the comparison is in the same pixels: the dashed column
/// alternates ink and gap down its length while the solid one does not, and its ink sits closer
/// to the background, which at 1x is what a sub-pixel logical width *is*.
PLOT_TEST(a_cursor_stroke_can_be_thinner_and_dashed) {
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::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
p.add_x_axis({"t", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
// Green: no other element of this frame is green-dominant (the grid is grey-dark and the axis
// line is chalk), so the predicate below can only find these two cursors.
const plot::Color ink = plot::Color::hex(0x00FF00);
p.add_cursor(3.0, ink);
const plot::u32 thin = p.add_cursor(7.0, ink);
p.set_cursor_snap(thin, plot::SnapMode::Free);
p.set_cursor_stroke(thin, 0.75f, plot::Vec2{3.0f, 3.0f});
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
const plot::Rect& a = p.layout().axes;
const plot::Color bg = r.style().background;
const auto px_at = [&](plot::u32 cx, plot::u32 cy, plot::u8 px[4]) {
return ctx.read_pixel(cx, ctx.height - 1u - cy, px);
};
const auto bg_distance = [&](const plot::u8 px[4]) {
const auto d = [](plot::u8 v, plot::f32 want) {
return std::abs(static_cast<int>(v) - static_cast<int>(want * 255.0f + 0.5f));
};
return d(px[0], bg.r) + d(px[1], bg.g) + d(px[2], bg.b);
};
// The two cursor columns, as the *centre of each run* of green columns: a stroke with an
// anti-aliased edge covers two or three columns, so taking the first two hits found one cursor
// twice (238 and 239) and never looked at the other -- which is how this test first reported
// "neither is dashed" with both columns' ink identical.
plot::u32 cols[2] = {0u, 0u};
plot::u32 run_from[2] = {0u, 0u};
plot::u32 run_to[2] = {0u, 0u};
int found = 0;
bool in_run = false;
plot::u32 run_start = 0;
const auto close_run = [&](plot::u32 end_column) {
if (found >= 2) return;
cols[found] = (run_start + end_column) / 2u;
run_from[found] = run_start;
run_to[found] = end_column;
++found;
};
for (plot::u32 cx = static_cast<plot::u32>(a.x); cx < static_cast<plot::u32>(a.x + a.w); ++cx) {
bool green = false;
for (plot::u32 row = static_cast<plot::u32>(a.y) + 4u;
row < static_cast<plot::u32>(a.y + a.h) - 4u && !green; ++row) {
plot::u8 px[4] = {};
PLOT_CHECK(px_at(cx, row, px));
green = green_pixel(px);
}
if (green && !in_run) {
in_run = true;
run_start = cx;
} else if (!green && in_run) {
in_run = false;
close_run(cx - 1u);
}
}
if (in_run) close_run(static_cast<plot::u32>(a.x + a.w) - 1u);
PLOT_CHECK(found == 2);
plot::u32 ink_rows[2] = {0u, 0u};
plot::u32 gap_rows[2] = {0u, 0u};
// The ink each stroke lays down, summed over its whole run of columns: a cursor is a *data*
// position and so is deliberately not snapped, so which columns (and how brightly) a stroke
// covers depends on where its coordinate falls between pixels. Comparing one column's peak made
// the *solid* stroke look lighter than the thin one, because its ink happened to split across
// two columns. What is asked here is the thing the style states: less ink per unit length.
long long total_ink[2] = {0, 0};
for (int c = 0; c < 2; ++c) {
for (plot::u32 cx = run_from[c]; cx <= run_to[c]; ++cx) {
for (plot::u32 row = static_cast<plot::u32>(a.y) + 4u;
row < static_cast<plot::u32>(a.y + a.h) - 4u; ++row) {
plot::u8 px[4] = {};
PLOT_CHECK(px_at(cx, row, px));
if (green_pixel(px)) {
if (cx == cols[c]) ++ink_rows[c];
total_ink[c] += bg_distance(px);
} else if (pixel_is(px, bg) && cx == cols[c]) {
++gap_rows[c];
}
}
}
}
std::printf("[cursor-stroke] solid x=%u [%u..%u] ink=%u gaps=%u total=%lld; "
"thin x=%u [%u..%u] ink=%u gaps=%u total=%lld\n",
cols[0], run_from[0], run_to[0], ink_rows[0], gap_rows[0], total_ink[0], cols[1],
run_from[1], run_to[1], ink_rows[1], gap_rows[1], total_ink[1]);
PLOT_CHECK(gap_rows[0] == 0u); // the default stroke is continuous
PLOT_CHECK(ink_rows[1] > 0u && gap_rows[1] > 0u); // the custom one is dashed
PLOT_CHECK(total_ink[1] < total_ink[0]); // and lays down less ink
ctx.destroy();
}
/// A window sparse enough that its samples *are* the shape gets a marker on each one. Ten samples
/// across a panel are places a reader counts, and the polyline between them is not the shape any
/// more; a hundred are a curve and the markers would be noise, which is what the threshold is for.
/// The test reads the vertical run of trace-coloured pixels per column: a marker is `marker_px`
/// across, so at a sample's own column the run is taller than the line, and at a midpoint it is
/// the line alone. The dense case proves the second half -- same series shape, no column taller
/// than the stroke.
PLOT_TEST(a_sparse_window_marks_its_samples) {
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_CHECK(r.style().marker_below_samples >= 50.0f); // the default is the spec's 50
const auto run_at = [&](plot::Plot& p, const plot::Rect& a) {
const plot::u32 first_row = static_cast<plot::u32>(a.y);
const plot::u32 rows = static_cast<plot::u32>(a.h);
std::vector<plot::u32> tall;
for (plot::u32 cx = static_cast<plot::u32>(a.x);
cx < static_cast<plot::u32>(a.x + a.w); ++cx) {
plot::u32 best = 0;
plot::u32 run = 0;
for (plot::u32 yy = 0; yy < rows; ++yy) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(cx, ctx.height - 1u - (first_row + yy), px));
if (green_pixel(px)) {
++run;
best = std::max(best, run);
} else {
run = 0;
}
}
if (best >= 3u) tall.push_back(cx);
}
(void)p;
return tall;
};
// The dots *group*: a marker is `marker_px` wide, so it spans several adjacent columns. What
// identifies one is a run of tall columns, exactly as a cursor's stroke spans two -- the first
// version of this test counted columns and read three markers per sample.
const auto groups = [](const std::vector<plot::u32>& cols) {
std::vector<plot::u32> centres;
for (std::size_t i = 0; i < cols.size();) {
std::size_t j = i;
while (j + 1 < cols.size() && cols[j + 1] == cols[j] + 1u) ++j;
centres.push_back((cols[i] + cols[j]) / 2u);
i = j + 1;
}
return centres;
};
// Ten samples, a flat line: every sample is a place, and none of them is a bend.
const auto sparse = [&](plot::u32 samples, const char* what) {
std::vector<plot::f32> y(samples, 0.25f);
const plot::data::ArraySeries series(y, 0.0, 10.0 / static_cast<plot::f64>(samples));
plot::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
const plot::u32 ax = p.add_x_axis({"t", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
p.add_trace({&series, plot::Color::hex(0x00FF00), 1.5f, ax, ay});
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
const std::vector<plot::u32> tall = run_at(p, p.layout().axes);
const std::vector<plot::u32> centres = groups(tall);
std::printf("[markers] %s: %zu columns, %zu groups, visible=%llu\n", what, tall.size(),
centres.size(), static_cast<unsigned long long>(r.stats().visible_samples));
return centres;
};
const std::vector<plot::u32> ten = sparse(10u, "10 samples");
PLOT_CHECK(ten.size() == 10u); // one marker per sample
// ... and at the samples: ten of them across the panel, evenly spaced. This is the assertion
// that says the dots are *the samples* and not something the style happened to paint.
const double step = static_cast<double>(ten.back() - ten.front()) / 9.0;
for (std::size_t i = 1; i < ten.size(); ++i) {
PLOT_CHECK_NEAR(static_cast<double>(ten[i] - ten[i - 1]), step, 2.0);
}
// A hundred samples: the same shape, past the threshold, and no group is a dot.
const std::vector<plot::u32> hundred = sparse(100u, "100 samples");
PLOT_CHECK(hundred.size() <= 5u);
ctx.destroy();
}
PLOT_TEST(grid_hairlines_land_on_whole_pixels) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
@@ -3177,6 +3616,20 @@ plot::f32 window_ink(const GridFrame& f, const plot::Rect& window, plot::Color b
return ink;
}
/// The ink one minor line is expected to leave in a `kWindowRows`-tall window, **from the
/// style**: its width in device pixels, its colour's alpha (the line shader multiplies it in)
/// and the dash's duty cycle. Written out rather than as a literal, because the minor class'
/// defaults are deliberately a fraction of a solid opaque line -- a threshold saying "most of
/// eight rows" stopped describing the class the moment it gained a dash, while it is the style
/// that says how much ink a class is *meant* to leave.
plot::f32 minor_line_ink(const plot::render::Style& style) {
const plot::f32 duty =
style.grid_minor_dash_off > 0.0f
? style.grid_minor_dash_on / (style.grid_minor_dash_on + style.grid_minor_dash_off)
: 1.0f;
return kWindowRows * style.device(style.grid_minor_width) * style.grid_minor.a * duty;
}
} // namespace
/// Spec §11's per-class switch, on the minor class: a class the style switches off is
@@ -3206,10 +3659,12 @@ PLOT_TEST(disabling_the_minor_grid_removes_its_colour) {
const GridWindows win = grid_windows(both);
const plot::f32 major_before = window_ink(both, win.major, bg, major_ink);
const plot::f32 minor_before = window_ink(both, win.minor, bg, minor_ink);
// A line, not one stray fringe pixel: an 8-row window over a 1 px stroke holds about
// 8 px of ink per class, and this asks for most of it.
// A line, not one stray fringe pixel: an 8-row window over a stroke holds most of the ink
// that stroke is meant to leave. For the major class that is the window's own height; for the
// minor class it is what its width, alpha and dash ask for, so the threshold follows the
// style instead of a literal the class is no longer drawn to.
PLOT_CHECK(major_before > 0.75f * kWindowRows);
PLOT_CHECK(minor_before > 0.75f * kWindowRows);
PLOT_CHECK(minor_before > 0.75f * minor_line_ink(r.style()));
r.style().grid_minor_visible = false;
const GridFrame without = draw_grid_scene(ctx, r, 1.0f);
@@ -3256,7 +3711,7 @@ PLOT_TEST(disabling_the_major_grid_leaves_the_minor_grid) {
const plot::f32 major_before = window_ink(both, win.major, bg, r.style().grid_major);
const plot::f32 minor_before = window_ink(both, win.minor, bg, r.style().grid_minor);
PLOT_CHECK(major_before > 0.75f * kWindowRows);
PLOT_CHECK(minor_before > 0.75f * kWindowRows);
PLOT_CHECK(minor_before > 0.75f * minor_line_ink(r.style()));
r.style().grid_major_visible = false;
const GridFrame without = draw_grid_scene(ctx, r, 1.0f);
@@ -3345,6 +3800,12 @@ PLOT_TEST(grid_widths_are_per_class_and_scale_with_dpi) {
r.style().grid_major_width = 2.0f;
r.style().grid_minor_width = 1.0f;
// Widths are this test's subject, so the minor class' dash and alpha are stated away first:
// they would multiply every count and the ratio below, and their own defaults are the subject
// of `the_minor_grid_is_thinner_dashed_and_dimmer_than_the_major`.
r.style().grid_minor_dash_off = 0.0f;
r.style().grid_minor = plot::Color::hex(0x1C2126);
const GridFrame one = draw_grid_scene(ctx, r, 1.0f);
const GridWindows win = grid_windows(one);
const plot::f32 major_1x = window_ink(one, win.major, bg, r.style().grid_major);
@@ -5217,9 +5678,10 @@ plot::i32 densest_column(const std::vector<plot::u8>& frame, plot::u32 fb_w, plo
return best;
}
/// The grid *majors* (0x2A3038 over the 0x101418 background), which no other element of these
/// frames reaches: the minors are 0x1C2126 (too dark for the bound), the crosshair is near-white,
/// a trace is saturated and the cursor is the orange accent.
/// The grid *majors* (0x39414C over the 0x101418 background), which no other element of these
/// frames reaches: the minors are that same colour at 50% alpha over a 0.75-covered dashed
/// stroke, so their brightest pixel is about 15 levels off the background and stays under this
/// bound; the crosshair is near-white, a trace is saturated and the cursor is the orange accent.
bool grid_pixel(const plot::u8 px[4]) {
const plot::u8 lo = std::min(px[0], std::min(px[1], px[2]));
const plot::u8 hi = std::max(px[0], std::max(px[1], px[2]));
+82
View File
@@ -2,6 +2,7 @@
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "harness.hpp"
#include <plot/plot.hpp>
#include <plot/plot/ticks.hpp>
#include <cmath>
#include <cstdlib>
@@ -235,6 +236,11 @@ PLOT_TEST(si_labels_are_utf8_and_never_overrun) {
PLOT_TEST(format_time_shapes) {
char b[32];
plot::format_time(0.5, b, sizeof b); PLOT_CHECK(std::string(b) == "500ms");
plot::format_time(0.005, b, sizeof b); PLOT_CHECK(std::string(b) == "5ms");
plot::format_time(0.0005, b, sizeof b); PLOT_CHECK(std::string(b) == "500\u00b5s");
plot::format_time(5.0e-6, b, sizeof b); PLOT_CHECK(std::string(b) == "5\u00b5s");
plot::format_time(5.0e-9, b, sizeof b); PLOT_CHECK(std::string(b) == "5ns");
plot::format_time(5.0e-12, b, sizeof b); PLOT_CHECK(std::string(b) == "5ps");
plot::format_time(1.5, b, sizeof b); PLOT_CHECK(std::string(b) == "1.5s");
plot::format_time(90.0, b, sizeof b); PLOT_CHECK(std::string(b) == "1:30");
plot::format_time(3661.0, b, sizeof b); PLOT_CHECK(std::string(b) == "1:01:01");
@@ -268,3 +274,79 @@ PLOT_TEST(format_time_never_lets_a_field_reach_its_radix) {
check_no_field_reaches_its_radix(label);
}
}
// An axis origin shifts what the labels *say* and which grid the ticks sit on: a window
// zoomed into 12.345 s must read `0, 0.5ms, 1ms` against its own left edge, because three
// significant digits cannot tell 12.345671 s from 12.345672 s. A host prints the origin once
// beside the axis and the user adds.
PLOT_TEST(ticks_are_written_relative_to_an_axis_origin) {
// The same window twice: absolute, and relative to its own start.
const plot::Range window{12.345, 12.346};
std::vector<plot::Tick> absolute;
plot::generate_ticks(window, 400.0f, 80.0f, absolute, /*include_minors=*/false, 0.0);
PLOT_CHECK(!absolute.empty());
PLOT_CHECK(absolute.front().value >= window.min);
std::vector<plot::Tick> relative;
plot::generate_ticks(window, 400.0f, 80.0f, relative, /*include_minors=*/false, window.min);
PLOT_CHECK(!relative.empty());
// The walk starts on the origin's grid: the first major is the origin itself, and every
// major is an exact multiple of the step away from it.
PLOT_CHECK_NEAR(relative.front().value, window.min, 1e-12);
PLOT_CHECK_NEAR(relative[1].value - relative[0].value,
(absolute[1].value - absolute[0].value), 1e-12);
for (const plot::Tick& t : relative) {
PLOT_CHECK(t.value >= window.min - 1e-12);
PLOT_CHECK(t.value <= window.max + 1e-12);
}
// And through an axis: the *labels* are the relative values, the positions the absolute
// ones. That is the whole contract -- `format_value()` is where the origin is applied.
plot::Plot p;
p.set_viewport({0, 0, 800, 400});
const u32 ax = p.add_x_axis({"time", window, false, plot::AxisPlace::Bottom, false,
plot::TickFormat::Time});
p.x_axis(ax);
PLOT_CHECK_NEAR(p.x_axis_origin(ax), 0.0, 1e-12);
p.set_x_axis_origin(ax, window.min);
PLOT_CHECK_NEAR(p.x_axis_origin(ax), window.min, 1e-12);
char buf[64];
p.x_axis(ax).format_value(window.min, buf, sizeof buf);
PLOT_CHECK(std::string(buf) == "0"); // the window's start reads zero
p.x_axis(ax).format_value(window.min + 0.0005, buf, sizeof buf);
PLOT_CHECK(std::string(buf) == "500\u00b5s"); // half a millisecond reads in microseconds
// An id that names no axis is ignored; the axis that exists is untouched by it.
p.set_x_axis_origin(7u, 1234.0);
PLOT_CHECK_NEAR(p.x_axis_origin(ax), window.min, 1e-12);
}
// The moving-window case, which is why the mode exists: the origin is re-taken from the range
// at refresh, so the tick on the left edge reads exactly zero however far the window has
// scrolled since the host last looked. A host cannot predict the edge (the follow places it
// from the newest sample), and a value it wrote a frame earlier is a frame of scroll stale --
// at 1 MS/s that is more than a millisecond-scale window shows.
PLOT_TEST(a_range_origin_puts_the_zero_tick_on_the_left_edge) {
plot::Plot p;
p.set_viewport({0, 0, 800, 400});
const plot::Range window{12.345, 12.347};
const u32 ax = p.add_x_axis({"time", window, false, plot::AxisPlace::Bottom, false,
plot::TickFormat::Time});
p.set_x_axis_origin_from_range(ax, true);
// `layout()` is the library's own path to the ticks (it ends in `refresh_ticks()`), so this
// is the order a frame really runs in.
p.layout();
PLOT_CHECK(!p.x_axis(ax).ticks.empty());
const plot::Tick& first = p.x_axis(ax).ticks.front();
PLOT_CHECK(first.major);
PLOT_CHECK_NEAR(f64(first.value), window.min, 1e-12); // exactly the left edge ...
PLOT_CHECK(first.label == "0"); // ... and it reads zero
// The window slides: the origin follows it, so the zero tick is on the *new* edge.
const plot::Range moved{12.6, 12.602};
p.set_x_range(ax, moved);
p.layout();
PLOT_CHECK_NEAR(f64(p.x_axis(ax).ticks.front().value), moved.min, 1e-12);
PLOT_CHECK(p.x_axis(ax).ticks.front().label == "0");
PLOT_CHECK_NEAR(p.x_axis_origin(ax), moved.min, 1e-12); // and the host reads it back
}