Compare commits

..
11 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
Martino Ferrari 211c5b97bc feat(input): a cursor can be unselectable, for a host's readout marks
A host that draws the result of a computation as a cursor -- a ruler at a level
it measured, a marker at a time it found -- must not let the user drag it: the
value belongs to the measurement, not to the pointer. Parking it outside the
axis and re-placing it every frame does not achieve that, because a drag is held
for the gesture's life and the marker follows the pointer between frames, so the
readout *looks* draggable however often it is put back.

`Cursor::selectable` defaults true; the controller's hit test skips an
unselectable cursor, so it is neither hovered nor grabbed and its hit radius
goes to whatever is behind it. Set through `Plot::set_cursor_selectable()`.

Tested by `an_unselectable_cursor_is_neither_hovered_nor_grabbed` in
tests/test_controller.cpp.
2026-10-03 10:29:13 +02:00
Martino Ferrari 125cc1f64a feat(plot): set_cursor_color(), and a figure test for per-row rulers
A *readout* cursor -- a marker a host keeps for something it computed, rather
than an instrument the user placed -- has to be told apart from its neighbours,
and the cursor API could set everything about one except its colour. The setter
keeps the id and ignores a stale one, like the setters beside it.

Tested by `a_cursor_can_be_recoloured_by_id`, and the suite is 356 cases, 0
failed.
2026-10-03 00:11:37 +02:00
Martino Ferrari 523446d08f test(render): a figure draws each row's own horizontal rulers
The figure-mode cursor test covered the *vertical* marker, which is one time
shared by every row. A horizontal ruler is the other half: its y is on that
row's own axis, so two rows' rulers are two different values, and every row's
has to be drawn in its own band. A host that puts two rulers on every plot
(`udpscope`'s cursors pane) needs exactly this, and had no test for it.

It asserts the strokes only -- the ruler's value label goes through the text
path, which this case is not about, so it uses the harness's no-atlas backend.
2026-10-02 23:59:53 +02:00
Martino Ferrari 133cd680ce feat(plot): set_annotation(), so a readout annotation can be moved
A host whose annotation is a readout -- the point where a cursor crosses a
trace -- has to move it every frame, and `add_annotation()` only appends:
the id would change every frame and the annotation count grow without bound.
The setter keeps the id, ignores a stale one exactly as the cursor setters
do, and pairs with parking (`visible = false`) since there is no
`remove_annotation`.

Tested by `an_annotation_can_be_moved_by_id` in tests/test_plot.cpp.
2026-10-02 15:00:50 +02:00
18 changed files with 1100 additions and 61 deletions

No files matched your search

+14 -1
View File
@@ -14,6 +14,11 @@ built on top of it.
| Qt6 | `plot::qt6` | `adapters/qt6/` | | Qt6 | `plot::qt6` | `adapters/qt6/` |
| GTK4 | `plot::gtk4` | `adapters/gtk4/` | | GTK4 | `plot::gtk4` | `adapters/gtk4/` |
The 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), 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 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 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 ## 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 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; - x and y axes, with a vertical (bottom-to-top) y-axis title;
- cursors in both orientations — vertical markers and horizontal rulers; - cursors in both orientations — vertical markers and horizontal rulers;
- draggable regions and measurement bands with exact interval statistics; - 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 | | field | default | meaning |
|---|---|---| |---|---|---|
| `background` | `0x101418` | frame clear colour and legend/readout panel base | | `background` | `0x101418` | frame clear colour and legend/readout panel base |
| `grid_major` | `0x2A3038` | major (labelled-tick) grid lines | | `grid_major` | `0x39414C` | major (labelled-tick) grid lines |
| `grid_minor` | `0x1C2126` | minor (subdivision) grid lines | | `grid_minor` | `0x1C2126` at 50% alpha | minor (subdivision) grid lines |
| `axis_line` | `0x8A94A6` | axis lines | | `axis_line` | `0x8A94A6` | axis lines |
| `axis_text` | `0xC8D0DC` | tick labels and axis titles | | `axis_text` | `0xC8D0DC` | tick labels and axis titles |
| `cursor` | `0xFFB020` | cursor markers; also the hovered/grab highlight | | `cursor` | `0xFFB020` | cursor markers; also the hovered/grab highlight |
@@ -56,8 +56,12 @@ Logical metrics (scaled by `device()`):
|---|---|---| |---|---|---|
| `axis_font_px` | `12.0` | logical em the axis text is drawn at | | `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 | | `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_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_major_visible` | `true` | stage the major grid at all |
| `grid_minor_visible` | `true` | stage the minor 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) | | `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 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. `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 ## The crosshair's dash
`crosshair_dash_on`/`crosshair_dash_off` are the fragment shader's discard pattern, so no `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; AxisPlace place = AxisPlace::Bottom;
bool log = false; bool log = false;
TickFormat format = TickFormat::Si; 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 -- /// 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 /// a y axis has no live edge -- and `Off` on both, so nothing changes for a
/// caller who never asks for it. See XFollow. /// caller who never asks for it. See XFollow.
+20
View File
@@ -6,6 +6,7 @@
#include <plot/core/color.hpp> #include <plot/core/color.hpp>
#include <plot/core/types.hpp> #include <plot/core/types.hpp>
#include <plot/math/vec2.hpp>
namespace plot { namespace plot {
@@ -86,6 +87,25 @@ struct Cursor {
u32 axis_x = 0; ///< the x axis `x` is in. Dead for `Horizontal`. u32 axis_x = 0; ///< the x axis `x` is in. Dead for `Horizontal`.
u32 axis_y = 0; ///< the y axis `y` is in. Dead for `Vertical`. u32 axis_y = 0; ///< the y axis `y` is in. Dead for `Vertical`.
bool visible = true; bool visible = true;
/// Whether the pointer may hit this cursor at all: `false` for a *readout* -- a marker a
/// host keeps to show something it computed, which the user must not be able to drag. The
/// controller's `cursor_at()` skips it, so it is neither hovered nor grabbed, and the
/// 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 /// 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 /// place the orientation becomes a branch, so every consumer takes the same decision and
+35
View File
@@ -229,6 +229,13 @@ class Plot {
/// Measures the interval between two cursors over the series of `trace`. /// Measures the interval between two cursors over the series of `trace`.
u32 add_measure(u32 cursor_a, u32 cursor_b, u32 trace = 0); u32 add_measure(u32 cursor_a, u32 cursor_b, u32 trace = 0);
u32 add_annotation(const Annotation& annotation); u32 add_annotation(const Annotation& annotation);
/// Moves an annotation that is already in the plot, keeping its id -- what a host whose
/// annotation is a *readout* needs (the point where a cursor crosses a trace moves with
/// the cursor). `Plot` has no `remove_annotation`, so the same parking discipline as the
/// cursors applies: set `visible = false` rather than dropping it, and re-place where it
/// belongs. An id that names no annotation is ignored, not an error, exactly as every
/// other setter here treats a stale id.
void set_annotation(u32 id, const Annotation& annotation);
/// Adds a region. Its bounds are ordered and, when the region asks to snap, pulled /// Adds a region. Its bounds are ordered and, when the region asks to snap, pulled
/// onto the sample grid as it is added -- exactly as `add_cursor()` places a /// onto the sample grid as it is added -- exactly as `add_cursor()` places a
/// snapping cursor -- so a region handed to a plot that already holds samples is on /// snapping cursor -- so a region handed to a plot that already holds samples is on
@@ -472,6 +479,34 @@ class Plot {
/// ///
/// Ignored for a vertical cursor, whose snap is `SnapMode` -- see `set_cursor_snap()`. /// Ignored for a vertical cursor, whose snap is `SnapMode` -- see `set_cursor_snap()`.
void set_cursor_h_snap(u32 id, HRulerSnap mode); void set_cursor_h_snap(u32 id, HRulerSnap mode);
/// Recolours a cursor that already exists. A *readout* cursor -- one a host keeps as a
/// marker for something it computed, rather than an instrument the user placed -- has to
/// 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 /// The measurement for `id`. Re-evaluated only when a cursor moved, when the
/// measure was added, or when the series underneath it changed: a different /// measure was added, or when the series underneath it changed: a different
+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: /// 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`. /// 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 /// 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 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 /// 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 /// single-sample series), a non-positive `px` or `min_px`, and a span whose step
/// overflows to infinity. /// overflows to infinity.
void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out, 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 } // namespace plot
+35 -3
View File
@@ -38,8 +38,19 @@ struct Style {
// --- colours: neither logical nor device, so nothing to scale ------------ // --- colours: neither logical nor device, so nothing to scale ------------
Color background = Color::hex(0x101418); Color background = Color::hex(0x101418);
Color grid_major = Color::hex(0x2A3038); /// The labelled grid. Brighter than the subdivisions and still well under `axis_line`
Color grid_minor = Color::hex(0x1C2126); /// (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_line = Color::hex(0x8A94A6);
Color axis_text = Color::hex(0xC8D0DC); Color axis_text = Color::hex(0xC8D0DC);
Color cursor = Color::hex(0xFFB020); 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 /// 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 /// costs nothing rather than being drawn transparent (spec §11). The colours
/// above are already per class. /// 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_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_major_visible = true; ///< stage the major grid at all.
bool grid_minor_visible = true; ///< stage the minor 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 f32 line_width_px = 1.5f; ///< logical width of the strokes framing the data
///< (axis lines, cursor markers). ///< (axis lines, cursor markers).
/// Logical half-width of the anti-aliased fringe the line shader blends at /// Logical half-width of the anti-aliased fringe the line shader blends at
+3 -1
View File
@@ -925,7 +925,9 @@ u64 Controller::cursor_at(f32 px, f32 py) const {
f32 best_d = 0.0f; f32 best_d = 0.0f;
for (u32 i = 0; i < plot_->cursor_count(); ++i) { for (u32 i = 0; i < plot_->cursor_count(); ++i) {
const Cursor& cursor = plot_->cursor(i); const Cursor& cursor = plot_->cursor(i);
if (!cursor.visible) continue; // A readout is drawn but not offered to the pointer: skipping it here is what makes it
// undraggable, rather than fighting the drag a frame at a time.
if (!cursor.visible || !cursor.selectable) continue;
// One hit test for both orientations, because a cursor *is* one line across the axes: // One hit test for both orientations, because a cursor *is* one line across the axes:
// what the pointer has to be near is that line's own coordinate -- the column of a // what the pointer has to be near is that line's own coordinate -- the column of a
// vertical marker, the row of a horizontal ruler -- measured with the same radius // vertical marker, the row of a horizontal ruler -- measured with the same radius
+17 -3
View File
@@ -3,18 +3,32 @@
#include <plot/plot/axis.hpp> #include <plot/plot/axis.hpp>
#include <cmath>
namespace plot { namespace plot {
void Axis::format_value(f64 v, char* buf, usize n) const { 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) { if (format == TickFormat::Time) {
format_time(v, buf, n); format_time(shown, buf, n);
return; return;
} }
format_si(v, buf, n); format_si(shown, buf, n);
} }
void Axis::refresh(f32 px, f32 min_px, bool include_minors) { 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; if (format != TickFormat::Time) return;
// generate_ticks labels with format_si; a time axis relabels its majors here, // 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 // 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 <plot/plot/figure.hpp>
#include <algorithm> #include <algorithm>
#include <cstdio>
#include <cmath> #include <cmath>
#include <cstddef> #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 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 // 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 // *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 // than everything it retains.
// edge behind the oldest sample left, i.e. over data that is gone. //
// 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 bool log = !plots_.empty() && plots_.back().x_axis(0u).log;
const f64 newest_space = to_link_space(live.max, log); const f64 newest_space = to_link_space(live.max, log);
const f64 retained = const f64 retained =
std::max(0.0, newest_space - to_link_space(live.min, log)); 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.live_newest = live.max;
group.has_live = true; group.has_live = true;
const f64 right = newest_space - group.offset; const f64 right = newest_space - group.offset;
+49
View File
@@ -306,6 +306,55 @@ u32 Plot::add_annotation(const Annotation& annotation) {
return static_cast<u32>(annotations_.size() - 1); return static_cast<u32>(annotations_.size() - 1);
} }
void Plot::set_cursor_color(u32 id, Color color) {
if (id >= cursors_.size()) return;
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;
}
u32 Plot::add_region(const Region& region) { u32 Plot::add_region(const Region& region) {
// A region whose bounds are not numbers is not an interval this plot can hold, and a // A region whose bounds are not numbers is not an interval this plot can hold, and a
// `Free` one stored that way could never be dragged back (every motion re-derives both // `Free` one stored that way could never be dragged back (every motion re-derives both
+33 -9
View File
@@ -99,14 +99,34 @@ void format_time(f64 seconds, char* buf, usize n) {
const f64 t = std::fabs(seconds); const f64 t = std::fabs(seconds);
if (t < 1.0) { if (t < 1.0) {
// Sub-second: milliseconds. The 999.5 cut keeps %.3g out of scientific // Sub-second: the prefix that keeps the mantissa in [1, 1000) -- the same rule the SI
// notation (it would print "1e+03ms") right below the one-second boundary. // formatter uses on a y axis, and the reading a zoomed-in window needs. A fixed
const f64 ms = t * 1000.0; // millisecond unit printed `0.005ms` where `5us` is what the axis is showing, and
if (ms < 999.5) { // `1e-05ms` where `10ns` is. The micro sign is the UTF-8 sequence the embedded font has a
std::snprintf(buf, n, "%s%.3gms", sign, ms); // glyph for (see `format_si`), not a Latin-1 byte.
} else { // Exactly zero carries no unit, the same convention `format_si` states: every prefixed
std::snprintf(buf, n, "%s%.3gs", sign, t); // 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; 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(); out.clear();
const f64 lo = range.min; 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); : 10.0);
if (!std::isfinite(step) || !(step > 0.0)) return; // span so large the step overflowed 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; if (!std::isfinite(first_major)) return;
// Minor ticks subdivide the major step: by 4 when the step's leading digit is // 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. /// clamp, so it lands off-scale rather than as NaN.
f64 space(f64 v, bool log) noexcept { return log ? Transform::log10_positive(v) : v; } 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 /// One ASCII/UTF-8 line of the measurement readout. `format_si` leaves room for
/// three significant digits plus a prefix and sign. /// three significant digits plus a prefix and sign.
constexpr usize kNumberChars = 24; 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 // 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 single group order always did. `LineBatch::clear()` keeps its capacity, so
// the second pass re-uses the first one's storage. // 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) { if (!visible) {
return; 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 // 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. // 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, 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, 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 // ------------------------------------------------------------- axis chrome
@@ -994,15 +994,26 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
const u64 n = series.size(); const u64 n = series.size();
if (!(dx > 0.0) || n == 0) continue; if (!(dx > 0.0) || n == 0) continue;
// Map the visible x range onto sample indices. One sample of slack on each // The visible x range onto sample indices, through the series' *own* x.
// 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. // 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); const Range x_range = plot.x_range(trace.axis_x);
f64 lo = (x_range.min - series.x0()) / dx; // `dx` is still read above for the samples-per-pixel estimate -- a density, where a nominal
f64 hi = (x_range.max - series.x0()) / dx; // spacing is exactly right -- and never for the window itself.
if (hi < lo) std::swap(lo, hi); u64 first = 0;
const u64 first = clamp_index(std::floor(lo) - 1.0, n); u64 last = n;
const u64 last = clamp_index(std::ceil(hi) + 1.0, 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; if (last <= first) continue;
// This is the whole performance contract: the window, never the series. // 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 if (gather_.size() < 4u) continue; // fewer than two points is no segment
stats_.visible_samples += visible; 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) { if (level == 0u) {
++stats_.raw_traces; ++stats_.raw_traces;
} else { } 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 // 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 // a floating-point ulp, which a bare `<` would mistake for "outside". Only the ones
// beyond that margin are skipped. // 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) if (horizontal ? (at < layout.axes.y - half || at > layout.axes.bottom() + half)
: (at < layout.axes.x - half || at > layout.axes.right() + half)) { : (at < layout.axes.x - half || at > layout.axes.right() + half)) {
continue; 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, stage_segment(x_range.min, x_range.min, y_drawn, x_range.max, y_drawn, log_x,
plot.y_axis(cursor.axis_y).log); plot.y_axis(cursor.axis_y).log);
flush_lines(mapping(plot, 0, cursor.axis_y, x_range.min), flush_lines(mapping(plot, 0, cursor.axis_y, x_range.min),
on ? style_.cursor : cursor.color, on ? style_.cursor : cursor.color, width, cursor.dash);
style_.device(on ? style_.handle_width_px : style_.line_width_px));
} else { } else {
const f64 x_drawn = plot.x_transform(cursor.axis_x).to_data_x(static_cast<f64>(drawn)); 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, stage_segment(x_drawn, x_drawn, y_range.min, x_drawn, y_range.max,
plot.x_axis(cursor.axis_x).log, log_y); plot.x_axis(cursor.axis_x).log, log_y);
flush_lines(mapping(plot, cursor.axis_x, 0, x_drawn), flush_lines(mapping(plot, cursor.axis_x, 0, x_drawn),
on ? style_.cursor : cursor.color, on ? style_.cursor : cursor.color, width, cursor.dash);
style_.device(on ? style_.handle_width_px : style_.line_width_px));
} }
draw_cursor_label(plot, layout, cursor, drawn); draw_cursor_label(plot, layout, cursor, drawn);
} }
+73
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 // 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 // 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. // 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) { PLOT_TEST(a_wheel_on_a_following_figures_axis_strip_keeps_the_live_edge) {
constexpr u32 kFigureRows = 3u; constexpr u32 kFigureRows = 3u;
constexpr f64 kWheelDx = 1.0 / 1000.0; constexpr f64 kWheelDx = 1.0 / 1000.0;
@@ -2309,3 +2350,35 @@ PLOT_TEST(double_click_needs_both_timestamps_and_the_window) {
f.controller.on_pointer({410.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false, f.controller.on_pointer({410.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
true, 20.1}); true, 20.1});
} }
// A readout cursor is drawn but not offered to the pointer: the host that keeps it is showing
// a computed value, and a drag on it would be a drag on a number rather than on an instrument.
// The hit test is where that is enforced -- re-placing the marker every frame would only fight
// the drag, which is the shape this replaces.
PLOT_TEST(an_unselectable_cursor_is_neither_hovered_nor_grabbed) {
plot::Plot p;
p.set_viewport({0, 0, 800, 400});
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
const u32 readout = p.add_cursor(50.0, plot::Color::hex(0x00FF00));
p.set_cursor_selectable(readout, false);
PLOT_CHECK(p.cursor(readout).visible); // drawn all the same
PLOT_CHECK(!p.cursor(readout).selectable);
// A normal cursor right beside it stays grabbable, so the flag is a property of one cursor
// and not a switch on the plot.
const u32 normal = p.add_cursor(50.0, plot::Color::hex(0xFF0000));
plot::input::Controller c(p);
c.set_viewport({0, 0, 800, 400});
const f32 sx = screen_x_of(p, ax, 50.0);
c.on_pointer({sx, 200, 0, 0, plot::input::Button::Left, 0, false, false, false});
PLOT_CHECK(c.hovered_cursor() == normal); // the readout is not in the running
// Press and move: the pointer grabbed the *normal* cursor (the two are at the same x, so
// the readout would otherwise be the nearer hit), and the readout's own x never moves.
const f64 before = p.cursor(readout).x;
c.on_pointer({sx, 200, 0, 0, plot::input::Button::Left, 0, true, true, false});
PLOT_CHECK(c.grabbed_cursor() == normal);
c.on_pointer({sx + 40, 200, 40, 0, plot::input::Button::Left, 0, true, false, false});
PLOT_CHECK_NEAR(p.cursor(readout).x, before, 1e-12);
PLOT_CHECK(c.grabbed_cursor() == normal);
}
+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 // 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 // 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 // factor of 100) -- and the offset as far behind the edge as the retained data
// survive the next frames' advances. // 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; const plot::f64 edge = left.x_bounds().max;
figure.plot(1u).set_x_range(0u, {edge / 1000.0, edge / 10.0}); figure.plot(1u).set_x_range(0u, {edge / 1000.0, edge / 10.0});
figure.layout(); figure.layout();
PLOT_CHECK_NEAR(decades_of(figure.plot(0u)), 2.0, 1e-9); 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(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) { for (int step = 0; step < 3; ++step) {
left.push(0.5f); left.push(0.5f);
left.acquire(); left.acquire();
figure.layout(); figure.layout();
PLOT_CHECK_NEAR(decades_of(figure.plot(0u)), 2.0, 1e-9); 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(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); PLOT_CHECK(figure.x_link_follow(group) == plot::XFollow::Live);
} }
+50
View File
@@ -2584,3 +2584,53 @@ PLOT_TEST(xy_series_measurement_uses_the_true_nearest_samples) {
PLOT_CHECK_NEAR(stats.min, 10.0, 1e-9); PLOT_CHECK_NEAR(stats.min, 10.0, 1e-9);
PLOT_CHECK_NEAR(stats.max, 10.0, 1e-9); PLOT_CHECK_NEAR(stats.max, 10.0, 1e-9);
} }
// A host whose annotation is a readout -- the point where a cursor crosses a trace --
// moves it every frame. The id has to survive that, and a stale id must be ignored
// rather than read or written out of bounds, exactly as the cursors' setters behave.
// A readout cursor says which readout it belongs to with its colour; the setter keeps the
// id, and a stale one changes nothing.
PLOT_TEST(a_cursor_can_be_recoloured_by_id) {
plot::Plot p;
const u32 id = p.add_cursor(2.0, plot::Color::hex(0x111111));
PLOT_CHECK(p.cursor(id).color.b == plot::Color::hex(0x111111).b);
p.set_cursor_color(id, plot::Color::hex(0x9BE8A0));
PLOT_CHECK(p.cursor(id).color.g == plot::Color::hex(0x9BE8A0).g);
PLOT_CHECK(p.cursor_count() == 1u); // recoloured, not added
PLOT_CHECK_NEAR(p.cursor(id).x, 2.0, 1e-12);
p.set_cursor_color(42u, plot::Color::hex(0xFFFFFF));
PLOT_CHECK(p.cursor(id).color.g == plot::Color::hex(0x9BE8A0).g);
}
PLOT_TEST(an_annotation_can_be_moved_by_id) {
plot::Plot p;
const plot::Annotation pin{1.0, 2.0, "first", plot::Color::hex(0xE8E8E8), 0u, 0u, true};
const u32 id = p.add_annotation(pin);
PLOT_CHECK(p.annotation(id).x == 1.0);
PLOT_CHECK(p.annotation(id).text == std::string("first"));
plot::Annotation moved = pin;
moved.x = 3.5;
moved.y = -1.25;
moved.text = "moved";
p.set_annotation(id, moved);
PLOT_CHECK_NEAR(p.annotation(id).x, 3.5, 1e-12);
PLOT_CHECK_NEAR(p.annotation(id).y, -1.25, 1e-12);
PLOT_CHECK(p.annotation(id).text == std::string("moved"));
PLOT_CHECK(p.annotation_count() == 1u); // moved, not added
// Parked rather than removed, like a cursor: the id stays valid and reads back hidden.
plot::Annotation parked = moved;
parked.visible = false;
p.set_annotation(id, parked);
PLOT_CHECK(!p.annotation(id).visible);
PLOT_CHECK(p.annotation_count() == 1u);
// A stale id changes nothing (and reads a default, as the accessors promise).
plot::Annotation other = pin;
other.x = 99.0;
p.set_annotation(42u, other);
PLOT_CHECK_NEAR(p.annotation(id).x, 3.5, 1e-12);
}
+548 -7
View File
@@ -695,6 +695,125 @@ PLOT_TEST(renders_visible_trace_across_viewport) {
ctx.destroy(); 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) { PLOT_TEST(dense_and_sparse_paths_agree_on_extent) {
plottest::HeadlessContext ctx(kW, kH); plottest::HeadlessContext ctx(kW, kH);
plot::Status st; 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 // 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 // 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. // 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) { PLOT_TEST(grid_hairlines_land_on_whole_pixels) {
plottest::HeadlessContext ctx(kW, kH); plottest::HeadlessContext ctx(kW, kH);
plot::Status st; plot::Status st;
@@ -3177,6 +3616,20 @@ plot::f32 window_ink(const GridFrame& f, const plot::Rect& window, plot::Color b
return ink; 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 } // namespace
/// Spec §11's per-class switch, on the minor class: a class the style switches off is /// 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 GridWindows win = grid_windows(both);
const plot::f32 major_before = window_ink(both, win.major, bg, major_ink); 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); 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 // A line, not one stray fringe pixel: an 8-row window over a stroke holds most of the ink
// 8 px of ink per class, and this asks for most of it. // 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(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; r.style().grid_minor_visible = false;
const GridFrame without = draw_grid_scene(ctx, r, 1.0f); 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 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); const plot::f32 minor_before = window_ink(both, win.minor, bg, r.style().grid_minor);
PLOT_CHECK(major_before > 0.75f * kWindowRows); 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; r.style().grid_major_visible = false;
const GridFrame without = draw_grid_scene(ctx, r, 1.0f); 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_major_width = 2.0f;
r.style().grid_minor_width = 1.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 GridFrame one = draw_grid_scene(ctx, r, 1.0f);
const GridWindows win = grid_windows(one); const GridWindows win = grid_windows(one);
const plot::f32 major_1x = window_ink(one, win.major, bg, r.style().grid_major); 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; return best;
} }
/// The grid *majors* (0x2A3038 over the 0x101418 background), which no other element of these /// The grid *majors* (0x39414C 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, /// frames reaches: the minors are that same colour at 50% alpha over a 0.75-covered dashed
/// a trace is saturated and the cursor is the orange accent. /// 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]) { 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 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])); const plot::u8 hi = std::max(px[0], std::max(px[1], px[2]));
@@ -5971,3 +6433,82 @@ PLOT_TEST(release_gl_shuts_down_and_the_renderer_can_reinit) {
PLOT_CHECK(!r.last_frame_had_gl_error()); PLOT_CHECK(!r.last_frame_had_gl_error());
ctx.destroy(); ctx.destroy();
} }
// The other orientation, in a figure: a horizontal ruler is per row (its `y` is on that
// row's y axis, so two rows' rulers are two different values), and every row's has to be
// drawn in its own band. The vertical case above covers the shared-time half; this is the
// half where each row owns its instrument, which is what an app's "two rulers per plot"
// needs -- and which the app found undrawn.
PLOT_TEST(figure_rows_draw_their_own_horizontal_rulers) {
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::f32> samples = wave(101u, 0.0f);
plot::data::ArraySeries series(samples, 0.0, 1.0);
// A strongly red ruler and a strongly green one, so a pixel that is red- or
// green-dominant inside a row's axes can only be that row's ink: the trace is blue and
// the chrome grey (all three channels equal).
const plot::Color red = plot::Color::hex(0xFF2020);
const plot::Color green = plot::Color::hex(0x20FF20);
const plot::f32 y_scales[3] = {671.0e6f, 90.0f, 2.51f};
// The harness's no-atlas backend: a ruler always writes its value as text, and this case
// is about the *stroke*, so the text is asked for and staged as nothing. It goes on the
// *renderer*, not on the plots: `draw(Figure&)` replaces each row's backend with the
// renderer's own on the first frame, so a plot-level backend would be swapped out under a
// batch that had already declared that backend's (absent) cell.
OnePxPerStringBackend no_text;
r.set_text_backend(&no_text);
plot::Figure figure(3u);
figure.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
for (plot::u32 row = 0; row < 3u; ++row) {
plot::Plot& p = figure.plot(row);
const plot::u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
const plot::u32 ay =
p.add_y_axis({"v", {0.0, y_scales[row]}, false, plot::AxisPlace::Left, false});
p.add_trace({&series, kRowBlue, 1.5f, ax, ay});
// A quarter and three quarters up this row's own range: inside its axes whatever the
// scale is, which is the point of a ruler being per row.
p.add_h_ruler(0.25 * y_scales[row], red);
p.add_h_ruler(0.75 * y_scales[row], green);
}
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());
dump_if_requested(r, "figure_h_rulers.ppm");
const std::vector<plot::u8> frame = read_frame(ctx);
// Rows of the frame that hold a fully covered red or green line, and which row's band
// each falls in. The predicate demands full coverage exactly as the vertical case does.
const auto lines_in = [&](const plot::Rect& area, bool want_red) {
plot::u32 found = 0;
for (plot::u32 y = static_cast<plot::u32>(area.y);
y < static_cast<plot::u32>(area.bottom()); ++y) {
plot::u32 run = 0;
for (plot::u32 x = static_cast<plot::u32>(area.x);
x < static_cast<plot::u32>(area.right()); ++x) {
const plot::u8* px = &frame[(plot::usize(y) * ctx.width + x) * 4u];
const bool ok = want_red ? (px[0] >= 200 && px[1] <= 80 && px[2] <= 80)
: (px[1] >= 200 && px[0] <= 80 && px[2] <= 80);
if (ok) ++run;
}
if (run > (area.w * 3u) / 4u) ++found;
}
return found;
};
const plot::FigureLayout layout = figure.layout();
for (plot::u32 row = 0; row < 3u; ++row) {
const plot::Rect& axes = figure.plot(row).layout().axes;
PLOT_CHECK(lines_in(axes, true) >= 1u); // this row's own A ruler
PLOT_CHECK(lines_in(axes, false) >= 1u); // and its B ruler
}
(void)layout;
}
+82
View File
@@ -2,6 +2,7 @@
// SPDX-FileCopyrightText: 2026 Martino Ferrari // SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "harness.hpp" #include "harness.hpp"
#include <plot/plot.hpp>
#include <plot/plot/ticks.hpp> #include <plot/plot/ticks.hpp>
#include <cmath> #include <cmath>
#include <cstdlib> #include <cstdlib>
@@ -235,6 +236,11 @@ PLOT_TEST(si_labels_are_utf8_and_never_overrun) {
PLOT_TEST(format_time_shapes) { PLOT_TEST(format_time_shapes) {
char b[32]; char b[32];
plot::format_time(0.5, b, sizeof b); PLOT_CHECK(std::string(b) == "500ms"); 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(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(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"); 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); 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
}