Compare commits

..
20 Commits
Author SHA1 Message Date
Martino FerrariandClaude Opus 4.6 bee318870f feat(plot): a figure can keep an empty band to the right of its 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
`layout_.axes` reached the viewport's right edge, so the rightmost x tick
label -- centred on its tick -- had half of itself outside the plot, and
the newest sample's ink landed on the frame. `Plot::set_right_gutter()`
reserves a band there, out of the same `1 - kMinAxesShare` allowance the
label and title strips are served from, so it can no more squeeze `axes`
below the half-viewport floor than they can. `Figure` sets one value on
every row, the left gutter's reason: the rows' axes have to end on a
common column as well as start on one.

Nothing is drawn in the band, so unlike the strips it is a want and never
a measurement: no measuring pass, and it cannot feed the layout's fixed
point.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 22:24:27 +02:00
Martino Ferrari 58473db384 fix: a time label's mantissa cannot reach its own radix
`format_time()` picked its sub-second rung from the raw value, so three
significant figures of a value just under a rung's top rounded to 1000 and
`%.3g` wrote that as an exponent: a one-second tick computed as 0.9999999 s
read `1e+03ms`. The rung is now chosen by the rounded mantissa, which is the
same 999.5 check `format_si()` already made for the SI prefixes, and a value
that rounds to a whole second leaves the sub-second branch entirely.
2026-10-05 21:31:43 +02:00
Martino Ferrari c2c8c596f3 fix(data): the gather origin is the caller's, so a deep zoom draws on the sample
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
`Series::gather()` stages x as f32 relative to `x0()` -- the start of the
*retained window* -- so the value's ULP was a fraction of data the view need
not even hold: 1.9 us over a 20 s ring. A readout quotes `x_at()` in exact
f64, so at a 10 us view across 700 px that put more than a hundred pixels
between where a sample is drawn and the x reported for it.

The origin is now a parameter, placed before `out_xy` so every call site is a
type error rather than a silent change of behaviour. `Renderer::draw_traces`
passes the visible range's minimum, which makes the error a fixed 2^-24 of the
span on screen -- 4e-5 of a pixel over a 700 px axis -- at every zoom.

`stage_xy()` loses its second origin as a consequence: the gather origin and
the draw origin are now always the same value, so the linear case hands the
gathered buffer straight to `add_polyline_xy()` with no per-point copy.

`Plot::crosshair_point()` names the query itself, the one origin that cannot
lose precision for a caller that might.

Guarded by `a_gather_origin_at_the_view_keeps_a_deep_zoom_on_the_sample_it_quotes`,
which measures the error in pixels both ways: under 0.05 px with the view's
minimum, over 10 px with `x0()`.
2026-10-05 18:43:50 +02:00
Martino FerrariandClaude Opus 4.6 59c2e45af7 feat(plot): an x axis caption drawn as chrome, not as an annotation
A host that re-anchors its x labels on a grid has to name the anchor somewhere, and
an `Annotation` cannot serve: its anchor is a data coordinate, so the caption crawls
across the plot under a pan and drifts under a zoom.

`Plot::set_x_note()` takes the host's own string -- the host is the only one that
knows how much precision the sum of a label and its caption needs -- and
`Renderer::draw_x_note()` places it in the axes' bottom-left corner in pixels, on the
legend's own backplate. It is measured in the renderer rather than in `Plot::layout()`
because it reserves no space and nothing can be picked on it, and a caption explaining
the x labels must not be able to move the layout those labels were measured into.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 18:21:16 +02:00
Martino FerrariandClaude Opus 4.6 03c41adbec fix(render): three warnings a stricter build reports, none of them behavioural
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 point-index loop in `draw_traces` named its variable `i`, shadowing the trace
index that the whole block is nested inside; it is `p` now, which also leaves the
comment free to say which one is which. The marker loop's two screen coordinates
were narrowed implicitly. And `same_cell()`'s only caller is an `assert`, so a
release build had an unused function; it is guarded the way its caller is.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 16:12:39 +02:00
Martino Ferrari c960decaea feat(plot): the readout marker snaps to the sample it quotes, and the value mode is a choice
The mark was placed at the *pointer's* x with the quoted sample's y, so with the
nearest-sample readout it sat off the datum horizontally -- and further off the deeper
the zoom. `CrosshairRow` now carries the data `x` the value belongs to and the marker is
placed from it, so in `NearestSample` the mark lands on the sample the panel is quoting.
`Style::crosshair_marker_px` (9) and `crosshair_marker_width` (1.5) replace the
renderer's hard-coded 3 px arm and 1 px stroke: a snapped mark lands among a trace's own
sample dots (`marker_px`, 3) and at that size would read as one more sample rather than
as the answer to the question the hover asks.

`CrosshairValue` makes the previous behaviour reachable rather than deleted. The two
modes answer different questions -- `NearestSample` "which datum is under the pointer",
`Interpolated` "what is the signal at this instant" -- and only the caller knows which it
is asking, so the plot offers both with the sample as the default.
`crosshair_interpolate()` is restored for it.

`Plot::crosshair_point()` is the lookup with both coordinates, public because a host that
draws instruments of its own needs the same snap and a second rule for it would drift
from this one; `crosshair_value()` delegates to it.
2026-10-05 14:36:47 +02:00
Martino FerrariandClaude Opus 4.6 c028f48fa0 fix(plot): a crosshair readout quotes a sample, never a value between two
The readout's y was a linear interpolation between the two samples adjacent to
the pointer, which answers "what is the signal at this instant" -- a number the
series does not contain. On a source whose samples are already averaged or
decimated at ingest that is a value nothing measured, and at a zoom past the
sample spacing it is visibly not the datum under the pointer.

`crosshair_span()`'s `t` becomes a selector rather than a blend factor: the
nearer end wins, a tie reading the earlier sample (the bias the span already
takes for a degenerate pair). `t` is measured on the samples' own x through
`neighbours()`/`x_at()`, so "nearer" is exact on a non-uniform clock too, and
the lookup still reads level 0, so the value is a stored sample and never an
LOD bucket's aggregate. `crosshair_interpolate()` had no other caller and is
gone.

`CrosshairRow::marker` is placed from the same y, so the dot follows the
reported sample: past the sample spacing it sits beside the polyline rather
than on it, deliberately -- one y feeding both keeps the mark and the number
from ever disagreeing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 13:42:57 +02:00
Martino Ferrari 26a7c82a80 feat(input): Ctrl restricts keyboard zoom to x, Alt to y
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
2026-10-04 12:03:01 +02:00
Martino Ferrari 0ee6e10c81 fix(render): a clamped ring front does not draw a segment across the panel
CI / debug (tests + all adapters) (push) Canceled after 0s
CI / coverage (floor + measured table) (push) Canceled after 0s
CI / asan (AddressSanitizer) (push) Canceled after 0s
A ring-backed series answers a *clamped* stamp for the oldest slots of its window -- the ones the
producer has already overwritten -- because the published window is pinned while both rings slide
under it, and the alternative (a sentinel) collapsed the whole index mapping. The consequence for a
polyline is that the stamps do not increase at the *front*: the first points carry the newest stamp
and everything after them is older. Drawn as they come, that is a segment from the newest sample
back to the oldest -- a line across the whole panel, which a saturated ring shows as a flicker.

The gathered points are now reduced to their longest strictly increasing suffix before anything is
staged, so the unreadable front is left out and the real samples are untouched. A forward pass
cannot express this: the bad points are at the front and everything behind them is legitimately
smaller, so "drop anything that does not advance" would delete the trace -- walking backwards keeps
each point strictly older than the one kept after it and stops at the first unreadable slot. One
pass over the visible points, which the staging then walks anyway.

Test: a_clamped_front_does_not_draw_a_segment_across_the_panel -- a series whose first two samples
carry the newest stamp at -1 while the rest walk 0..50 at +1, the shape a saturated ChannelBuffer
produces. Flat at +1 except for those two, so any ink below the middle of the axes is the bad
segment: with the filter there is none (0 below, 1032 above in the columns the trace covers), and
without it the diagonal crosses the panel.
2026-10-03 17:51:37 +02:00
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
34 changed files with 2132 additions and 268 deletions

No files matched your search

+21 -2
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,13 +29,27 @@ run touched a GPU, so read it as an upper bound and a record of the software pat
## Features ## Features
- a trace whose series answers a *clamped* stamp for its oldest slots -- a ring whose published
window is pinned while it slides -- is drawn from its longest increasing *suffix*, so an unreadable
front cannot draw a segment from the newest sample back to the oldest (a line across the whole
panel);
- 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;
- a signal legend anchored to an axes corner; - a signal legend anchored to an axes corner;
- a hover crosshair with a per-signal, interpolated readout; - a hover crosshair with a per-signal readout, quoting the nearest sample and marking it
(`CrosshairValue::NearestSample`, the default) or interpolating between two
(`CrosshairValue::Interpolated`);
- a multi-plot `Figure` of stacked rows sharing one synchronised x axis; - a multi-plot `Figure` of stacked rows sharing one synchronised x axis;
- live follow (`XFollow::Live`) with relative (multiplicative) zoom and mouse panning; - live follow (`XFollow::Live`) with relative (multiplicative) zoom and mouse panning;
- XY series for irregularly sampled data (`plot/data/xy_series.hpp`): sorted-ascending x - XY series for irregularly sampled data (`plot/data/xy_series.hpp`): sorted-ascending x
+20 -12
View File
@@ -166,10 +166,10 @@ series therefore stages O(visible pixels) vertices, not O(samples), and `FrameSt
which path each trace took. Below the threshold the raw samples are drawn instead; both which path each trace took. Below the threshold the raw samples are drawn instead; both
sides go through the same instanced anti-aliased quad pipeline. sides go through the same instanced anti-aliased quad pipeline.
## Exact versus interpolated ## One sample or a statistic of many
The library deliberately holds two conventions next to each other, and they do **not** Every number the library reports is one the samples actually hold. What differs between
agree: these two is *how many* samples answer the question:
- **`Series::reduce` / `sum` / `sumsq` are exact over a sample range.** They answer "what - **`Series::reduce` / `sum` / `sumsq` are exact over a sample range.** They answer "what
are the samples' extremes / sum / sum of squares over `[begin, end)`". Each is are the samples' extremes / sum / sum of squares over `[begin, end)`". Each is
@@ -179,15 +179,23 @@ agree:
and no query allocates. `Measure::evaluate` and `Plot::region_stats` are built on these, and no query allocates. `Measure::evaluate` and `Plot::region_stats` are built on these,
never on the `*_buckets` forms, which would silently report wrong extremes for an never on the `*_buckets` forms, which would silently report wrong extremes for an
unaligned interval. unaligned interval.
- **The crosshair readout is interpolated, and says so.** `crosshair_span(series, x)` - **The crosshair readout is a single sample — the nearest one — by default.**
returns the two samples `x` lies between (`begin`, `end`, `t`), and `crosshair_span(series, x)` returns the two samples `x` lies between (`begin`, `end`, `t`),
`crosshair_interpolate(y_begin, y_end, t)` does a linear interpolation: it answers "what and `CrosshairValue::NearestSample` takes whichever of the two is nearer: `t <= 0.5` the
is the signal *at* this instant", which is generally between two samples — a different earlier, otherwise the later. It answers "which datum is under the pointer", not "what is
question from an interval statistic. The lookup is O(1) (one division and a floor) and the signal *at* this instant" — a linearly interpolated y is a number the series does not
allocation-free; `Plot::crosshair_value(trace, x, out_y)` exposes the same number to a contain, and on a source whose samples are already averaged or decimated at ingest it is a
host. A row whose trace has no sample at the crosshair's x shows `kCrosshairNoValue` claim about a signal nobody measured. That is why it is the default; a host whose x is a
(an em dash, `"—"`), and an x outside the series window yields no value rather than the quantity it is willing to interpolate states `CrosshairValue::Interpolated` and gets
nearest sample's. `crosshair_interpolate(y_begin, y_end, t)` instead, at the crosshair's own x. The lookup is
O(1) and allocation-free either way, and it reads level 0, so the value is a stored sample
(or a blend of two) and never an LOD bucket's aggregate. `Plot::crosshair_point(trace, x,
out_x, out_y)` exposes **both** coordinates to a host and `crosshair_value()` the y alone;
`CrosshairRow::x`/`y` and its `marker` are placed from that same pair, so in
`NearestSample` the mark snaps onto the quoted sample and the dot and the number cannot
disagree. A row whose trace has no sample at the crosshair's x shows `kCrosshairNoValue`
(an em dash, `"—"`), and an x outside the series window yields no value rather than
clamping to the first or last sample.
`Measurement` (from `Plot::measurement()`) documents `integral` as a **left-Riemann sum**, `Measurement` (from `Plot::measurement()`) documents `integral` as a **left-Riemann sum**,
`sum(begin, end) * dx` — the samples taken as the value at the left edge of each step. That `sum(begin, end) * dx` — the samples taken as the value at the left edge of each step. That
+34 -3
View File
@@ -106,6 +106,7 @@ one per plot.
- `set_crosshair_readout(bool)` switches the panel off — or back on — without moving the - `set_crosshair_readout(bool)` switches the panel off — or back on — without moving the
crosshair. `set_crosshair_style(const CrosshairStyle&)` overrides this plot's stroke and crosshair. `set_crosshair_style(const CrosshairStyle&)` overrides this plot's stroke and
dash. dash.
- `set_crosshair_value_mode(CrosshairValue)` chooses what a readout *is* — see below.
In a `Figure`, the crosshair is applied to **every row** (one shared `x`): each row's readout In a `Figure`, the crosshair is applied to **every row** (one shared `x`): each row's readout
lists its own traces at that instant. `Figure::set_crosshair(x, active)` is vertical-only lists its own traces at that instant. `Figure::set_crosshair(x, active)` is vertical-only
@@ -113,9 +114,39 @@ across the figure, and `Figure::set_crosshair(x, hovered_row, y_in_row, active)`
horizontal line **only in the row the pointer is in** — a figure's rows share x and not y, so horizontal line **only in the row the pointer is in** — a figure's rows share x and not y, so
a figure-wide horizontal line would be a value that means something different in each row. a figure-wide horizontal line would be a value that means something different in each row.
Readout values are **interpolated**, not interval statistics (see ### What a readout is: the two value modes
[data-and-performance.md](data-and-performance.md)); a row whose trace has no sample at the
crosshair's x is listed with the em dash `"—"` and no marker, rather than a fabricated value. `Crosshair::value_mode` (`set_crosshair_value_mode()`) answers two different questions, and
which one a caller is asking decides the mode:
| mode | the value | the marker | answers |
|---|---|---|---|
| `CrosshairValue::NearestSample` (default) | the nearer bracketing sample's y (a tie reads the earlier) | **snapped to that sample's own x** | *which datum is under the pointer?* |
| `CrosshairValue::Interpolated` | a linear blend of the two bracketing samples | at the crosshair's own x | *what is the signal at this instant?* |
The default is the sample, because a readout that quotes a number the data does not contain is
a second estimate laid over the first — and once the view is zoomed past the sample spacing the
polyline between two samples is a line the *renderer* drew, so reading a value off it reports
the drawing rather than the signal. A host whose x is a continuous quantity it is willing to
interpolate (a smooth analytic curve, a resampled series) states `Interpolated` and gets the
blend, exactly at the pointer. Either way the value is a **stored** sample or a blend of two of
them, never an LOD bucket's aggregate (see
[data-and-performance.md](data-and-performance.md)).
`CrosshairRow` carries both halves: `x` and `y` are the data coordinate the value belongs to —
the quoted sample's own in `NearestSample`, the crosshair's x in `Interpolated` — and `marker`
is that point in screen space. `Plot::crosshair_point(trace, x, out_x, out_y)` is the same
lookup for a host that draws instruments of its own; `Plot::crosshair_value()` is it with the x
discarded.
The mark is drawn `Style::crosshair_marker_px` across (9 logical px) at
`crosshair_marker_width`, both deliberately **larger than a trace's own sample dots**
(`marker_px`, 3): in `NearestSample` the mark lands *among* those dots, and at the same size it
would read as one more sample instead of as the one the panel is quoting. `0` across draws no
mark.
A row whose trace has no sample at the crosshair's x is listed with the em dash `"—"` and no
marker, rather than a fabricated value.
## The gesture table (exactly as implemented) ## The gesture table (exactly as implemented)
+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
+1 -1
View File
@@ -33,7 +33,7 @@ class ArraySeries final : public Series {
Range x_bounds() const override; Range x_bounds() const override;
Range y_bounds() const override; Range y_bounds() const override;
const MinMaxPyramid& pyramid() const override; const MinMaxPyramid& pyramid() const override;
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode = GatherMode::Envelope) const override; GatherMode mode = GatherMode::Envelope) const override;
protected: protected:
+18 -10
View File
@@ -59,12 +59,20 @@ class Series {
/// Appends interleaved (x, y) pairs for level `level`. /// Appends interleaved (x, y) pairs for level `level`.
/// ///
/// **x is relative to `x0()`**: the emitted value is `x_at(i) - x0()` as /// **x is relative to `x_origin`**: the emitted value is `x_at(i) - x_origin` as f32, never
/// f32, never the absolute coordinate. A consumer reconstructs the absolute /// the absolute coordinate. A consumer reconstructs the absolute x in f64
/// x in f64 (`x0() + emitted`) before mapping it to the screen; staging an /// (`x_origin + emitted`) before mapping it to the screen; staging an absolute epoch-time x
/// absolute epoch-time x as f32 would quantise it to a grid coarser than /// as f32 would quantise it to a grid coarser than the pixel (f32 carries 24 bits -- 1 s at
/// the pixel (f32 carries 24 bits -- 1 s at 1.7e9), while the relative /// 1.7e9). y is unchanged.
/// value `i*dx` is small and exact. y is unchanged. ///
/// **The caller names the origin because only the caller knows the span the f32 has to be
/// exact against.** The origin was `x0()` -- the start of the series' *retained window* --
/// and a relative value's ULP is then a fraction of the whole window however little of it is
/// on screen: 1.9 us over 20 s of retained data, which at a view 10 us wide is more than a
/// hundred pixels between where a sample is *drawn* and the x a readout quotes for the same
/// sample (`x_at()`, exact f64). Passing the visible range's minimum instead makes the error
/// 2^-24 of the *visible span* -- a fixed 4e-5 of a pixel over a 700 px axis -- at every
/// zoom. A caller with no view in mind may pass `x0()` and get the old behaviour.
/// ///
/// Level 0 emits the raw samples of [begin, end). Level >= 1 emits one /// Level 0 emits the raw samples of [begin, end). Level >= 1 emits one
/// geometry element per *complete* level bucket wholly inside the retained /// geometry element per *complete* level bucket wholly inside the retained
@@ -85,7 +93,7 @@ class Series {
/// level_for() picks), and never reaches outside the retained window -- nor /// level_for() picks), and never reaches outside the retained window -- nor
/// outside [begin, end). Pairs are appended to out_xy, which is never /// outside [begin, end). Pairs are appended to out_xy, which is never
/// cleared. /// cleared.
virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, virtual void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode = GatherMode::Envelope) const = 0; GatherMode mode = GatherMode::Envelope) const = 0;
/// Absolute x of sample `i` (`x0() + i*dx()` on a uniform-grid series). /// Absolute x of sample `i` (`x0() + i*dx()` on a uniform-grid series).
@@ -97,7 +105,7 @@ class Series {
/// lands exactly on a sample). False when x lies outside the series' x /// lands exactly on a sample). False when x lies outside the series' x
/// bounds; `i0`/`i1` are then untouched. The crosshair and measurement /// bounds; `i0`/`i1` are then untouched. The crosshair and measurement
/// paths read coordinates only through this and `x_at()`, so a series with /// paths read coordinates only through this and `x_at()`, so a series with
/// its own x array interpolates exactly. /// its own x array brackets exactly.
virtual bool neighbours(f64 x, u64& i0, u64& i1) const; virtual bool neighbours(f64 x, u64& i0, u64& i1) const;
/// Index of the sample nearest data-space `x`, clamped to `[0, size()-1]`; /// Index of the sample nearest data-space `x`, clamped to `[0, size()-1]`;
@@ -121,7 +129,7 @@ class Series {
/// The shared body of the concrete gather() overrides: raw samples at level 0, /// The shared body of the concrete gather() overrides: raw samples at level 0,
/// bucket geometry above it (per `mode`). /// bucket geometry above it (per `mode`).
void gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, void gather_impl(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode) const; GatherMode mode) const;
private: private:
@@ -143,7 +151,7 @@ class Series {
/// 0 is entirely this, and a level >= 1 gather uses it for the two partial edge /// 0 is entirely this, and a level >= 1 gather uses it for the two partial edge
/// buckets a pyramid of complete buckets cannot answer for. Precondition: /// buckets a pyramid of complete buckets cannot answer for. Precondition:
/// begin < end, end <= size(). /// begin < end, end <= size().
void gather_raw(u64 begin, u64 end, std::vector<f32>& out_xy) const; void gather_raw(u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy) const;
}; };
} // namespace plot::data } // namespace plot::data
+1 -1
View File
@@ -92,7 +92,7 @@ class StreamSeries final : public Series {
Range x_bounds() const override; Range x_bounds() const override;
Range y_bounds() const override; Range y_bounds() const override;
const MinMaxPyramid& pyramid() const override; const MinMaxPyramid& pyramid() const override;
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode = GatherMode::Envelope) const override; GatherMode mode = GatherMode::Envelope) const override;
/// Always true: this series' `x_bounds()` is the retained ring window, which /// Always true: this series' `x_bounds()` is the retained ring window, which
/// slides with every `acquire()` -- the property `XFollow::Live` follows. /// slides with every `acquire()` -- the property `XFollow::Live` follows.
+2 -2
View File
@@ -53,8 +53,8 @@ class XYSeries final : public Series {
Range x_bounds() const override; Range x_bounds() const override;
Range y_bounds() const override; Range y_bounds() const override;
const MinMaxPyramid& pyramid() const override; const MinMaxPyramid& pyramid() const override;
/// Same contract as `Series::gather`, with x emitted relative to `x0()`. /// Same contract as `Series::gather`, with x emitted relative to `x_origin`.
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode = GatherMode::Envelope) const override; GatherMode mode = GatherMode::Envelope) const override;
/// The sample's own x. /// The sample's own x.
+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.
+87 -27
View File
@@ -30,6 +30,35 @@ struct CrosshairStyle {
///< (once resolved) means solid. ///< (once resolved) means solid.
}; };
/// What a crosshair readout's number *is*: one of the series' own samples, or a value
/// computed between two of them.
///
/// The two answer different questions and the right one is a property of the data, which the
/// plot cannot know — so it is the caller's to state.
///
/// * `NearestSample` answers **"which datum is under the pointer"**. The row reports the y of
/// whichever bracketing sample is nearer the crosshair's x (a tie reading the earlier), and
/// the marker is placed at **that sample's own x**, so the mark says *which* sample the
/// number came from. This is the default, and it is the honest mode for a source whose
/// samples are already averaged or decimated before they arrive: a number between two of
/// those is a second estimate of a signal that nothing measured, and at a zoom past the
/// sample spacing the polyline between two samples is a line the renderer drew, so a
/// reading off it reports the drawing.
/// * `Interpolated` answers **"what is the signal at this instant"**, by linear interpolation
/// between the two bracketing samples (`crosshair_interpolate()`). The marker stays on the
/// crosshair's own x, on the polyline, because that is where the number is. Right for a
/// continuous quantity sampled finely enough that the line between two samples is a model
/// the caller believes — a smooth analytical curve, an interpolating instrument — and
/// wrong wherever that line is an artefact of plotting.
///
/// Set per plot through `Plot::set_crosshair_value_mode()`. It reaches the readout's numbers,
/// the markers' positions and `Plot::crosshair_value()` alike, so a host and the panel beside
/// the pointer can never disagree about what the value means.
enum class CrosshairValue : u8 {
NearestSample, ///< The nearer bracketing sample's y, marked at that sample's x. Default.
Interpolated, ///< A linear blend of the two, marked at the crosshair's own x.
};
/// The crosshair's state: where it is, which of its two lines it draws, and whether the /// The crosshair's state: where it is, which of its two lines it draws, and whether the
/// per-signal readout is wanted. /// per-signal readout is wanted.
/// ///
@@ -44,10 +73,10 @@ struct CrosshairStyle {
/// drawn -- the single-coordinate `Plot::set_crosshair()` leaves the vertical one only, /// drawn -- the single-coordinate `Plot::set_crosshair()` leaves the vertical one only,
/// and a `Figure` is always vertical-only because its rows share x and not y. /// and a `Figure` is always vertical-only because its rows share x and not y.
/// ///
/// **The readout's values are interpolated, and deliberately so.** The y a row reports for /// **The readout's values are samples by default** -- the y a row reports is the nearer of
/// a trace is *linear interpolation between the two samples adjacent to `x`*, not a sample /// the two samples adjacent to `x`, and its marker sits on that sample. `value_mode` is what
/// and not an interval statistic -- see `crosshair_span()` for the rule stated in full, and /// chooses otherwise; see `CrosshairValue` for the two questions it picks between, and
/// for why it is the opposite convention from `Series::reduce()`/`sum()`/`sumsq()`. /// `crosshair_span()` for how both differ from `Series::reduce()`/`sum()`/`sumsq()`.
struct Crosshair { struct Crosshair {
/// Whether the crosshair is drawn at all. Set by `Plot::set_crosshair()`; cleared by /// Whether the crosshair is drawn at all. Set by `Plot::set_crosshair()`; cleared by
/// `Plot::clear_crosshair()`, which `input::Controller` calls when the pointer leaves the /// `Plot::clear_crosshair()`, which `input::Controller` calls when the pointer leaves the
@@ -58,7 +87,7 @@ struct Crosshair {
/// Draw the horizontal line (the row of `y`). On for the two-coordinate overload only: /// Draw the horizontal line (the row of `y`). On for the two-coordinate overload only:
/// a crosshair placed at one coordinate has no second value to mark a row with. /// a crosshair placed at one coordinate has no second value to mark a row with.
bool horizontal = true; bool horizontal = true;
/// List the visible traces and their interpolated values in a panel beside the pointer. /// List the visible traces and their values in a panel beside the pointer.
/// On by default, and free to leave on: a plot with no visible trace has no rows to /// On by default, and free to leave on: a plot with no visible trace has no rows to
/// measure and the panel is then not placed at all. /// measure and the panel is then not placed at all.
bool show_readout = true; bool show_readout = true;
@@ -67,6 +96,10 @@ struct Crosshair {
/// The data y of the horizontal line, on y axis 0. Meaningless for a vertical-only /// The data y of the horizontal line, on y axis 0. Meaningless for a vertical-only
/// crosshair, which draws no row and anchors its readout at the top of the axes. /// crosshair, which draws no row and anchors its readout at the top of the axes.
f64 y = 0.0; f64 y = 0.0;
/// Whether a row's value is one of the series' samples or a blend of two -- see
/// `CrosshairValue`, which also says where each mode puts the marker. Set through
/// `Plot::set_crosshair_value_mode()`.
CrosshairValue value_mode = CrosshairValue::NearestSample;
/// This plot's own stroke and dash, when it wants to differ from the global style; a /// This plot's own stroke and dash, when it wants to differ from the global style; a
/// default-constructed `CrosshairStyle` defers every field to `render::Style` (see /// default-constructed `CrosshairStyle` defers every field to `render::Style` (see
/// `CrosshairStyle`). Set through `Plot::set_crosshair_style()`. /// `CrosshairStyle`). Set through `Plot::set_crosshair_style()`.
@@ -84,14 +117,17 @@ struct Crosshair {
/// arithmetic, which is O(1) in the series' length. The crosshair runs on every pointer /// arithmetic, which is O(1) in the series' length. The crosshair runs on every pointer
/// move, so that is a requirement rather than a nicety. /// move, so that is a requirement rather than a nicety.
/// ///
/// **Interpolated, not exact.** The value a caller builds from this span by /// **`t` is a position, not yet an answer.** It says where the query sits between the two
/// `crosshair_interpolate()` between the two samples is the signal *at* `x`, which is /// samples and nothing more; what a caller makes of it is `CrosshairValue`'s to decide. In
/// generally between two samples -- the opposite convention from `Series::reduce()`, /// `NearestSample` -- the default -- it *selects*, `t <= 0.5` taking `begin`, so the readout
/// `sum()` and `sumsq()`, which are **exact over a sample range**. The two conventions sit /// quotes a sample the series actually holds. In `Interpolated` it *blends*, through
/// next to each other in this library on purpose and they do not agree: `reduce()` answers /// `crosshair_interpolate()`. A plot cannot tell which is right, because the question is
/// "what are the samples' extremes over this interval", this answers "what is the signal at /// about the data and not about the drawing; `CrosshairValue` is where that is argued.
/// this instant". A readout that pretended to be exact would be quoting an extreme or a ///
/// mean of a window as if it were the value under the pointer. /// Either way this is a different convention from `Series::reduce()`, `sum()` and `sumsq()`,
/// which are **exact over a sample range**: those answer "what are the samples'
/// extremes/total over this interval", a crosshair answers "what is the signal at this one
/// x". What differs is a statistic of many samples against a reading at one place.
/// ///
/// `valid` is false -- and every field left at its default -- for an **empty series**, a /// `valid` is false -- and every field left at its default -- for an **empty series**, a
/// non-positive `dx()`, a non-finite `x`, and an `x` strictly outside the series' window /// non-positive `dx()`, a non-finite `x`, and an `x` strictly outside the series' window
@@ -104,13 +140,14 @@ struct CrosshairSpan {
/// Index of the first sample whose x is at or after the query: `begin` when the query /// Index of the first sample whose x is at or after the query: `begin` when the query
/// lands on a sample, `begin + 1` between two of them. /// lands on a sample, `begin + 1` between two of them.
u64 end = 0; u64 end = 0;
/// The query's position between the two samples, in `[0, 1]`. /// The query's position between the two samples, in `[0, 1]`. Whether it selects one of
/// them or blends both is `CrosshairValue`'s -- see the struct comment.
f64 t = 0.0; f64 t = 0.0;
bool valid = false; bool valid = false;
}; };
/// The sample span `x` falls in, on `series`; see `CrosshairSpan` for the rule, for the /// The sample span `x` falls in, on `series`; see `CrosshairSpan` for what `t` means, for the
/// interpolation convention it is half of, and for the cases that yield `valid == false`. /// convention it is half of, and for the cases that yield `valid == false`.
/// ///
/// O(1) and allocation-free: a subtraction, a division and a truncation. The series' own sample /// O(1) and allocation-free: a subtraction, a division and a truncation. The series' own sample
/// fetch is the caller's -- `Plot::crosshair_value()` reads the span through /// fetch is the caller's -- `Plot::crosshair_value()` reads the span through
@@ -118,12 +155,14 @@ struct CrosshairSpan {
/// allocates. /// allocates.
CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept; CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept;
/// The interpolated y at `t` between two samples: `y_begin` at `t == 0`, `y_end` at /// The linear blend of a span's two end samples at `t`: `y_begin + t * (y_end - y_begin)`,
/// `t == 1`. The one place the interpolation's arithmetic lives, so the lookup's rule and /// which is what `CrosshairValue::Interpolated` reports.
/// its documentation cannot drift apart. ///
inline f64 crosshair_interpolate(f64 y_begin, f64 y_end, f64 t) noexcept { /// Written as that form rather than `(1 - t) * a + t * b` so `t == 0` returns `y_begin`
return y_begin + (y_end - y_begin) * t; /// exactly and `t == 1` returns `y_end` exactly, with no rounding of its own at the ends: a
} /// query that landed on a sample must read that sample's value back unchanged, whichever mode
/// is in force. `NearestSample` does not call it at all.
f64 crosshair_interpolate(f64 y_begin, f64 y_end, f64 t) noexcept;
/// One trace's line of the readout: which trace it is about, the value under the pointer /// One trace's line of the readout: which trace it is about, the value under the pointer
/// and the boxes the renderer stages. /// and the boxes the renderer stages.
@@ -150,8 +189,14 @@ struct CrosshairRow {
/// entries -- so a row that is not listed is still a listed *trace*, with a marker, just /// entries -- so a row that is not listed is still a listed *trace*, with a marker, just
/// not a line in a panel that has no room for it. /// not a line in a panel that has no room for it.
bool listed = false; bool listed = false;
/// The trace's interpolated y at the crosshair's x, on the trace's own y axis. Only /// The data x the row's value belongs to, on the trace's own x axis: in
/// meaningful when `valid`. /// `CrosshairValue::NearestSample` the **quoted sample's own x**, which is generally not the
/// crosshair's; in `Interpolated` the crosshair's x itself, since that is where an
/// interpolated number is. `marker` is placed from this and from `y`. Only meaningful when
/// `valid`.
f64 x = 0.0;
/// The row's value, on the trace's own y axis: the nearer sample's y, or the blend of the
/// two -- see `CrosshairValue`. Only meaningful when `valid`.
f64 y = 0.0; f64 y = 0.0;
/// The trace's name, or the positional placeholder of an unnamed one. /// The trace's name, or the positional placeholder of an unnamed one.
std::string label; std::string label;
@@ -161,9 +206,24 @@ struct CrosshairRow {
/// `kCrosshairNoValue`, the em dash. The absence is therefore *legible* -- a blank cell reads as /// `kCrosshairNoValue`, the em dash. The absence is therefore *legible* -- a blank cell reads as
/// "still loading" rather than as "nothing here" -- and nothing numeric is fabricated for it. /// "still loading" rather than as "nothing here" -- and nothing numeric is fabricated for it.
std::string value; std::string value;
/// The trace's intersection with the crosshair, in framebuffer pixels: the pen the /// Where the renderer draws the marker, in framebuffer pixels: the pen for `(x, y)`, placed
/// renderer draws the marker at. Placed through the *trace's own* axis pair, so the /// through the *trace's own* axis pair, so a trace on a secondary y axis is marked on the
/// marker is where that trace is actually drawn. Meaningless when `!valid`. /// scale it is drawn against.
///
/// It marks **the point the row reports**, which is the whole job: in
/// `CrosshairValue::NearestSample` it is *snapped to the quoted sample*, sitting on the datum
/// rather than beside it on the crosshair's column, so the mark says which of the samples
/// under the pointer the number came from. (Placing it at the pointer's x with the sample's y
/// put it off the data in both coordinates at a deep zoom -- a mark on nothing.) In
/// `Interpolated` it stays on the crosshair's column, on the polyline, which is equally where
/// *that* mode's number is. One `(x, y)` feeding both the mark and the text is what keeps them
/// from ever disagreeing.
///
/// It is drawn `Style::crosshair_marker_px` across -- **bigger than a trace's own sample
/// markers** on purpose, since a snapped mark lands among them and has to read as "this one"
/// rather than as one more sample. A mark for a sample outside the visible range is staged
/// where that sample is and left to the axes clip, rather than being dropped by a range test
/// of the drawing layer's own. Meaningless when `!valid`.
Vec2 marker{}; Vec2 marker{};
/// The swatch's box, `Legend::swatch_w` x `Legend::swatch_h` centred in the row. Empty /// The swatch's box, `Legend::swatch_w` x `Legend::swatch_h` centred in the row. Empty
/// when `!listed`. /// when `!listed`.
+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
+11
View File
@@ -138,6 +138,16 @@ class Figure {
} }
f32 min_y_gutter() const noexcept { return min_y_gutter_; } f32 min_y_gutter() const noexcept { return min_y_gutter_; }
/// The empty band every row keeps to the right of its `axes`, in device pixels (0.0
/// by default, so `axes` reaches the viewport's right edge). It is set here rather
/// than row by row for the left gutter's reason: the rows' axes have to line up, and
/// they end on a common column only if they are told one width. See
/// `Plot::set_right_gutter()` for what it is for. Negative values are clamped to 0.
void set_right_gutter(f32 device_px) noexcept {
right_gutter_ = device_px > 0.0f ? device_px : 0.0f;
}
f32 right_gutter() const noexcept { return right_gutter_; }
/// Whether the shared x axis is the figure's: every row reserves no x label strip /// Whether the shared x axis is the figure's: every row reserves no x label strip
/// and one shared axis is drawn under the last row (the default), or every row /// and one shared axis is drawn under the last row (the default), or every row
/// draws its own labels and the figure draws nothing extra. /// draws its own labels and the figure draws nothing extra.
@@ -326,6 +336,7 @@ class Figure {
FigureLayout layout_{}; FigureLayout layout_{};
f32 row_gap_ = 0.0f; f32 row_gap_ = 0.0f;
f32 min_y_gutter_ = 0.0f; f32 min_y_gutter_ = 0.0f;
f32 right_gutter_ = 0.0f;
bool labels_bottom_only_ = true; bool labels_bottom_only_ = true;
}; };
+102 -9
View File
@@ -88,6 +88,11 @@ struct PlotLayout {
/// no text backend there is no title to lay out at all and both stay 0.0f. /// no text backend there is no title to lay out at all and both stay 0.0f.
f32 y_title_width = 0.0f; f32 y_title_width = 0.0f;
f32 x_title_height = 0.0f; f32 x_title_height = 0.0f;
/// The empty band kept between `axes`' right edge and the viewport's, 0.0f unless a
/// caller asked for one (see `Plot::set_right_gutter()`). Reported because it is
/// clamped by the same cap as the strips above, so what was asked for and what was
/// reserved are not always the same number.
f32 right_gutter = 0.0f;
/// The measured legend: inside `axes`, never reserving any part of it, and empty /// The measured legend: inside `axes`, never reserving any part of it, and empty
/// when there is nothing to draw. It is measured with the same text backend the /// when there is nothing to draw. It is measured with the same text backend the
/// strips above are, and against the same `axes` rect the caller reads back, so /// strips above are, and against the same `axes` rect the caller reads back, so
@@ -101,8 +106,8 @@ struct PlotLayout {
/// where a pointer can grab it cannot drift apart. /// where a pointer can grab it cannot drift apart.
std::vector<RegionLayout> regions; std::vector<RegionLayout> regions;
/// The crosshair's geometry, when one is active: the two lines' stroke boxes, the /// The crosshair's geometry, when one is active: the two lines' stroke boxes, the
/// readout panel and one row per visible trace with the interpolated value the pointer /// readout panel and one row per visible trace with the value of the sample nearest the
/// is over. Placed here rather than derived by the renderer, exactly as the legend and /// pointer. Placed here rather than derived by the renderer, exactly as the legend and
/// the regions are -- and, like them, entirely inside `axes`: the panel is flipped and /// the regions are -- and, like them, entirely inside `axes`: the panel is flipped and
/// clamped against the rect it was measured for, so nothing of the readout can be drawn /// clamped against the rect it was measured for, so nothing of the readout can be drawn
/// over a label strip or into a figure's neighbouring row. /// over a label strip or into a figure's neighbouring row.
@@ -229,6 +234,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 +484,46 @@ 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);
/// A one-line caption for the x axis, drawn as **chrome** in the bottom-left corner of the
/// axes with the legend's own backplate behind it. It exists for the thing a shifted origin
/// leaves unsaid: labels counted from a window's left edge are only readable beside the value
/// that edge has, and the reader adds the two. An `Annotation` cannot serve -- its anchor is a
/// data coordinate, so the caption crawls across the plot under a pan and drifts under a zoom,
/// which is the one thing a legend-like panel must not do. The text is the host's, because only
/// the host knows how much precision the sum needs: the axis' own label form carries three
/// significant figures, which is exactly what made the shifted origin necessary.
/// Empty (the default) draws nothing.
void set_x_note(std::string text);
const std::string& x_note() const noexcept { return x_note_; }
/// 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
@@ -593,6 +645,20 @@ class Plot {
} }
f32 min_y_gutter() const noexcept { return min_y_gutter_; } f32 min_y_gutter() const noexcept { return min_y_gutter_; }
/// An empty band reserved between `axes`' right edge and the viewport's, in device
/// pixels. 0.0 -- the default -- is what the layout has always done: `axes` reaches
/// the viewport's right edge, which leaves the rightmost x tick label, *centred* on
/// that edge, half of it to be drawn in and nothing to draw it in -- and puts the
/// newest sample's ink on the frame. It is the mirror of the left gutter in purpose
/// and not in kind: nothing is drawn here, so there is nothing to measure and the
/// value is the caller's to choose. Reserved inside the same cap as the label and
/// title strips, so it can no more squeeze `axes` below the half-viewport floor than
/// they can. Negative values are clamped to 0.
void set_right_gutter(f32 device_px) noexcept {
right_gutter_ = device_px > 0.0f ? device_px : 0.0f;
}
f32 right_gutter() const noexcept { return right_gutter_; }
/// Grid segments for the primary axis pair (x axis 0, y axis 0), in data space. /// Grid segments for the primary axis pair (x axis 0, y axis 0), in data space.
const Grid& grid() const noexcept { return grid_; } const Grid& grid() const noexcept { return grid_; }
@@ -653,20 +719,31 @@ class Plot {
/// dash, which live on `CrosshairLayout` because they depend on `ui_scale`. /// dash, which live on `CrosshairLayout` because they depend on `ui_scale`.
const CrosshairStyle& crosshair_style() const noexcept { return crosshair_.style; } const CrosshairStyle& crosshair_style() const noexcept { return crosshair_.style; }
/// Chooses what a readout's numbers *are*: one of the series' own samples, or a value
/// computed between two of them. `CrosshairValue` argues the two cases and says where each
/// puts the marker; the default is `NearestSample`.
///
/// Deliberately **not** part of `set_crosshair()`, like the readout switch and the style
/// override: it is a statement about the data, so a pointer move must not be able to change
/// it. It takes effect on the next `layout()` and reaches `crosshair_value()` immediately.
void set_crosshair_value_mode(CrosshairValue mode) noexcept { crosshair_.value_mode = mode; }
/// The readout's value mode, as set -- see `set_crosshair_value_mode()`.
CrosshairValue crosshair_value_mode() const noexcept { return crosshair_.value_mode; }
/// The crosshair's state: where it is, which lines it draws and whether the readout is /// The crosshair's state: where it is, which lines it draws and whether the readout is
/// on. Read-only -- `set_crosshair()` and `clear_crosshair()` are how it moves -- so the /// on. Read-only -- `set_crosshair()` and `clear_crosshair()` are how it moves -- so the
/// coordinates it holds and the geometry `layout()` places from them cannot disagree. /// coordinates it holds and the geometry `layout()` places from them cannot disagree.
const Crosshair& crosshair() const noexcept { return crosshair_; } const Crosshair& crosshair() const noexcept { return crosshair_; }
/// The **interpolated** value of `trace`'s series at a data `x`: `crosshair_span()` /// The value this plot's readout would report for `trace` at a data `x`: the nearer
/// between the two adjacent samples, then `crosshair_interpolate()` -- the same lookup /// bracketing sample's y, or the blend of the two, according to
/// the readout's rows are built from, exposed for a host that wants the number for /// `crosshair_value_mode()` -- the same lookup the readout's rows are built from, exposed
/// itself (a status line, a tooltip of its own). /// for a host that wants the number for itself (a status line, a tooltip of its own).
/// ///
/// False, leaving `out_y` untouched, for an id that names no trace, a trace with no /// False, leaving `out_y` untouched, for an id that names no trace, a trace with no
/// series, and an `x` the series has no sample at -- the cases `CrosshairSpan::valid` /// series, and an `x` the series has no sample at -- the cases `CrosshairSpan::valid`
/// documents, plus the trace itself. Never a clamped or nearest-sample answer: see /// documents, plus the trace itself. Never a clamped answer: see `crosshair_span()` for why
/// `crosshair_span()` for why an out-of-window x is not a value. /// an out-of-window x is not a value.
/// ///
/// O(1) and allocation-free once the plot has looked a value up before: the span is /// O(1) and allocation-free once the plot has looked a value up before: the span is
/// index arithmetic and the samples are read through one `Series::gather()` of at most /// index arithmetic and the samples are read through one `Series::gather()` of at most
@@ -674,6 +751,17 @@ class Plot {
/// on every pointer move -- once per visible trace, so nothing here may scan a series. /// on every pointer move -- once per visible trace, so nothing here may scan a series.
bool crosshair_value(u32 trace, f64 x, f64& out_y) const; bool crosshair_value(u32 trace, f64 x, f64& out_y) const;
/// The whole **point** the readout reports, x as well as y: `crosshair_value()`'s number
/// together with the data x it belongs to. In `CrosshairValue::NearestSample` that x is the
/// quoted sample's own, which is generally not the `x` asked for; in `Interpolated` it is
/// `x` itself.
///
/// This is what places `CrosshairRow::marker`, and it is public because a host that draws
/// instruments of its own -- a cursor pinned to the value it reads, an annotation naming the
/// sample -- needs the same snap, and recomputing it would be a second rule that can drift
/// from this one. Same failure cases, same cost, same scratch buffer as `crosshair_value()`.
bool crosshair_point(u32 trace, f64 x, f64& out_x, f64& out_y) const;
private: private:
/// One measure plus its cache. The dirty flag is what keeps measurement() from /// One measure plus its cache. The dirty flag is what keeps measurement() from
/// re-evaluating every frame; `last_series`, `last_size` and `last_x0` catch a /// re-evaluating every frame; `last_series`, `last_size` and `last_x0` catch a
@@ -781,7 +869,7 @@ class Plot {
/// never names a trace. /// never names a trace.
void build_legend(); void build_legend();
/// Measures the crosshair into `layout_.crosshair`: the two lines' stroke boxes, one row /// Measures the crosshair into `layout_.crosshair`: the two lines' stroke boxes, one row
/// per visible trace with its interpolated value and marker, and the readout panel -- /// per visible trace with its nearest sample's value and marker, and the readout panel --
/// placed beside the crosshair, flipped where it would cross an edge and clamped, so /// placed beside the crosshair, flipped where it would cross an edge and clamped, so
/// every rect it produces is inside `axes`. A plot with no visible trace still gets its /// every rect it produces is inside `axes`. A plot with no visible trace still gets its
/// lines: the rows are the readout's, not the crosshair's. /// lines: the rows are the readout's, not the crosshair's.
@@ -791,11 +879,16 @@ class Plot {
render::Style style_{}; render::Style style_{};
const render::TextBackend* text_ = nullptr; const render::TextBackend* text_ = nullptr;
Legend legend_{}; Legend legend_{};
/// The x axis' corner caption; see set_x_note(). Empty draws nothing.
std::string x_note_;
/// Which parts of the bottom x chrome are reserved at all; see set_x_strip_mode(). /// Which parts of the bottom x chrome are reserved at all; see set_x_strip_mode().
XStrip x_strip_ = XStrip::Both; XStrip x_strip_ = XStrip::Both;
/// The floor a figure imposes on the left gutter; see set_min_y_gutter(). 0.0 means /// The floor a figure imposes on the left gutter; see set_min_y_gutter(). 0.0 means
/// none, which is what a plot standing on its own keeps. /// none, which is what a plot standing on its own keeps.
f32 min_y_gutter_ = 0.0f; f32 min_y_gutter_ = 0.0f;
/// The empty band kept to the right of `axes`; see set_right_gutter(). 0.0 means none,
/// which is what the layout did before the setter existed.
f32 right_gutter_ = 0.0f;
/// Which handles a pointer is on, published by the input layer (see HotHandles). /// Which handles a pointer is on, published by the input layer (see HotHandles).
HotHandles hot_{}; HotHandles hot_{};
+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
+11 -4
View File
@@ -445,11 +445,12 @@ class Renderer {
/// `origin_x` back in, in f64, before the single f32 narrowing (B1), so a /// `origin_x` back in, in f64, before the single f32 narrowing (B1), so a
/// large-magnitude x -- an epoch axis -- never loses sub-pixel precision. /// large-magnitude x -- an epoch axis -- never loses sub-pixel precision.
void stage_segment(f64 origin_x, f64 x0, f64 y0, f64 x1, f64 y1, bool log_x, bool log_y); void stage_segment(f64 origin_x, f64 x0, f64 y0, f64 x1, f64 y1, bool log_x, bool log_y);
/// Stages `n` interleaved `x,y` points of a gathered window (see /// Stages `n` interleaved `x,y` points of a gathered window (see `Series::gather`): x
/// `Series::gather`): x **relative to the series' `x0()`**, y absolute. The /// **relative to `origin_x`**, which the caller asked the gather for, y absolute. On linear
/// absolute data x is reconstructed in f64 (`series_x0 + rel`) and staged as /// axes that is already what the batch wants and the buffer is handed over as it is; a log
/// axis reconstructs the absolute x in f64 per segment and stages
/// `to_space(x_abs) - to_space(origin_x)` -- O(visible pixels), not O(epoch). /// `to_space(x_abs) - to_space(origin_x)` -- O(visible pixels), not O(epoch).
void stage_xy(const f32* xy, u32 n, f64 series_x0, f64 origin_x, bool log_x, bool log_y); void stage_xy(const f32* xy, u32 n, f64 origin_x, bool log_x, bool log_y);
/// Lays one run of text out at a framebuffer position and stages it into /// Lays one run of text out at a framebuffer position and stages it into
/// `batch`, shifting it into viewport space and tinting it `color` (the /// `batch`, shifting it into viewport space and tinting it `color` (the
/// per-instance `a_colour`). Returns the pen past the run. /// per-instance `a_colour`). Returns the pen past the run.
@@ -534,6 +535,12 @@ class Renderer {
/// them again, so the panel a test asserts and the panel this draws are the same /// them again, so the panel a test asserts and the panel this draws are the same
/// numbers. /// numbers.
void draw_legend(const Plot& plot, const PlotLayout& layout); void draw_legend(const Plot& plot, const PlotLayout& layout);
/// `Plot::x_note()`, in the bottom-left corner of the axes on the legend's backplate. It is
/// measured here rather than in `Plot::layout()` on purpose: unlike a legend it reserves no
/// space and nothing can be picked on it, so there is no measurement a host or a test needs to
/// read back, and a caption that explains the x labels must not be able to move the layout the
/// labels were measured into.
void draw_x_note(const Plot& plot, const PlotLayout& layout);
/// The backplate the legend and the crosshair's readout share: the frame's background at /// The backplate the legend and the crosshair's readout share: the frame's background at
/// `kLegendPanelAlpha` over `panel`, with a one-device-pixel border in `border` around the /// `kLegendPanelAlpha` over `panel`, with a one-device-pixel border in `border` around the
/// outermost pixels the fill covers (the crisp-hairline rule -- a border drawn on the measured /// outermost pixels the fill covers (the crisp-hairline rule -- a border drawn on the measured
+50 -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);
@@ -75,6 +86,21 @@ struct Style {
f32 crosshair_width = 0.75f; f32 crosshair_width = 0.75f;
f32 crosshair_dash_on = 4.0f; f32 crosshair_dash_on = 4.0f;
f32 crosshair_dash_off = 4.0f; f32 crosshair_dash_off = 4.0f;
/// The full extent, in logical pixels, of the little cross drawn at each trace's readout
/// point (`CrosshairRow::marker`), in that trace's own colour.
///
/// **Deliberately larger than `marker_px`**, which is what a trace's own samples are dotted
/// with at a deep zoom. In the default `CrosshairValue::NearestSample` the readout's mark is
/// *snapped to a sample*, so it lands among those dots -- at the same size it would read as
/// one more sample instead of as "this is the one the panel is quoting". The lines are chrome
/// and recede; this mark is the answer to the question the hover asks, and it is the one
/// crosshair element that should not.
///
/// Its stroke is `crosshair_marker_width`, thicker than the lines' for the same reason. `0`
/// across draws no mark at all, which is how a caller that wants the panel without the marks
/// says so.
f32 crosshair_marker_px = 9.0f;
f32 crosshair_marker_width = 1.5f;
// --- the scale ---------------------------------------------------------- // --- the scale ----------------------------------------------------------
@@ -96,10 +122,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
+2 -2
View File
@@ -75,9 +75,9 @@ Range ArraySeries::y_bounds() const { return y_bounds_; }
const MinMaxPyramid& ArraySeries::pyramid() const { return pyramid_; } const MinMaxPyramid& ArraySeries::pyramid() const { return pyramid_; }
void ArraySeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, void ArraySeries::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode) const { GatherMode mode) const {
gather_impl(level, begin, end, out_xy, mode); gather_impl(level, begin, end, x_origin, out_xy, mode);
} }
void ArraySeries::copy_raw(u64 begin, u64 end, f32* out) const { void ArraySeries::copy_raw(u64 begin, u64 end, f32* out) const {
+15 -11
View File
@@ -176,21 +176,24 @@ u64 Series::snap_index(f64 x) const {
return static_cast<u64>(k); return static_cast<u64>(k);
} }
void Series::gather_raw(u64 begin, u64 end, std::vector<f32>& out_xy) const { void Series::gather_raw(u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy) const {
const f64 step = dx(); const f64 step = dx();
// Sample i sits at `x0() + i*step`, so this is `x_at(i) - x_origin` with the subtraction done
// once in f64 rather than per sample: see gather() for why the caller owns the origin.
const f64 shift = x0() - x_origin;
f32 raw[kGatherChunk]; f32 raw[kGatherChunk];
for (u64 i = begin; i < end;) { for (u64 i = begin; i < end;) {
const u64 take = std::min<u64>(kGatherChunk, end - i); const u64 take = std::min<u64>(kGatherChunk, end - i);
copy_raw(i, i + take, raw); copy_raw(i, i + take, raw);
for (u64 k = 0; k < take; ++k) { for (u64 k = 0; k < take; ++k) {
out_xy.push_back(f32(f64(i + k) * step)); // x relative to x0(): see gather() out_xy.push_back(f32(shift + f64(i + k) * step)); // x relative to x_origin: see gather()
out_xy.push_back(raw[k]); out_xy.push_back(raw[k]);
} }
i += take; i += take;
} }
} }
void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, void Series::gather_impl(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode) const { GatherMode mode) const {
const u64 n = size(); const u64 n = size();
const u64 b = std::min(begin, n); const u64 b = std::min(begin, n);
@@ -198,7 +201,7 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
if (b >= e) return; if (b >= e) return;
if (level == 0) { if (level == 0) {
gather_raw(b, e, out_xy); gather_raw(b, e, x_origin, out_xy);
return; return;
} }
// Public levels clamp to the deepest representable one (R3): past // Public levels clamp to the deepest representable one (R3): past
@@ -226,6 +229,7 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
// picks, so the envelope path stays O(buckets) and the geometry still spans // picks, so the envelope path stays O(buckets) and the geometry still spans
// the requested range exactly, for every mode. // the requested range exactly, for every mode.
const f64 step = dx(); const f64 step = dx();
const f64 shift = x0() - x_origin; // as in gather_raw(): x_at(i) - x_origin = shift + i*step
const u64 span = u64{kBucket} << level; const u64 span = u64{kBucket} << level;
const u64 origin = pyramid().window_base(); const u64 origin = pyramid().window_base();
const u64 lbase = pyramid().level_base(level); const u64 lbase = pyramid().level_base(level);
@@ -235,11 +239,11 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
if (first >= last) { if (first >= last) {
// No complete bucket of this level is inside the range -- a window squeezed // No complete bucket of this level is inside the range -- a window squeezed
// into one bucket -- so the raw samples are the only exact geometry there is. // into one bucket -- so the raw samples are the only exact geometry there is.
gather_raw(b, e, out_xy); gather_raw(b, e, x_origin, out_xy);
return; return;
} }
const u64 lead = first * span - origin; // window index the first emitted bucket starts at const u64 lead = first * span - origin; // window index the first emitted bucket starts at
if (b < lead) gather_raw(b, std::min(lead, e), out_xy); if (b < lead) gather_raw(b, std::min(lead, e), x_origin, out_xy);
switch (mode) { switch (mode) {
case GatherMode::Envelope: { case GatherMode::Envelope: {
@@ -252,9 +256,9 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
const MinMax m = pyramid().bucket(level, bi); const MinMax m = pyramid().bucket(level, bi);
const u64 first_sample = bi * span - origin; // >= 0: stored buckets start inside the window const u64 first_sample = bi * span - origin; // >= 0: stored buckets start inside the window
const u64 last_sample = std::min((bi + 1) * span, origin + e) - 1 - origin; const u64 last_sample = std::min((bi + 1) * span, origin + e) - 1 - origin;
out_xy.push_back(f32(f64(first_sample) * step)); // x relative to x0() out_xy.push_back(f32(shift + f64(first_sample) * step)); // x relative to x_origin
out_xy.push_back(m.min); out_xy.push_back(m.min);
out_xy.push_back(f32(f64(last_sample) * step)); out_xy.push_back(f32(shift + f64(last_sample) * step));
out_xy.push_back(m.max); out_xy.push_back(m.max);
} }
break; break;
@@ -269,7 +273,7 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
const u64 centre = bi * span + span / 2 - origin; // inside [b, e): the bucket is wholly inside const u64 centre = bi * span + span / 2 - origin; // inside [b, e): the bucket is wholly inside
f32 v = 0.0f; f32 v = 0.0f;
copy_raw(centre, centre + 1, &v); copy_raw(centre, centre + 1, &v);
out_xy.push_back(f32(f64(centre) * step)); // x relative to x0() out_xy.push_back(f32(shift + f64(centre) * step)); // x relative to x_origin
out_xy.push_back(v); out_xy.push_back(v);
} }
break; break;
@@ -283,7 +287,7 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
const u32 count = pyramid().bucket_count(level, bi); const u32 count = pyramid().bucket_count(level, bi);
if (count == 0) continue; if (count == 0) continue;
const u64 centre = bi * span + span / 2 - origin; const u64 centre = bi * span + span / 2 - origin;
out_xy.push_back(f32(f64(centre) * step)); // x relative to x0() out_xy.push_back(f32(shift + f64(centre) * step)); // x relative to x_origin
out_xy.push_back(f32(pyramid().bucket_sum(level, bi) / f64(count))); out_xy.push_back(f32(pyramid().bucket_sum(level, bi) / f64(count)));
} }
break; break;
@@ -291,7 +295,7 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
} }
const u64 trail = last * span - origin; // one past the last emitted bucket's span const u64 trail = last * span - origin; // one past the last emitted bucket's span
if (trail < e) gather_raw(trail, e, out_xy); if (trail < e) gather_raw(trail, e, x_origin, out_xy);
} }
} // namespace plot::data } // namespace plot::data
+2 -2
View File
@@ -87,9 +87,9 @@ Range StreamSeries::y_bounds() const { return y_bounds_; }
const MinMaxPyramid& StreamSeries::pyramid() const { return pyramid_; } const MinMaxPyramid& StreamSeries::pyramid() const { return pyramid_; }
void StreamSeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, void StreamSeries::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode) const { GatherMode mode) const {
gather_impl(level, begin, end, out_xy, mode); gather_impl(level, begin, end, x_origin, out_xy, mode);
} }
void StreamSeries::copy_raw(u64 begin, u64 end, f32* out) const { void StreamSeries::copy_raw(u64 begin, u64 end, f32* out) const {
+4 -4
View File
@@ -100,16 +100,16 @@ u64 XYSeries::snap_index(f64 x) const {
: hi; // ties go to the lower index : hi; // ties go to the lower index
} }
void XYSeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, void XYSeries::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode) const { GatherMode mode) const {
const u64 n = size(); const u64 n = size();
const u64 b = std::min(begin, n); const u64 b = std::min(begin, n);
const u64 e = std::min(std::max(end, b), n); const u64 e = std::min(std::max(end, b), n);
if (b >= e) return; if (b >= e) return;
// x emitted relative to x0(): see Series::gather(). The x array is f64, so // x emitted relative to the caller's origin: see Series::gather(). The x array is f64, so
// the subtraction happens wide and only the small, exact offset narrows. // the subtraction happens wide and only the small, exact offset narrows.
const f64 origin = x0(); const f64 origin = x_origin;
if (level == 0) { if (level == 0) {
for (u64 i = b; i < e; ++i) { for (u64 i = b; i < e; ++i) {
@@ -127,7 +127,7 @@ void XYSeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
// `begin`, `last` one past the last bucket wholly before `end`, and the two // `begin`, `last` one past the last bucket wholly before `end`, and the two
// edge spans around them are read from the raw arrays -- so no emitted // edge spans around them are read from the raw arrays -- so no emitted
// geometry comes from outside [begin, end), and the emitted x values are the // geometry comes from outside [begin, end), and the emitted x values are the
// array's own coordinates relative to x0(). // array's own coordinates relative to the caller's origin.
const u64 span = u64{kBucket} << level; const u64 span = u64{kBucket} << level;
const u64 lend = pyramid().level_size(level); const u64 lend = pyramid().level_size(level);
const u64 first = std::min(ceil_div(b, span), lend); const u64 first = std::min(ceil_div(b, span), lend);
+13 -1
View File
@@ -323,18 +323,28 @@ void Controller::on_key(const KeyEvent& e) {
if (zooms) { if (zooms) {
const f64 notches = e.key == kKeyZoomIn ? kKeyZoomNotches : -kKeyZoomNotches; const f64 notches = e.key == kKeyZoomIn ? kKeyZoomNotches : -kKeyZoomNotches;
const f64 factor = zoom_factor(notches); const f64 factor = zoom_factor(notches);
// Ctrl restricts the zoom to x, Alt to y; bare +/- zooms both. (Shift cannot be
// used: '+' is shift+'=' on common layouts, so key_from() folds them together and
// a bare zoom-in already arrives with Mod::Shift set.) Ctrl+Alt zooms neither,
// which is harmless and needs no special case.
const bool only_x = has_mod(e.mods, Mod::Ctrl);
const bool only_y = has_mod(e.mods, Mod::Alt);
if (!only_y) {
for (u32 i = 0; i < plot_->x_axis_count(); ++i) { for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
const Axis& a = plot_->x_axis(i); const Axis& a = plot_->x_axis(i);
const f64 lo = to_space(a.range.min, a.log); const f64 lo = to_space(a.range.min, a.log);
const f64 hi = to_space(a.range.max, a.log); const f64 hi = to_space(a.range.max, a.log);
zoom_x_axis(i, from_space(0.5 * (lo + hi), a.log), factor); zoom_x_axis(i, from_space(0.5 * (lo + hi), a.log), factor);
} }
}
if (!only_x) {
for (u32 i = 0; i < plot_->y_axis_count(); ++i) { for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
const Axis& a = plot_->y_axis(i); const Axis& a = plot_->y_axis(i);
const f64 lo = to_space(a.range.min, a.log); const f64 lo = to_space(a.range.min, a.log);
const f64 hi = to_space(a.range.max, a.log); const f64 hi = to_space(a.range.max, a.log);
zoom_y_axis(i, from_space(0.5 * (lo + hi), a.log), factor); zoom_y_axis(i, from_space(0.5 * (lo + hi), a.log), factor);
} }
}
} else if (fits) { } else if (fits) {
plot_->auto_fit_x(); plot_->auto_fit_x();
for (u32 i = 0; i < plot_->y_axis_count(); ++i) plot_->auto_fit_y(i); for (u32 i = 0; i < plot_->y_axis_count(); ++i) plot_->auto_fit_y(i);
@@ -925,7 +935,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
+18 -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;
@@ -440,6 +451,10 @@ const FigureLayout& Figure::layout() {
// not drawn once per row. A figure told to label every row keeps them everywhere, // not drawn once per row. A figure told to label every row keeps them everywhere,
// and then there is no shared axis to speak of: each row has its own. // and then there is no shared axis to speak of: each row has its own.
p.set_x_strip_mode(labels_bottom_only_ && row != bottom_row ? XStrip::None : XStrip::Both); p.set_x_strip_mode(labels_bottom_only_ && row != bottom_row ? XStrip::None : XStrip::Both);
// Before either pass below, and the same value for every row: nothing is measured on
// the right, so unlike the left gutter this needs no measurement pass -- the rows end
// on a common column because they are all told the one width.
p.set_right_gutter(right_gutter_);
} }
// After the viewports, so a row's ticks and grid -- built from its axes rect -- and // After the viewports, so a row's ticks and grid -- built from its axes rect -- and
// its transforms describe this frame's rects; and before the rows lay themselves // its transforms describe this frame's rects; and before the rows lay themselves
+115 -18
View File
@@ -306,6 +306,57 @@ 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;
}
void Plot::set_x_note(std::string text) { x_note_ = std::move(text); }
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
@@ -988,12 +1039,21 @@ const PlotLayout& Plot::layout() {
x_height = std::min(x_height, x_budget); x_height = std::min(x_height, x_budget);
const f32 y_title_width = std::min(y_title_w, y_budget - y_width); const f32 y_title_width = std::min(y_title_w, y_budget - y_width);
const f32 x_title_height = std::min(x_title_h, x_budget - x_height); const f32 x_title_height = std::min(x_title_h, x_budget - x_height);
// The right-hand band, out of what is left of the same allowance the two left
// strips were served from -- so the three together cannot take more than
// `1 - kMinAxesShare` of the width however much each asks for. Nothing is drawn
// in it, so unlike the strips above it is a want and never a measurement, and it
// cannot feed the fixed point below: the y labels are chosen from the row's range
// and height, so a narrower `axes` does not change what they need.
const f32 right_gutter =
std::min(right_gutter_, y_budget - (y_width + y_title_width));
layout_.y_axis_width = y_width; layout_.y_axis_width = y_width;
layout_.x_axis_height = x_height; layout_.x_axis_height = x_height;
layout_.y_title_width = y_title_width; layout_.y_title_width = y_title_width;
layout_.x_title_height = x_title_height; layout_.x_title_height = x_title_height;
layout_.right_gutter = right_gutter;
layout_.axes = Rect{viewport_.x + y_width + y_title_width, viewport_.y, layout_.axes = Rect{viewport_.x + y_width + y_title_width, viewport_.y,
vw - (y_width + y_title_width), vw - (y_width + y_title_width + right_gutter),
vh - (x_height + x_title_height)}; vh - (x_height + x_title_height)};
layout_.y_title_area = Rect{viewport_.x, layout_.axes.y, y_title_width, layout_.axes.h}; layout_.y_title_area = Rect{viewport_.x, layout_.axes.y, y_title_width, layout_.axes.h};
layout_.y_label_area = layout_.y_label_area =
@@ -1033,7 +1093,7 @@ const PlotLayout& Plot::layout() {
build_regions(); build_regions();
build_legend(); build_legend();
// The crosshair last: its panel is fitted into `axes` and its lines are held inside it, and // The crosshair last: its panel is fitted into `axes` and its lines are held inside it, and
// its rows are the interpolated values at a data x -- so it wants the transforms the // its rows are the sample values nearest a data x -- so it wants the transforms the
// settled layout pins, exactly as the legend and the regions do. // settled layout pins, exactly as the legend and the regions do.
build_crosshair(); build_crosshair();
return layout_; return layout_;
@@ -1303,13 +1363,13 @@ void Plot::build_regions() {
CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept { CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept {
CrosshairSpan span; CrosshairSpan span;
const u64 n = series.size(); const u64 n = series.size();
// No samples and no finite query: nothing to interpolate between. // No samples and no finite query: nothing to bracket.
if (n == 0u || !std::isfinite(x)) return span; if (n == 0u || !std::isfinite(x)) return span;
// The bracketing samples, straight from the series (`neighbours()`/`x_at()`): // The bracketing samples, straight from the series (`neighbours()`/`x_at()`):
// the window is closed at both ends -- the oldest and the newest sample are // the window is closed at both ends -- the oldest and the newest sample are
// samples too -- and a query outside it has no neighbours to interpolate // samples too -- and a query outside it has no neighbours at all. Answering
// between. Answering with the nearest sample's value instead is the clamped lie // with the nearest sample's value instead is the clamped lie
// this refuses: the readout would report a number as if it were under the // this refuses: the readout would report a number as if it were under the
// pointer when the series does not reach that far. The series owns where its // pointer when the series does not reach that far. The series owns where its
// samples are, so an XY series is bracketed by its own x array exactly, where // samples are, so an XY series is bracketed by its own x array exactly, where
@@ -1322,8 +1382,8 @@ CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept {
// A query that landed **on** a sample is a span of one, whichever side the // A query that landed **on** a sample is a span of one, whichever side the
// series' bracket reported: a sample at the query is the answer, and the two // series' bracket reported: a sample at the query is the answer, and the two
// ends of the window are samples too -- a bracket of (0, 1) at a query sitting // ends of the window are samples too -- a bracket of (0, 1) at a query sitting
// on sample 0 would read as an interpolation with no width. Decided on the // on sample 0 would read as a span with a width it does not have. Decided on
// samples' own coordinates, so it holds for any `neighbours()` implementation. // the samples' own coordinates, so it holds for any `neighbours()` impl.
if (series.x_at(i0) == x) { if (series.x_at(i0) == x) {
span.begin = span.end = i0; span.begin = span.end = i0;
span.t = 0.0; span.t = 0.0;
@@ -1338,16 +1398,16 @@ CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept {
} }
if (i0 == i1) { if (i0 == i1) {
// The series bracketed a single sample for a query that is not on it: nothing // The series bracketed a single sample for a query that is not on it: there
// to interpolate with, and the sample is the honest answer. // is no second sample to choose between, and this one is the honest answer.
span.begin = span.end = i0; span.begin = span.end = i0;
span.t = 0.0; span.t = 0.0;
} else { } else {
span.begin = i0; span.begin = i0;
span.end = i1; span.end = i1;
// `t` from the samples' own x coordinates, so the interpolation is exact on // `t` from the samples' own x coordinates, so "which is nearer" is exact on
// any grid, uniform or not. A degenerate pair (two samples sharing an x) // any grid, uniform or not. A degenerate pair (two samples sharing an x)
// interpolates nothing and reads the earlier sample. // reads the earlier sample.
const f64 xa = series.x_at(i0); const f64 xa = series.x_at(i0);
const f64 xb = series.x_at(i1); const f64 xb = series.x_at(i1);
span.t = xb > xa ? (x - xa) / (xb - xa) : 0.0; span.t = xb > xa ? (x - xa) / (xb - xa) : 0.0;
@@ -1361,6 +1421,13 @@ CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept {
return span; return span;
} }
f64 crosshair_interpolate(f64 y_begin, f64 y_end, f64 t) noexcept {
// This form, not `(1 - t) * a + t * b`: at `t == 0` it is `y_begin` and at `t == 1` it is
// `y_end`, both exactly, so a query that landed on a sample reads that sample back whatever
// mode is in force. The symmetric form rounds at both ends.
return y_begin + t * (y_end - y_begin);
}
std::string crosshair_unnamed_label(u32 trace) { std::string crosshair_unnamed_label(u32 trace) {
char buf[16]; char buf[16];
std::snprintf(buf, sizeof buf, "#%u", static_cast<unsigned>(trace)); std::snprintf(buf, sizeof buf, "#%u", static_cast<unsigned>(trace));
@@ -1390,6 +1457,11 @@ void Plot::set_crosshair(f64 x, f64 y, bool active) {
} }
bool Plot::crosshair_value(u32 trace, f64 x, f64& out_y) const { bool Plot::crosshair_value(u32 trace, f64 x, f64& out_y) const {
f64 ignored = 0.0;
return crosshair_point(trace, x, ignored, out_y);
}
bool Plot::crosshair_point(u32 trace, f64 x, f64& out_x, f64& out_y) const {
if (trace >= traces_.size()) return false; if (trace >= traces_.size()) return false;
const data::Series* series = traces_[trace].series; const data::Series* series = traces_[trace].series;
if (series == nullptr) return false; if (series == nullptr) return false;
@@ -1401,13 +1473,32 @@ bool Plot::crosshair_value(u32 trace, f64 x, f64& out_y) const {
// when it fell between them. The scratch is cleared and refilled rather than reallocated, // when it fell between them. The scratch is cleared and refilled rather than reallocated,
// and sized for two pairs from construction, so the pointer path allocates nothing. // and sized for two pairs from construction, so the pointer path allocates nothing.
crosshair_scratch_.clear(); crosshair_scratch_.clear();
series->gather(0u, span.begin, span.end + 1u, crosshair_scratch_); // The origin is the query itself: only y is read out of the pairs below -- the x comes back
// through `x_at()` -- so this names the one origin that cannot lose precision for a caller
// that might.
series->gather(0u, span.begin, span.end + 1u, x, crosshair_scratch_);
const usize pairs = static_cast<usize>(span.end - span.begin + 1u); const usize pairs = static_cast<usize>(span.end - span.begin + 1u);
if (crosshair_scratch_.size() != 2u * pairs) return false; // a series that produced nothing if (crosshair_scratch_.size() != 2u * pairs) return false; // a series that produced nothing
const f64 y_begin = static_cast<f64>(crosshair_scratch_[1u]); const f64 y_begin = static_cast<f64>(crosshair_scratch_[1u]);
const f64 y_end = static_cast<f64>(crosshair_scratch_[2u * pairs - 1u]); const f64 y_end = static_cast<f64>(crosshair_scratch_[2u * pairs - 1u]);
if (crosshair_.value_mode == CrosshairValue::Interpolated) {
// The signal the samples imply *at* this x, so the point is the query's own x: that is
// where an interpolated number lives, and the marker on the polyline there is honest
// about it.
out_x = x;
out_y = crosshair_interpolate(y_begin, y_end, span.t); out_y = crosshair_interpolate(y_begin, y_end, span.t);
return true; return true;
}
// `NearestSample`: the nearer of the two, and **its own x with it**. `span.t` is measured on
// the samples' own coordinates, so "nearer" is exact on any grid; a tie reads the earlier
// sample, which is the bias `crosshair_span()` already takes for a degenerate pair. The x
// comes back through `x_at()` rather than from the gathered pairs, which are relative to a
// caller-named origin by the `Series::gather()` contract -- adding it back here would be a
// second reconstruction of a coordinate the series will hand over directly.
const bool take_begin = span.t <= 0.5;
out_x = series->x_at(take_begin ? span.begin : span.end);
out_y = take_begin ? y_begin : y_end;
return true;
} }
void Plot::build_crosshair() { void Plot::build_crosshair() {
@@ -1475,17 +1566,23 @@ void Plot::build_crosshair() {
row.trace = i; row.trace = i;
row.valid = false; row.valid = false;
row.listed = false; row.listed = false;
row.x = 0.0;
row.y = 0.0; row.y = 0.0;
row.marker = Vec2{}; row.marker = Vec2{};
row.swatch = Rect{}; row.swatch = Rect{};
row.label_box = Rect{}; row.label_box = Rect{};
row.value_box = Rect{}; row.value_box = Rect{};
row.label.assign(trace.name.empty() ? crosshair_unnamed_label(i) : trace.name); row.label.assign(trace.name.empty() ? crosshair_unnamed_label(i) : trace.name);
// The value: the interpolated number where the trace has a sample at this x, and the em dash // The value where the trace has one at this x, and the em dash where it does not. The row is
// where it does not. The row is listed either way, so the absence has to be *said* -- a blank // listed either way, so the absence has to be *said* -- a blank value cell reads as a value
// value cell reads as a value that has not arrived -- and the dash is measured into the panel's // that has not arrived -- and the dash is measured into the panel's width like any other
// width like any other value, so the row's boxes are placed by one rule. // value, so the row's boxes are placed by one rule.
if (crosshair_value(i, crosshair_.x, row.y)) { //
// The **point**, not just the number: `crosshair_point()` reports the x the value belongs to
// as well, which in the default `NearestSample` mode is the quoted sample's own and not the
// pointer's. Both coordinates come from that one call, so the mark and the text cannot
// disagree about which datum is being read (see `CrosshairRow::marker`).
if (crosshair_point(i, crosshair_.x, row.x, row.y)) {
row.valid = true; row.valid = true;
char value[32]; char value[32];
// The trace's *own* y axis formats the number, so a readout reads exactly like the // The trace's *own* y axis formats the number, so a readout reads exactly like the
@@ -1494,7 +1591,7 @@ void Plot::build_crosshair() {
row.value.assign(value); row.value.assign(value);
// Placed through the trace's own axis pair, so the marker sits where that trace is // Placed through the trace's own axis pair, so the marker sits where that trace is
// actually drawn however its axes are scaled. // actually drawn however its axes are scaled.
row.marker = Vec2{static_cast<f32>(x_transform(trace.axis_x).to_screen_x(crosshair_.x)), row.marker = Vec2{static_cast<f32>(x_transform(trace.axis_x).to_screen_x(row.x)),
static_cast<f32>(y_transform(trace.axis_y).to_screen_y(row.y))}; static_cast<f32>(y_transform(trace.axis_y).to_screen_y(row.y))};
} else { } else {
row.value.assign(kCrosshairNoValue); row.value.assign(kCrosshairNoValue);
+41 -10
View File
@@ -98,15 +98,42 @@ void format_time(f64 seconds, char* buf, usize n) {
const char* const sign = seconds < 0.0 ? "-" : ""; const char* const sign = seconds < 0.0 ? "-" : "";
const f64 t = std::fabs(seconds); const f64 t = std::fabs(seconds);
if (t < 1.0) { // `0.9995` and not `1.0`: three significant figures of 0.9999 s are `1e+03ms`, so a value
// Sub-second: milliseconds. The 999.5 cut keeps %.3g out of scientific // that *rounds* to a second belongs to the whole-second branch below, which writes it `1s`.
// notation (it would print "1e+03ms") right below the one-second boundary. // The same spill one rung down is handled inside the ladder.
const f64 ms = t * 1000.0; if (t < 0.9995) {
if (ms < 999.5) { // Sub-second: the prefix that keeps the mantissa in [1, 1000) -- the same rule the SI
std::snprintf(buf, n, "%s%.3gms", sign, ms); // formatter uses on a y axis, and the reading a zoomed-in window needs. A fixed
} else { // millisecond unit printed `0.005ms` where `5us` is what the axis is showing, and
std::snprintf(buf, n, "%s%.3gs", sign, t); // `1e-05ms` where `10ns` is. The micro sign is the UTF-8 sequence the embedded font has a
// glyph for (see `format_si`), not a Latin-1 byte.
// Exactly zero carries no unit, the same convention `format_si` states: every prefixed
// form of it is "0" with a unit the value does not have.
if (t == 0.0) {
std::snprintf(buf, n, "0");
return;
} }
static const struct {
f64 scale;
const char* unit;
} kUnits[] = {
{1.0e-3, "ms"},
{1.0e-6, "\u00b5s"},
{1.0e-9, "ns"},
{1.0e-12, "ps"},
};
// The rung is picked by the *rounded* mantissa, which is what `format_si`'s own 999.5
// check buys there: 0.9999 ms is `999.9µs` before rounding and `1e+03µs` after, so it is
// the millisecond rung's value and reads `1ms`. Testing the raw `t < u.scale` let the
// mantissa reach 1000 at every rung boundary.
for (const auto& u : kUnits) {
if (t < u.scale * 0.9995) 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 +158,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 +185,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
+5
View File
@@ -9,12 +9,17 @@
namespace plot::render { namespace plot::render {
namespace { namespace {
#ifndef NDEBUG
/// Two declared cells agree when neither size differs by more than the atlas' /// Two declared cells agree when neither size differs by more than the atlas'
/// tolerance: the test `glyph_is_cell()` makes, for a cell against a cell. /// tolerance: the test `glyph_is_cell()` makes, for a cell against a cell.
///
/// Guarded because its one caller is an `assert` (see `TextBatch::set_cell`), so a
/// release build has no use for it and would warn that it is unused.
bool same_cell(Vec2 a, Vec2 b) noexcept { bool same_cell(Vec2 a, Vec2 b) noexcept {
const auto near = [](f32 p, f32 q) { return std::fabs(p - q) <= kAtlasCellTolerance; }; const auto near = [](f32 p, f32 q) { return std::fabs(p - q) <= kAtlasCellTolerance; };
return near(a.x, b.x) && near(a.y, b.y); return near(a.x, b.x) && near(a.y, b.y);
} }
#endif
/// `axis` as a unit vector: the cell's local +x, which is what the instance and /// `axis` as a unit vector: the cell's local +x, which is what the instance and
/// the shader both take (see `TextInstance`). A zero, infinite or NaN direction /// the shader both take (see `TextInstance`). A zero, infinite or NaN direction
+155 -55
View File
@@ -9,6 +9,7 @@
#include <plot/render/detail/text_scale.hpp> #include <plot/render/detail/text_scale.hpp>
#include <algorithm> #include <algorithm>
#include <cstring>
#include <cmath> #include <cmath>
#include <cstdio> #include <cstdio>
#include <cstdlib> #include <cstdlib>
@@ -42,15 +43,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;
@@ -541,6 +533,7 @@ void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) {
draw_measures(plot, layout); draw_measures(plot, layout);
draw_annotations(plot); draw_annotations(plot);
draw_legend(plot, layout); draw_legend(plot, layout);
draw_x_note(plot, layout);
// The under text batch goes out here, so the readout's panel below it can // The under text batch goes out here, so the readout's panel below it can
// occlude what it covers: legend names, cursor labels, measure labels and // occlude what it covers: legend names, cursor labels, measure labels and
// annotation captions are all chrome the panel is allowed to sit on. // annotation captions are all chrome the panel is allowed to sit on.
@@ -743,24 +736,16 @@ void Renderer::stage_segment(f64 origin_x, f64 x0, f64 y0, f64 x1, f64 y1, bool
static_cast<f32>(space(y1, log_y))}); static_cast<f32>(space(y1, log_y))});
} }
void Renderer::stage_xy(const f32* xy, u32 n, f64 series_x0, f64 origin_x, bool log_x, void Renderer::stage_xy(const f32* xy, u32 n, f64 origin_x, bool log_x, bool log_y) {
bool log_y) {
if (!log_x && !log_y) { if (!log_x && !log_y) {
// The common case: reconstruct the absolute data x in f64 (`x0 + rel` -- // The common case costs nothing at all: the gather emitted x relative to this very origin
// the gather contract's own reconstruction), stage it relative to the // (`draw_traces` asks it for `x_range.min`, which is the draw's origin too) and a linear y
// draw's origin, and the f32 is O(visible pixels), not O(epoch). // needs no mapping, so the gathered buffer *is* the staged one.
staged_.clear(); lines_.add_polyline_xy(xy, n);
staged_.reserve(usize(n) * 2u);
const f64 origin_s = space(origin_x, log_x);
for (u32 i = 0; i < n; ++i) {
staged_.push_back(static_cast<f32>(xy[2u * i] + series_x0 - origin_s));
staged_.push_back(xy[2u * i + 1u]);
}
lines_.add_polyline_xy(staged_.data(), n);
return; return;
} }
for (u32 i = 0; i + 1u < n; ++i) { for (u32 i = 0; i + 1u < n; ++i) {
stage_segment(origin_x, xy[2u * i] + series_x0, xy[2u * i + 1u], xy[2u * i + 2u] + series_x0, stage_segment(origin_x, xy[2u * i] + origin_x, xy[2u * i + 1u], xy[2u * i + 2u] + origin_x,
xy[2u * i + 3u], log_x, log_y); xy[2u * i + 3u], log_x, log_y);
} }
} }
@@ -807,7 +792,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 +802,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 +988,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.
@@ -1013,24 +1018,85 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
: 0u; : 0u;
gather_.clear(); gather_.clear();
series.gather(level, first, last, gather_, trace.mode); // The visible range's minimum is the gather origin: the emitted f32 is then O(the span on
// screen), so its ULP is a fixed 2^-24 of the axis at every zoom. `series.x0()` -- the origin
// before -- is the start of the *retained window*, so the quantisation was a fraction of data
// the view need not even hold, and a deep zoom drew a sample visibly off the x a readout
// quotes for it. See `Series::gather()`.
series.gather(level, first, last, x_range.min, gather_, trace.mode);
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 = x_range.min + 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 = static_cast<f32>(xt.to_screen_x(x_abs));
const f32 sy = static_cast<f32>(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 {
++stats_.lod_traces; ++stats_.lod_traces;
} }
// Gathered x is relative to `series.x0()` (the gather contract); the staging // Gathered x is relative to `x_range.min` (the gather contract, with the origin this
// reconstructs the absolute data x in f64 and emits `to_space(x_abs) - // function asked for); the staging reconstructs the absolute data x in f64 and emits
// to_space(origin)` -- the visible x range's minimum is the natural origin // `to_space(x_abs) - to_space(origin)` about that same origin, so the f32 is O(visible
// for a trace -- so the f32 is O(visible pixels), not O(epoch) (B1). The // pixels), not O(epoch) (B1). The mapping folds the origin back into the offset uniform,
// mapping folds the origin back into the offset uniform, in f64. // in f64.
const bool log_x = plot.x_axis(trace.axis_x).log; const bool log_x = plot.x_axis(trace.axis_x).log;
const bool log_y = plot.y_axis(trace.axis_y).log; const bool log_y = plot.y_axis(trace.axis_y).log;
const Mapping map = mapping(plot, trace.axis_x, trace.axis_y, x_range.min); const Mapping map = mapping(plot, trace.axis_x, trace.axis_y, x_range.min);
// Keep the longest **increasing suffix** of the gathered points, and drop what precedes it.
//
// A ring-backed series may answer a *clamped* stamp for the oldest slots of its window -- the ones
// the producer has already overwritten. The clamp is deliberate (the alternative was a sentinel
// that collapsed the whole index mapping), but it makes the stamps *non-increasing at the front*:
// the first few points carry the newest stamp, and everything after them is older. A polyline
// through that draws a segment from the newest sample back to the oldest -- a line across the
// whole panel, which is what a saturated ring shows as a flicker.
//
// A forward filter cannot express this: the bad points are at the *front* and everything behind
// them is legitimately smaller, so dropping "anything that does not advance" would delete the
// trace. Walking backwards keeps each point that is strictly older than the one kept after it,
// which stops at the first unreadable slot and leaves the real samples alone. The cost is one
// pass over the visible points, which the staging below is about to walk anyway.
{
const usize count = gather_.size() / 2u;
usize kept = 0;
f64 next = std::numeric_limits<f64>::infinity();
for (usize k = count; k > 0; --k) {
const usize p = k - 1u; // the point index; `i` above is the trace's
const f64 x = static_cast<f64>(gather_[2u * p]) + x_range.min;
if (!(x < next)) continue; // out of order with what follows: unreadable, and a NaN too
next = x;
gather_[2u * (count - 1u - kept)] = gather_[2u * p];
gather_[2u * (count - 1u - kept) + 1u] = gather_[2u * p + 1u];
++kept;
}
if (kept != count) {
// The kept points were written back-to-front into the tail; move them down to the front.
std::memmove(gather_.data(), gather_.data() + 2u * (count - kept), 2u * kept * sizeof(f32));
gather_.resize(2u * kept);
}
}
const u32 points = static_cast<u32>(gather_.size() / 2u); const u32 points = static_cast<u32>(gather_.size() / 2u);
// A trace's dash rides on the same per-draw uniform the crosshair uses, in // A trace's dash rides on the same per-draw uniform the crosshair uses, in
@@ -1045,7 +1111,7 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
// trace/axis hierarchy on a high-DPI panel. `width == 0` is scatter: the // trace/axis hierarchy on a high-DPI panel. `width == 0` is scatter: the
// markers below are the whole trace. // markers below are the whole trace.
if (trace.width > 0.0f) { if (trace.width > 0.0f) {
stage_xy(gather_.data(), points, series.x0(), x_range.min, log_x, log_y); stage_xy(gather_.data(), points, x_range.min, log_x, log_y);
flush_lines(map, trace.color, style_.device(trace.width), dash); flush_lines(map, trace.color, style_.device(trace.width), dash);
} }
@@ -1063,7 +1129,7 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
const f64 origin_s = space(x_range.min, log_x); const f64 origin_s = space(x_range.min, log_x);
u32 staged = 0; u32 staged = 0;
for (u32 p = 0; p < points; p += stride) { for (u32 p = 0; p < points; p += stride) {
const f64 x_abs = static_cast<f64>(gather_[2u * p]) + series.x0(); const f64 x_abs = static_cast<f64>(gather_[2u * p]) + x_range.min;
const f32 rx = static_cast<f32>(space(x_abs, log_x) - origin_s); const f32 rx = static_cast<f32>(space(x_abs, log_x) - origin_s);
const f32 ry = static_cast<f32>(space(gather_[2u * p + 1u], log_y)); const f32 ry = static_cast<f32>(space(gather_[2u * p + 1u], log_y));
const Vec2 centre{rx * map.scale.x + map.offset.x, ry * map.scale.y + map.offset.y}; const Vec2 centre{rx * map.scale.x + map.offset.x, ry * map.scale.y + map.offset.y};
@@ -1106,7 +1172,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 +1201,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);
} }
@@ -1332,6 +1401,28 @@ void Renderer::draw_legend(const Plot& plot, const PlotLayout& layout) {
} }
} }
void Renderer::draw_x_note(const Plot& plot, const PlotLayout& layout) {
const std::string& text = plot.x_note();
if (text.empty()) return;
const Rect& axes = layout.axes;
const f32 line_h = font_backend_.height();
const f32 width = font_backend_.measure(text.c_str());
if (!(width > 0.0f) || !(line_h > 0.0f)) return;
// The pad is the legend's, so the two panels read as the same kind of chrome.
const f32 pad = style_.device(4.0f);
const Rect panel{axes.x + pad, axes.bottom() - pad - line_h - 2.0f * pad, width + 2.0f * pad,
line_h + 2.0f * pad};
// A sliver of axes gets no caption rather than one hanging over the frame: the panel is chrome
// *inside* the axes, and a backplate crossing the axis line would read as a second plot.
if (panel.w > axes.w || panel.h > axes.h) return;
draw_readout_panel(panel, style_.axis_line);
// Bottom-left, beside the labels it qualifies -- the reader adds the caption to a tick, so it
// belongs at the end of the tick row and not in a corner of its own. `glyphs_under_` because
// the panel is flushed with the frame's line geometry and the under-text batch goes out after
// it: text staged into the over batch would be drawn before its own backplate.
stage_text(glyphs_under_, {panel.x + pad, panel.y + pad}, text.c_str(), style_.axis_text);
}
void Renderer::stage_marker(plot::Marker marker, Vec2 centre_px, f32 size_px) { void Renderer::stage_marker(plot::Marker marker, Vec2 centre_px, f32 size_px) {
// Marker shapes as line segments, in viewport pixels: the trace's flush maps // Marker shapes as line segments, in viewport pixels: the trace's flush maps
// them with `pixel_mapping()`, so the size is device pixels whatever the data // them with `pixel_mapping()`, so the size is device pixels whatever the data
@@ -1451,19 +1542,28 @@ void Renderer::draw_crosshair_lines(const Plot& plot, const PlotLayout& layout)
flush_lines(data, style_.crosshair, stroke, crosshair.dash); flush_lines(data, style_.crosshair, stroke, crosshair.dash);
} }
// A small cross at each trace's intersection, in the trace's own colour -- opaque, like a // A cross at each trace's readout point, in the trace's own colour -- opaque, like a legend's
// legend's swatch, so a trace drawn at 15% still has a marker that can be seen. The arm // swatch, so a trace drawn at 15% still has a mark that can be seen. The position is the
// length is a logical size like every other marker's, and the position is the layout's: a // layout's, and in the default `CrosshairValue::NearestSample` that is the *quoted sample*
// marker for a sample far outside the visible y range is staged where the trace is and the // rather than the pointer's column: a mark for a sample outside the visible range is staged
// axes clip keeps it inside, rather than being skipped by a range test of this layer's own. // where that sample is and the axes clip keeps it inside, rather than being skipped by a range
const f32 arm = style_.device(3.0f); // test of this layer's own.
//
// `crosshair_marker_px` is the cross' full extent and is larger than `marker_px`, which dots a
// trace's own samples at a deep zoom -- a snapped mark lands among those dots and has to read
// as the one the panel is quoting. `0` across draws nothing, so a caller can have the panel
// and the lines without the marks.
const f32 arm = 0.5f * style_.device(style_.crosshair_marker_px);
const f32 mark_stroke = style_.device(style_.crosshair_marker_width);
if (arm > 0.0f) {
for (const CrosshairRow& row : crosshair.rows) { for (const CrosshairRow& row : crosshair.rows) {
if (!row.valid) continue; if (!row.valid) continue;
Color color = plot.trace(row.trace).color; Color color = plot.trace(row.trace).color;
color.a = 1.0f; color.a = 1.0f;
lines_.add_segment({row.marker.x - arm, row.marker.y}, {row.marker.x + arm, row.marker.y}); lines_.add_segment({row.marker.x - arm, row.marker.y}, {row.marker.x + arm, row.marker.y});
lines_.add_segment({row.marker.x, row.marker.y - arm}, {row.marker.x, row.marker.y + arm}); lines_.add_segment({row.marker.x, row.marker.y - arm}, {row.marker.x, row.marker.y + arm});
flush_lines(pixel_mapping(), color, style_.device(1.0f)); flush_lines(pixel_mapping(), color, mark_stroke);
}
} }
} }
+94
View File
@@ -263,6 +263,27 @@ PLOT_TEST(key_minus_zooms_out) {
PLOT_CHECK(f.plot.x_range(f.ax).span() > 100.0); PLOT_CHECK(f.plot.x_range(f.ax).span() > 100.0);
} }
PLOT_TEST(ctrl_plus_zooms_only_x) {
Fixture f;
f.ay = f.plot.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
const f64 x_before = f.plot.x_range(f.ax).span();
const f64 y_before = f.plot.y_range(f.ay).span();
f.controller.on_key({plot::input::kKeyZoomIn, kCtrl, true});
PLOT_CHECK(f.plot.x_range(f.ax).span() < x_before);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).span(), y_before, 1e-12);
}
PLOT_TEST(alt_plus_zooms_only_y) {
Fixture f;
f.ay = f.plot.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
constexpr plot::u32 kAlt = static_cast<plot::u32>(plot::input::Mod::Alt);
const f64 x_before = f.plot.x_range(f.ax).span();
const f64 y_before = f.plot.y_range(f.ay).span();
f.controller.on_key({plot::input::kKeyZoomIn, kAlt, true});
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), x_before, 1e-12);
PLOT_CHECK(f.plot.y_range(f.ay).span() < y_before);
}
/// A host can hand the controller a coordinate that is not a number: ImGui reports /// A host can hand the controller a coordinate that is not a number: ImGui reports
/// (-FLT_MAX, -FLT_MAX) for "no mouse position", and any host can produce a NaN out /// (-FLT_MAX, -FLT_MAX) for "no mouse position", and any host can produce a NaN out
/// of a degenerate mapping of its own. The pan arithmetic runs through the axis /// of a degenerate mapping of its own. The pan arithmetic runs through the axis
@@ -1788,6 +1809,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 +2371,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);
}
+69 -11
View File
@@ -425,6 +425,52 @@ PLOT_TEST(figure_gutter_never_squeezes_the_axes_below_the_floor) {
} }
} }
/// The other side of the same band. `axes` reached the viewport's right edge, which left
/// the rightmost x tick label -- centred on it -- half outside the plot, so a figure can
/// ask for an empty band there. One value for every row, like the left gutter, because
/// the rows' axes have to end on a common column as well as start on one.
PLOT_TEST(figure_rows_share_one_right_gutter) {
plot::Figure figure = different_magnitude_figure();
figure.layout();
const plot::f32 flush = figure.plot(0u).layout().axes.right();
PLOT_CHECK_NEAR(static_cast<plot::f64>(figure.right_gutter()), 0.0, 1e-12);
// The default is the layout as it always was: nothing between the data and the frame.
PLOT_CHECK_NEAR(static_cast<plot::f64>(flush), static_cast<plot::f64>(kW), 1e-4);
figure.set_right_gutter(30.0f);
figure.layout();
const plot::Rect first = figure.plot(0u).layout().axes;
for (plot::u32 row = 0; row < 3u; ++row) {
const plot::PlotLayout& rl = figure.plot(row).layout();
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.right_gutter), 30.0, 1e-4);
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.axes.right()), static_cast<plot::f64>(kW) - 30.0,
1e-4);
// The band comes off the right alone: the left gutter the rows agreed on is untouched,
// so a host can balance the two sides without the one moving when it sets the other.
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.axes.x), static_cast<plot::f64>(first.x), 1e-4);
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.axes.w), static_cast<plot::f64>(first.w), 1e-4);
// ... and the x strip follows the data area rather than the viewport, or the shared
// axis would be labelled over a band that holds no data.
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.x_label_area.w), static_cast<plot::f64>(rl.axes.w),
1e-4);
}
// Clamped by the same cap as the strips: a band wider than the whole figure cannot
// squeeze `axes` below the floor `Plot::layout()` guarantees.
figure.set_right_gutter(10.0f * kW);
figure.layout();
for (plot::u32 row = 0; row < 3u; ++row) {
const plot::Rect& axes = figure.plot(row).layout().axes;
PLOT_CHECK(axes.w >= 0.5f * kW - 1e-3f);
}
// Negative means "none", like the gutter and the row gap, and the data area goes back
// to the viewport's edge.
figure.set_right_gutter(-5.0f);
PLOT_CHECK_NEAR(static_cast<plot::f64>(figure.right_gutter()), 0.0, 1e-12);
figure.layout();
PLOT_CHECK_NEAR(static_cast<plot::f64>(figure.plot(0u).layout().axes.right()),
static_cast<plot::f64>(flush), 1e-4);
}
/// The degenerate neighbour: a figure of one row imposes exactly what that row /// The degenerate neighbour: a figure of one row imposes exactly what that row
/// measured, so it lays out as the plot would on its own -- the gutter cannot move a /// measured, so it lays out as the plot would on its own -- the gutter cannot move a
/// row that has no sibling to line up with. /// row that has no sibling to line up with.
@@ -844,20 +890,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);
} }
@@ -868,8 +923,8 @@ namespace {
/// The figure the crosshair cases hover: three rows at deliberately *different* sample rates on /// The figure the crosshair cases hover: three rows at deliberately *different* sample rates on
/// the one shared x, plus a fourth whose data does not reach the hovered x at all. Rows that /// the one shared x, plus a fourth whose data does not reach the hovered x at all. Rows that
/// sample at different rates are the point -- one x, three different interpolations -- and the /// sample at different rates are the point -- one x, and each row reporting a sample off its own
/// last row is the case a readout must not invent a value for. /// grid -- and the last row is the case a readout must not invent a value for.
plot::Figure crosshair_figure() { plot::Figure crosshair_figure() {
plot::Figure figure(4u); plot::Figure figure(4u);
figure.set_viewport(whole_figure()); figure.set_viewport(whole_figure());
@@ -897,7 +952,7 @@ plot::Figure crosshair_figure() {
/// Spec §15, the sync: `Figure::set_crosshair()` applies **one** data x to every row, so one /// Spec §15, the sync: `Figure::set_crosshair()` applies **one** data x to every row, so one
/// hover is one instant and N readouts rather than N independent ones. Rows at different sample /// hover is one instant and N readouts rather than N independent ones. Rows at different sample
/// rates therefore report *their own* interpolations of the same x -- the shared axis is x, and /// rates therefore report *their own* nearest sample to the same x -- the shared axis is x, and
/// each row's series is read on its own grid -- while a row whose series does not reach that x /// each row's series is read on its own grid -- while a row whose series does not reach that x
/// reports no value at all instead of a fabricated one. /// reports no value at all instead of a fabricated one.
PLOT_TEST(figure_crosshair_applies_one_x_to_every_row) { PLOT_TEST(figure_crosshair_applies_one_x_to_every_row) {
@@ -921,14 +976,15 @@ PLOT_TEST(figure_crosshair_applies_one_x_to_every_row) {
PLOT_CHECK(first.rows[0].label == "fast"); PLOT_CHECK(first.rows[0].label == "fast");
PLOT_CHECK_NEAR(first.rows[0].y, 30.0, 1e-9); PLOT_CHECK_NEAR(first.rows[0].y, 30.0, 1e-9);
// Row 1 samples every *two* units: x = 3 is halfway between sample 1 (y = 100) and sample 2 // Row 1 samples every *two* units: x = 3 is halfway between sample 1 (x = 2, y = 100) and
// (y = 200), so it reports 150 -- a different number for the same x, which is what makes this // sample 2 (x = 4, y = 200), which is the tie, so it reports sample 1's 100 -- a different
// case about the rows rather than about one series read four times. // number for the same x, which is what makes this case about the rows rather than about one
// series read four times. Not 150: a readout quotes a sample, and this row has none at x = 3.
const plot::CrosshairLayout& second = figure.plot(1u).layout().crosshair; const plot::CrosshairLayout& second = figure.plot(1u).layout().crosshair;
PLOT_CHECK(second.rows.size() == 1u); PLOT_CHECK(second.rows.size() == 1u);
PLOT_CHECK(second.rows[0].valid); PLOT_CHECK(second.rows[0].valid);
PLOT_CHECK(second.rows[0].label == "slow"); PLOT_CHECK(second.rows[0].label == "slow");
PLOT_CHECK_NEAR(second.rows[0].y, 150.0, 1e-9); PLOT_CHECK_NEAR(second.rows[0].y, 100.0, 1e-9);
PLOT_CHECK(second.rows[0].value != first.rows[0].value); PLOT_CHECK(second.rows[0].value != first.rows[0].value);
// Row 2's trace has no series yet: no row at all -- it is not a signal under the pointer. // Row 2's trace has no series yet: no row at all -- it is not a signal under the pointer.
@@ -963,7 +1019,9 @@ PLOT_TEST(figure_crosshair_applies_one_x_to_every_row) {
PLOT_CHECK_NEAR(figure.plot(row).crosshair().x, 1.0, 1e-12); PLOT_CHECK_NEAR(figure.plot(row).crosshair().x, 1.0, 1e-12);
} }
PLOT_CHECK_NEAR(figure.plot(0u).layout().crosshair.rows[0].y, 10.0, 1e-9); PLOT_CHECK_NEAR(figure.plot(0u).layout().crosshair.rows[0].y, 10.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(1u).layout().crosshair.rows[0].y, 50.0, 1e-9); // 0 + 100 * 0.5 // Not 50: x = 1 is the tie between this row's samples at x = 0 (y = 0) and x = 2 (y = 100),
// and a readout quotes one of them.
PLOT_CHECK_NEAR(figure.plot(1u).layout().crosshair.rows[0].y, 0.0, 1e-9);
PLOT_CHECK(!figure.plot(3u).layout().crosshair.rows[0].valid); PLOT_CHECK(!figure.plot(3u).layout().crosshair.rows[0].valid);
// On *every* row at once, and only the rows free it. // On *every* row at once, and only the rows free it.
+241 -36
View File
@@ -1939,11 +1939,11 @@ class CountingSeries final : public plot::data::Series {
} }
plot::Range y_bounds() const override { return {0.0, 99.0}; } plot::Range y_bounds() const override { return {0.0, 99.0}; }
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; } const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out, void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out,
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override { plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override {
++gather_calls_; ++gather_calls_;
samples_read_ += end - begin; samples_read_ += end - begin;
gather_impl(level, begin, end, out, mode); gather_impl(level, begin, end, x_origin, out, mode);
} }
u64 gather_calls() const noexcept { return gather_calls_; } u64 gather_calls() const noexcept { return gather_calls_; }
@@ -1965,20 +1965,21 @@ class CountingSeries final : public plot::data::Series {
} // namespace } // namespace
/// Spec §15, the lookup: a readout's y is **linear interpolation between the two samples /// Spec §15, the lookup: a readout's y is **the nearer of the two samples adjacent to the
/// adjacent to the crosshair's x**, and hand-computed here rather than checked against the /// crosshair's x**, never a value between them. Every y asserted here is therefore one of the
/// implementation's own arithmetic. The four corners of the rule are all asserted: a query /// series' own `tens` values -- a number *between* two of them, 15 or 22.5, is precisely what
/// between two samples, the two exact-sample ends of the window (where the span collapses onto /// this refuses, and what it used to return. The corners of the rule are all covered: a query
/// one sample), and a one-sample series, whose window is that sample alone. /// either side of the midpoint, a query exactly on it (the tie), the two exact-sample ends of
PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) { /// the window (where the span collapses onto one sample), and a one-sample series.
PLOT_TEST(crosshair_value_is_the_nearest_sample) {
CrosshairScene scene; CrosshairScene scene;
const u32 trace = scene.plot.add_trace( const u32 trace = scene.plot.add_trace(
{&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay}); {&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay});
// The span itself, for a query halfway between sample 1 (x = 1, y = 10) and sample 2 // The span itself, for a query halfway between sample 1 (x = 1, y = 10) and sample 2
// (x = 2, y = 20): the two neighbours, and the fraction between them. Asserting the span and // (x = 2, y = 20): the two neighbours, and the fraction between them. The span still reports
// not only the value is what pins the convention -- a nearest-sample lookup would report the // both and the fraction between them -- `t` is a *selector* now, not a blend factor, so the
// span's `t` as 0 or 1, and an average of a *range* would not report a span at all. // bracketing is unchanged and only what is built from it differs.
const plot::CrosshairSpan half = plot::crosshair_span(scene.series, 1.5); const plot::CrosshairSpan half = plot::crosshair_span(scene.series, 1.5);
PLOT_CHECK(half.valid); PLOT_CHECK(half.valid);
PLOT_CHECK(half.begin == 1u); PLOT_CHECK(half.begin == 1u);
@@ -1986,14 +1987,25 @@ PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) {
PLOT_CHECK_NEAR(half.t, 0.5, 1e-12); PLOT_CHECK_NEAR(half.t, 0.5, 1e-12);
f64 y = 0.0; f64 y = 0.0;
// Either side of the midpoint reads the sample it is nearer to, and *is* that sample: 10 and
// 20 are `tens` values, where an interpolation would have answered 11 and 19.
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.1, y));
PLOT_CHECK_NEAR(y, 10.0, 1e-9);
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.9, y));
PLOT_CHECK_NEAR(y, 20.0, 1e-9);
// Exactly halfway is the tie, and it reads the *earlier* sample -- the bias
// `crosshair_span()` already takes for a degenerate pair. 15.0 is the old answer.
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.5, y)); PLOT_CHECK(scene.plot.crosshair_value(trace, 1.5, y));
PLOT_CHECK_NEAR(y, 15.0, 1e-9); PLOT_CHECK_NEAR(y, 10.0, 1e-9);
// A quarter of the way through another pair: 20 + (30 - 20) * 0.25. // A quarter of the way through another pair: sample 2, not 22.5.
PLOT_CHECK(scene.plot.crosshair_value(trace, 2.25, y)); PLOT_CHECK(scene.plot.crosshair_value(trace, 2.25, y));
PLOT_CHECK_NEAR(y, 22.5, 1e-9); PLOT_CHECK_NEAR(y, 20.0, 1e-9);
// Just short of the next sample is that sample, so the readout steps rather than ramps.
PLOT_CHECK(scene.plot.crosshair_value(trace, 2.99, y));
PLOT_CHECK_NEAR(y, 30.0, 1e-9);
// The ends of the window are samples too, so a query exactly on one reads that sample -- // The ends of the window are samples too, so a query exactly on one reads that sample --
// and reads it *as* a span of one, with nothing to its left or right to interpolate with. // and reads it *as* a span of one, with no neighbour on the far side.
const plot::CrosshairSpan first = plot::crosshair_span(scene.series, 0.0); const plot::CrosshairSpan first = plot::crosshair_span(scene.series, 0.0);
PLOT_CHECK(first.valid); PLOT_CHECK(first.valid);
PLOT_CHECK(first.begin == 0u); PLOT_CHECK(first.begin == 0u);
@@ -2008,9 +2020,10 @@ PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) {
PLOT_CHECK(scene.plot.crosshair_value(trace, 3.0, y)); PLOT_CHECK(scene.plot.crosshair_value(trace, 3.0, y));
PLOT_CHECK_NEAR(y, 30.0, 1e-9); PLOT_CHECK_NEAR(y, 30.0, 1e-9);
// Outside the window there is no value at all -- not the nearest sample's, not the end of // Outside the window there is no value at all. The nearest-sample rule is a rule for an x the
// the window's, which are the clamped lies the header refuses -- and a series with no // series *brackets*; extending it past the ends would answer with the first or last sample for
// samples has no span at any x. // any x whatever, which is the clamped lie the header refuses. A series with no samples has no
// span at any x.
PLOT_CHECK(!plot::crosshair_span(scene.series, -1e-9).valid); PLOT_CHECK(!plot::crosshair_span(scene.series, -1e-9).valid);
PLOT_CHECK(!plot::crosshair_span(scene.series, 3.0 + 1e-9).valid); PLOT_CHECK(!plot::crosshair_span(scene.series, 3.0 + 1e-9).valid);
PLOT_CHECK(!scene.plot.crosshair_value(trace, 4.0, y)); PLOT_CHECK(!scene.plot.crosshair_value(trace, 4.0, y));
@@ -2023,7 +2036,7 @@ PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) {
PLOT_CHECK(!scene.plot.crosshair_value(/*trace=*/99u, 1.0, y)); // an id that names nothing PLOT_CHECK(!scene.plot.crosshair_value(/*trace=*/99u, 1.0, y)); // an id that names nothing
// A one-sample series: the sample itself is the only query it answers, and it answers it // A one-sample series: the sample itself is the only query it answers, and it answers it
// without a neighbour to interpolate against. // with no second sample to be nearer to.
plot::data::ArraySeries single(std::vector<f32>{7.0f}, 5.0, 1.0); plot::data::ArraySeries single(std::vector<f32>{7.0f}, 5.0, 1.0);
plot::Plot one; plot::Plot one;
one.add_x_axis({"t", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false}); one.add_x_axis({"t", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false});
@@ -2039,7 +2052,9 @@ PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) {
PLOT_CHECK(!one.crosshair_value(single_trace, 5.5, y)); PLOT_CHECK(!one.crosshair_value(single_trace, 5.5, y));
// The readout's row is the same lookup: the number a user reads off the panel is the value // The readout's row is the same lookup: the number a user reads off the panel is the value
// this test computed by hand, not a second derivation of it. // this test computed by hand, not a second derivation of it. x = 1.5 is the tie, so the row
// reads sample 1 -- and the formatted cell is `"10"`, where the interpolating readout showed
// `"15"`, a number the series does not contain.
scene.plot.set_crosshair(1.5, true); scene.plot.set_crosshair(1.5, true);
const plot::CrosshairLayout& crosshair = scene.plot.layout().crosshair; const plot::CrosshairLayout& crosshair = scene.plot.layout().crosshair;
PLOT_CHECK(crosshair.active); PLOT_CHECK(crosshair.active);
@@ -2049,8 +2064,136 @@ PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) {
// the figure's case pins). // the figure's case pins).
PLOT_CHECK(crosshair.rows.size() == 1u); PLOT_CHECK(crosshair.rows.size() == 1u);
PLOT_CHECK(crosshair.rows[0].valid); PLOT_CHECK(crosshair.rows[0].valid);
PLOT_CHECK_NEAR(crosshair.rows[0].y, 15.0, 1e-9); PLOT_CHECK_NEAR(crosshair.rows[0].y, 10.0, 1e-9);
PLOT_CHECK(crosshair.rows[0].value == "15"); PLOT_CHECK(crosshair.rows[0].value == "10");
}
/// The mark is **snapped to the sample the row quotes**, which is what makes it say *which*
/// value is being read. Placing it at the pointer's x with the sample's y put it on neither the
/// data nor the polyline, and the further a reader zoomed in the further off it sat -- a mark on
/// nothing.
///
/// The queries here are all **off** a sample on purpose, so "snapped" has something to be wrong
/// about: the assertion is that `row.x` is a `tens` grid position (0, 1, 2, 3) and not the query,
/// and that `row.marker` is that position mapped through the trace's own axes. The pixel column
/// is computed from the transform the plot already exposes, so this checks the two agree rather
/// than re-deriving the mapping.
PLOT_TEST(the_crosshair_marker_snaps_to_the_sample_it_reports) {
CrosshairScene scene;
const u32 trace = scene.plot.add_trace(
{&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay, "sine"});
// A query between samples 1 and 2, nearer 2: the point is sample 2 entire -- x = 2 as well as
// y = 20 -- so the mark lands on the datum and not a fifth of a division to its left.
f64 px = 0.0;
f64 py = 0.0;
PLOT_CHECK(scene.plot.crosshair_point(trace, 1.8, px, py));
PLOT_CHECK_NEAR(px, 2.0, 1e-12);
PLOT_CHECK_NEAR(py, 20.0, 1e-9);
// The tie takes the earlier sample, and takes *its* x with it: the same bias in both
// coordinates, so the mark and the number can never name different samples.
PLOT_CHECK(scene.plot.crosshair_point(trace, 1.5, px, py));
PLOT_CHECK_NEAR(px, 1.0, 1e-12);
PLOT_CHECK_NEAR(py, 10.0, 1e-9);
// `crosshair_value()` is the same call with the x dropped, so the two cannot disagree.
f64 y_only = 0.0;
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.5, y_only));
PLOT_CHECK_NEAR(y_only, py, 1e-12);
// A failing lookup leaves both outputs alone, as the value form does.
PLOT_CHECK(!scene.plot.crosshair_point(trace, 9.0, px, py));
PLOT_CHECK_NEAR(px, 1.0, 1e-12);
// And through the layout: the row carries the snapped x, and the marker is that x through the
// trace's own transform -- not the crosshair's column, which is where it used to be.
scene.plot.set_crosshair(1.8, true);
scene.plot.layout();
const plot::CrosshairRow& row = scene.plot.layout().crosshair.rows[0];
PLOT_CHECK(row.valid);
PLOT_CHECK_NEAR(row.x, 2.0, 1e-12);
PLOT_CHECK_NEAR(row.y, 20.0, 1e-9);
const f32 sample_col = static_cast<f32>(scene.plot.x_transform(scene.ax).to_screen_x(2.0));
const f32 pointer_col = static_cast<f32>(scene.plot.x_transform(scene.ax).to_screen_x(1.8));
PLOT_CHECK_NEAR(row.marker.x, sample_col, 1e-3);
PLOT_CHECK_NEAR(row.marker.y,
static_cast<f32>(scene.plot.y_transform(scene.ay).to_screen_y(20.0)), 1e-3);
// The two columns are far apart at this scale, so the check above is not satisfied by both
// being nearly the same number.
PLOT_CHECK(std::abs(row.marker.x - pointer_col) > 10.0f);
// The vertical line stays on the *pointer*: it is where the hover is, and only the mark moved.
PLOT_CHECK_NEAR(scene.plot.layout().crosshair.vertical.x +
0.5f * scene.plot.layout().crosshair.vertical.w,
pointer_col, 1e-3);
}
/// The other mode, and the reason it exists: `CrosshairValue::Interpolated` answers "what is the
/// signal *at* this instant" by blending the two bracketing samples, for a caller whose data is a
/// continuous quantity sampled finely enough that the line between two samples is a model it
/// believes. `NearestSample` is the default because that line is an artefact of plotting for most
/// sources -- see `CrosshairValue` -- but the question is about the data, so the library offers
/// both rather than picking for the caller.
///
/// Every number here is one the *other* mode refuses, so the two are pinned apart rather than
/// merely exercised: 15 at the tie, 11 and 19 either side of it. The marker moves with the value,
/// which is the mode's second half -- an interpolated y belongs at the pointer's x, on the
/// polyline, and nowhere else.
PLOT_TEST(crosshair_interpolated_mode_blends_the_two_samples) {
CrosshairScene scene;
const u32 trace = scene.plot.add_trace(
{&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay, "sine"});
PLOT_CHECK(scene.plot.crosshair_value_mode() == plot::CrosshairValue::NearestSample);
scene.plot.set_crosshair_value_mode(plot::CrosshairValue::Interpolated);
PLOT_CHECK(scene.plot.crosshair_value_mode() == plot::CrosshairValue::Interpolated);
f64 y = 0.0;
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.5, y));
PLOT_CHECK_NEAR(y, 15.0, 1e-9);
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.1, y));
PLOT_CHECK_NEAR(y, 11.0, 1e-9);
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.9, y));
PLOT_CHECK_NEAR(y, 19.0, 1e-9);
// A query *on* a sample reads that sample back exactly in this mode too: the span collapses to
// one index, `t` is 0, and `crosshair_interpolate()`'s form returns `y_begin` unrounded.
PLOT_CHECK(scene.plot.crosshair_value(trace, 2.0, y));
PLOT_CHECK(y == 20.0);
PLOT_CHECK(plot::crosshair_interpolate(10.0, 20.0, 0.0) == 10.0);
PLOT_CHECK(plot::crosshair_interpolate(10.0, 20.0, 1.0) == 20.0);
PLOT_CHECK_NEAR(plot::crosshair_interpolate(10.0, 20.0, 0.25), 12.5, 1e-12);
// Out of the window is still no value: interpolating needs two samples to be between.
PLOT_CHECK(!scene.plot.crosshair_value(trace, 4.0, y));
// The point is the *query's* x here, not a sample's, so the mark sits on the polyline where the
// number is -- the opposite placement from `NearestSample`, and the mode's visible half.
f64 px = 0.0;
f64 py = 0.0;
PLOT_CHECK(scene.plot.crosshair_point(trace, 1.8, px, py));
PLOT_CHECK_NEAR(px, 1.8, 1e-12);
PLOT_CHECK_NEAR(py, 18.0, 1e-9);
scene.plot.set_crosshair(1.8, true);
scene.plot.layout();
const plot::CrosshairRow& row = scene.plot.layout().crosshair.rows[0];
PLOT_CHECK_NEAR(row.x, 1.8, 1e-12);
PLOT_CHECK(row.value == "18");
const f32 pointer_col = static_cast<f32>(scene.plot.x_transform(scene.ax).to_screen_x(1.8));
PLOT_CHECK_NEAR(row.marker.x, pointer_col, 1e-3);
// Switching back is a plain state change, with no second placement to keep in step: the next
// layout reports the sample again.
scene.plot.set_crosshair_value_mode(plot::CrosshairValue::NearestSample);
scene.plot.layout();
PLOT_CHECK_NEAR(scene.plot.layout().crosshair.rows[0].x, 2.0, 1e-12);
PLOT_CHECK_NEAR(scene.plot.layout().crosshair.rows[0].y, 20.0, 1e-9);
}
/// The mark has to be **bigger than a trace's own sample dots**, because in the default mode it is
/// snapped onto one of them: at the same size it reads as one more sample rather than as the one
/// the panel is quoting. That is a property of the defaults, not of a drawing call, so it is
/// asserted where it is decided -- a later edit that tunes `marker_px` up past the crosshair's is
/// the regression, and nothing else in the library would notice.
PLOT_TEST(the_crosshair_mark_is_bigger_than_a_sample_dot) {
const plot::render::Style style;
PLOT_CHECK(style.crosshair_marker_px > style.marker_px);
// And heavier than the crosshair's own lines, which are chrome and recede on purpose.
PLOT_CHECK(style.crosshair_marker_width > style.crosshair_width);
} }
/// Spec §15, the readout's membership: **every visible trace gets a row, whether or not it is /// Spec §15, the readout's membership: **every visible trace gets a row, whether or not it is
@@ -2080,14 +2223,16 @@ PLOT_TEST(crosshair_readout_lists_every_visible_trace_including_unnamed_ones) {
PLOT_CHECK(crosshair.rows[1].label == plot::crosshair_unnamed_label(1u)); PLOT_CHECK(crosshair.rows[1].label == plot::crosshair_unnamed_label(1u));
PLOT_CHECK(crosshair.rows[1].label == "#1"); PLOT_CHECK(crosshair.rows[1].label == "#1");
// Both are listed with the value their own series has at x = 1.5, the swatch, the label's box // Both are listed with the value their own series has nearest x = 1.5, the swatch, the label's
// and the value's box -- all placed by the plot, all inside the panel it measured. // box and the value's box -- all placed by the plot, all inside the panel it measured. x = 1.5
// is the tie between samples 1 and 2, which reads the earlier one: 10, not the 15 an
// interpolating readout showed.
PLOT_CHECK(crosshair.panel.w > 0.0f && crosshair.panel.h > 0.0f); PLOT_CHECK(crosshair.panel.w > 0.0f && crosshair.panel.h > 0.0f);
for (const plot::CrosshairRow& row : crosshair.rows) { for (const plot::CrosshairRow& row : crosshair.rows) {
PLOT_CHECK(row.valid); PLOT_CHECK(row.valid);
PLOT_CHECK(row.listed); PLOT_CHECK(row.listed);
PLOT_CHECK_NEAR(row.y, 15.0, 1e-9); PLOT_CHECK_NEAR(row.y, 10.0, 1e-9);
PLOT_CHECK(row.value == "15"); PLOT_CHECK(row.value == "10");
PLOT_CHECK(row.swatch.x >= crosshair.panel.x); PLOT_CHECK(row.swatch.x >= crosshair.panel.x);
PLOT_CHECK(row.swatch.right() <= crosshair.panel.right()); PLOT_CHECK(row.swatch.right() <= crosshair.panel.right());
PLOT_CHECK(row.swatch.y >= crosshair.panel.y); PLOT_CHECK(row.swatch.y >= crosshair.panel.y);
@@ -2148,7 +2293,7 @@ PLOT_TEST(crosshair_readout_lists_every_visible_trace_including_unnamed_ones) {
PLOT_CHECK(on.rows[0].listed); PLOT_CHECK(on.rows[0].listed);
PLOT_CHECK(on.rows[1].listed); PLOT_CHECK(on.rows[1].listed);
PLOT_CHECK(on.panel.w > 0.0f && on.panel.h > 0.0f); PLOT_CHECK(on.panel.w > 0.0f && on.panel.h > 0.0f);
PLOT_CHECK(on.rows[0].value == "15"); PLOT_CHECK(on.rows[0].value == "10");
} }
/// Spec §15, the panel's placement: near the pointer, flipped over it where it would otherwise /// Spec §15, the panel's placement: near the pointer, flipped over it where it would otherwise
@@ -2253,11 +2398,14 @@ PLOT_TEST(crosshair_lookup_does_not_scan) {
p.add_y_axis({"v", {0.0, 99.0}, false, plot::AxisPlace::Left, false}); p.add_y_axis({"v", {0.0, 99.0}, false, plot::AxisPlace::Left, false});
const u32 trace = p.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u}); const u32 trace = p.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
// A query between two samples: 1234 % 100 = 34, the next is 35, halfway between them. // A query between two samples: 1234 % 100 = 34, the next is 35, and 1234.5 is the tie, which
// reads the earlier -- 34, where the interpolating readout answered 34.5. Both samples are
// still read: the bound below is on how much data the lookup touches, not on which end it
// then reports.
f64 y = 0.0; f64 y = 0.0;
const u64 before_reads = series.samples_read(); const u64 before_reads = series.samples_read();
PLOT_CHECK(p.crosshair_value(trace, 1234.5, y)); PLOT_CHECK(p.crosshair_value(trace, 1234.5, y));
PLOT_CHECK_NEAR(y, 34.5, 1e-9); PLOT_CHECK_NEAR(y, 34.0, 1e-9);
PLOT_CHECK(series.samples_read() - before_reads <= 2u); PLOT_CHECK(series.samples_read() - before_reads <= 2u);
PLOT_CHECK(series.gather_calls() <= 1u); PLOT_CHECK(series.gather_calls() <= 1u);
@@ -2288,7 +2436,7 @@ PLOT_TEST(crosshair_lookup_does_not_scan) {
const plot::CrosshairLayout& crosshair = p.layout().crosshair; const plot::CrosshairLayout& crosshair = p.layout().crosshair;
PLOT_CHECK(crosshair.rows.size() == 1u); PLOT_CHECK(crosshair.rows.size() == 1u);
PLOT_CHECK(crosshair.rows[0].valid); PLOT_CHECK(crosshair.rows[0].valid);
PLOT_CHECK_NEAR(crosshair.rows[0].y, 34.5, 1e-9); PLOT_CHECK_NEAR(crosshair.rows[0].y, 34.0, 1e-9);
PLOT_CHECK(series.samples_read() - reads <= 2u); PLOT_CHECK(series.samples_read() - reads <= 2u);
PLOT_CHECK(series.gather_calls() - calls <= 1u); PLOT_CHECK(series.gather_calls() - calls <= 1u);
// And the readout is not vacuous: the series it read two samples of really does hold a // And the readout is not vacuous: the series it read two samples of really does hold a
@@ -2323,9 +2471,9 @@ class XyFixture final : public plot::data::Series {
} }
plot::Range y_bounds() const override { return {0.0, 20.0}; } plot::Range y_bounds() const override { return {0.0, 20.0}; }
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; } const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out, void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out,
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override { plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override {
gather_impl(level, begin, end, out, mode); gather_impl(level, begin, end, x_origin, out, mode);
} }
f64 x_at(u64 i) const override { return x_[static_cast<std::size_t>(i)]; } f64 x_at(u64 i) const override { return x_[static_cast<std::size_t>(i)]; }
@@ -2511,9 +2659,12 @@ PLOT_TEST(scrolled_back_live_window_keeps_its_decades) {
} }
/// The XY routing, crosshair half: the span brackets the samples the *array* /// The XY routing, crosshair half: the span brackets the samples the *array*
/// names, and the interpolation fraction is measured on the samples' own positions /// names, and `t` is measured on the samples' own positions -- (5-1)/(10-1) over the
/// -- (5-1)/(10-1) over the wide gap, where a uniform-grid guess would have read /// wide gap, where a uniform-grid guess would have read samples that do not exist at
/// samples that do not exist at the positions it assumed. /// the positions it assumed. The gap is what makes the value worth asserting too:
/// x = 5 sits 4 from sample 1 and 5 from sample 2, so the nearest-sample rule has a
/// real choice to get right, and it only has that choice because `t` came from the
/// array.
PLOT_TEST(xy_series_crosshair_brackets_its_own_samples) { PLOT_TEST(xy_series_crosshair_brackets_its_own_samples) {
XyScene scene; XyScene scene;
const u32 trace = scene.plot.add_trace( const u32 trace = scene.plot.add_trace(
@@ -2526,7 +2677,11 @@ PLOT_TEST(xy_series_crosshair_brackets_its_own_samples) {
PLOT_CHECK_NEAR(span.t, 4.0 / 9.0, 1e-12); PLOT_CHECK_NEAR(span.t, 4.0 / 9.0, 1e-12);
f64 y = 0.0; f64 y = 0.0;
PLOT_CHECK(scene.plot.crosshair_value(trace, 5.0, y)); PLOT_CHECK(scene.plot.crosshair_value(trace, 5.0, y));
PLOT_CHECK_NEAR(y, 10.0 + 10.0 * (4.0 / 9.0), 1e-9); PLOT_CHECK_NEAR(y, 10.0, 1e-9); // sample 1, the nearer end of the gap
// And past the gap's midpoint it is the other sample, so the choice is `t`'s and not a
// fixed preference for one end.
PLOT_CHECK(scene.plot.crosshair_value(trace, 6.0, y));
PLOT_CHECK_NEAR(y, 20.0, 1e-9);
// On a sample, at both ends of the array's window, and outside it: the same // On a sample, at both ends of the array's window, and outside it: the same
// closed-window rule a uniform series follows, on the array's own coordinates. // closed-window rule a uniform series follows, on the array's own coordinates.
@@ -2584,3 +2739,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);
}
+750 -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, f64 x_origin, 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) - x_origin));
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,423 @@ 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();
}
/// A ring-backed series whose oldest slots read a *clamped* stamp gets no segment across the panel.
///
/// `ChannelBuffer`'s documented bargain: the published window is pinned while both rings slide under
/// it, so the oldest few slots of a saturated window point at time slots the producer has
/// overwritten and have to be clamped to the oldest *readable* one -- the alternative, a sentinel,
/// collapsed the whole index mapping. The effect on the polyline is that the stamps do not increase
/// at the *front*: the first points carry the newest stamp and everything behind them is older. Drawn
/// as they come, that is a segment from the newest sample back to the oldest: a line across the whole
/// panel, which is what "it connects the newest point with the oldest" looks like.
///
/// The trace here is flat at +1 except for those two unreadable points, which carry -1. Any ink in the
/// lower half of the axes is therefore the bad segment, and nothing else in the frame is that colour.
PLOT_TEST(a_clamped_front_does_not_draw_a_segment_across_the_panel) {
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));
/// Eight samples: indices 0 and 1 are the clamped front (newest stamp, low value), 2..7 walk 0..50
/// at +1.
struct ClampedFront final : plot::data::Series {
ClampedFront() {
pyramid_.reserve(8u);
for (u64 i = 0; i < 8u; ++i) pyramid_.append(i < 2u ? -1.0f : 1.0f);
}
u64 size() const override { return 8u; }
f64 x0() const override { return 0.0; }
f64 dx() const override { return 1.0; }
plot::Range x_bounds() const override { return {0.0, 60.0}; }
plot::Range y_bounds() const override { return {-1.0, 1.0}; }
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
f64 x_at(u64 i) const override { return i < 2u ? 60.0 : 10.0 * static_cast<f64>(i - 2u); }
bool neighbours(f64 x, u64& i0, u64& i1) const override {
if (x < 0.0 || x > 60.0) return false;
const u64 k = std::min<u64>(2u + static_cast<u64>(x / 10.0), 7u);
i0 = k;
i1 = k;
if (x_at(k) != x && k + 1u < 8u) i1 = k + 1u;
return true;
}
void gather(u32, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out,
plot::data::GatherMode) const override {
for (u64 i = begin; i < end; ++i) {
out.push_back(static_cast<f32>(x_at(i) - x_origin));
out.push_back(i < 2u ? -1.0f : 1.0f);
}
}
protected:
void copy_raw(u64 begin, u64 end, f32* out) const override {
for (u64 i = begin; i < end; ++i) out[i - begin] = i < 2u ? -1.0f : 1.0f;
}
private:
plot::data::MinMaxPyramid pyramid_;
} series;
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", {-5.0, 55.0}, false, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {-2.0, 2.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 plot::Rect& a = p.layout().axes;
plot::u32 below = 0;
plot::u32 on_the_line = 0;
for (plot::u32 cx = static_cast<plot::u32>(a.x) + 3u; cx + 3u < static_cast<plot::u32>(a.x + a.w);
++cx) {
for (plot::u32 row = static_cast<plot::u32>(a.h / 2u) + 2u;
row + 2u < static_cast<plot::u32>(a.h); ++row) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(cx, ctx.height - 1u - (static_cast<plot::u32>(a.y) + row), px));
if (green_pixel(px)) ++below;
}
}
for (plot::u32 cx = static_cast<plot::u32>(a.x) + 3u; cx + 3u < static_cast<plot::u32>(a.x + a.w);
++cx) {
for (plot::u32 row = static_cast<plot::u32>(a.h / 2u) - 2u;
row > 1u; --row) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(cx, ctx.height - 1u - (static_cast<plot::u32>(a.y) + row), px));
if (green_pixel(px)) ++on_the_line;
}
}
std::printf("[clamped-front] ink below the middle: %u, above: %u\n", below, on_the_line);
PLOT_CHECK(below == 0u); // the two clamped points' segments are nowhere
PLOT_CHECK(on_the_line > 100u); // and the real trace is still drawn
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;
@@ -2636,6 +3172,111 @@ PLOT_TEST(legend_does_not_cover_the_plot_when_placed_bottom_left) {
ctx.destroy(); ctx.destroy();
} }
/// `Plot::set_x_note()`'s caption is *chrome*: it sits in the bottom-left corner of the
/// axes on the legend's own backplate, and panning or zooming the x range does not move
/// it by a pixel. That is the whole reason it exists rather than an `Annotation`, whose
/// anchor is a data coordinate and so crawls under a pan and drifts under a zoom.
///
/// The caption's rect is deliberately not readable back -- it reserves no space and
/// nothing can be picked on it -- so the test measures it the only way a reader can see
/// it: the pixels a frame *with* the note differs from the same frame without one. The
/// bounding box of that difference is the caption's footprint, and the assertion is that
/// two completely different x ranges produce the *same* box.
PLOT_TEST(the_x_note_is_chrome_that_a_pan_or_a_zoom_does_not_move) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
plot::data::ArraySeries s(sine_samples(), 0.0, 1.0);
plot::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
PLOT_CHECK(p.add_trace({&s, plot::Color::hex(0x00E070, 1.0f), 1.5f, ax, ay, "signal"}) == 0u);
// The footprint of the note at one x range: the bounding box of the pixels that the
// caption, and only the caption, is responsible for.
const auto footprint = [&](const plot::Range& view, const char* dump) {
plot::Rect box{0.0f, 0.0f, 0.0f, 0.0f};
p.set_x_range(ax, view);
p.set_x_note("t0 = 1.23457e+06 s");
r.begin_frame(kW, kH, kFrameRect);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
dump_if_requested(r, dump);
const std::vector<plot::u8> with = read_frame(ctx);
p.set_x_note("");
r.begin_frame(kW, kH, kFrameRect);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
const std::vector<plot::u8> without = read_frame(ctx);
plot::u32 lo_x = kW, lo_y = kH, hi_x = 0, hi_y = 0, differing = 0;
for (plot::u32 y = 0; y < kH; ++y) {
for (plot::u32 x = 0; x < kW; ++x) {
const plot::usize at = (plot::usize(y) * kW + x) * 4u;
if (with[at] == without[at] && with[at + 1u] == without[at + 1u] &&
with[at + 2u] == without[at + 2u] && with[at + 3u] == without[at + 3u]) {
continue;
}
++differing;
lo_x = std::min(lo_x, x);
lo_y = std::min(lo_y, y);
hi_x = std::max(hi_x, x);
hi_y = std::max(hi_y, y);
}
}
// A comparison of two frames is only evidence if the caption was in one of them.
PLOT_CHECK(differing > 0u);
box = plot::Rect{static_cast<plot::f32>(lo_x), static_cast<plot::f32>(lo_y),
static_cast<plot::f32>(hi_x - lo_x + 1u),
static_cast<plot::f32>(hi_y - lo_y + 1u)};
return box;
};
const plot::Rect wide = footprint({0.0, 640.0}, "x_note.ppm");
// The corner: inside the axes, held off the left and bottom edges by a pad of a few
// pixels, and nowhere near the right-hand side or the top.
const plot::Rect axes = p.layout().axes;
PLOT_CHECK(wide.x > axes.x && wide.x < axes.x + 16.0f);
PLOT_CHECK(wide.bottom() <= axes.bottom() && wide.bottom() > axes.bottom() - 16.0f);
PLOT_CHECK(wide.right() < axes.x + 0.5f * axes.w); // a caption, not a banner
PLOT_CHECK(wide.h >= 8.0f && wide.h < 0.5f * axes.h);
// Real glyph ink on the backplate, so the box is a caption and not an empty panel. The
// frame has to be re-drawn *with* the note first: the measurement above left the
// note-less frame in the buffer, and `scan_rect_ink` reads the live one.
p.set_x_range(ax, {0.0, 640.0});
p.set_x_note("t0 = 1.23457e+06 s");
r.begin_frame(kW, kH, kFrameRect);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
PLOT_CHECK(scan_rect_ink(ctx, wide, r.style().axis_text).pixels >= 16u);
// The same caption under a pan to a different decade and a 64x zoom: an annotation
// anchored in data would have left the frame entirely, and the one here has not moved
// a pixel. The text is fixed across the three, since this case is about the position.
const plot::Rect panned = footprint({320.0, 960.0}, "x_note_panned.ppm");
const plot::Rect zoomed = footprint({300.0, 310.0}, "x_note_zoomed.ppm");
PLOT_CHECK_NEAR(panned.x, wide.x, 1e-4);
PLOT_CHECK_NEAR(panned.y, wide.y, 1e-4);
PLOT_CHECK_NEAR(panned.w, wide.w, 1e-4);
PLOT_CHECK_NEAR(panned.h, wide.h, 1e-4);
PLOT_CHECK_NEAR(zoomed.x, wide.x, 1e-4);
PLOT_CHECK_NEAR(zoomed.y, wide.y, 1e-4);
PLOT_CHECK_NEAR(zoomed.w, wide.w, 1e-4);
PLOT_CHECK_NEAR(zoomed.h, wide.h, 1e-4);
std::printf("[x note] footprint %.0f,%.0f %.0fx%.0f in axes %.0f,%.0f %.0fx%.0f; panned "
"%.0f,%.0f %.0fx%.0f, zoomed %.0f,%.0f %.0fx%.0f\n",
wide.x, wide.y, wide.w, wide.h, axes.x, axes.y, axes.w, axes.h, panned.x, panned.y,
panned.w, panned.h, zoomed.x, zoomed.y, zoomed.w, zoomed.h);
ctx.destroy();
}
// --- one physical size at any DPI -------------------------------------------- // --- one physical size at any DPI --------------------------------------------
namespace { namespace {
@@ -3177,6 +3818,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 +3861,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 +3913,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 +4002,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 +5880,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 +6635,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;
}
+60 -20
View File
@@ -24,7 +24,7 @@ PLOT_TEST(array_series_bounds_and_gather) {
PLOT_CHECK_NEAR(s.x_bounds().max, 11.5, 1e-9); PLOT_CHECK_NEAR(s.x_bounds().max, 11.5, 1e-9);
PLOT_CHECK_NEAR(s.y_bounds().min, -2.0, 1e-6); PLOT_CHECK_NEAR(s.y_bounds().min, -2.0, 1e-6);
std::vector<f32> out; std::vector<f32> out;
s.gather(0, 0, 4, out); s.gather(0, 0, 4, s.x0(), out);
PLOT_CHECK(out.size() == 8); // interleaved x,y PLOT_CHECK(out.size() == 8); // interleaved x,y
// x is relative to x0(): sample 0 emits 0, not 10 (the relative-x contract). // x is relative to x0(): sample 0 emits 0, not 10 (the relative-x contract).
PLOT_CHECK_NEAR(out[0], 0.0, 1e-6); PLOT_CHECK_NEAR(out[0], 0.0, 1e-6);
@@ -104,7 +104,7 @@ PLOT_TEST(stream_series_exact_queries_survive_ring_wrap) {
PLOT_CHECK_NEAR(s.sumsq(0, n), sumsq, 1e-1); PLOT_CHECK_NEAR(s.sumsq(0, n), sumsq, 1e-1);
std::vector<f32> out; std::vector<f32> out;
s.gather(0, 0, n, out); // level 0: raw samples s.gather(0, 0, n, s.x0(), out); // level 0: raw samples
PLOT_CHECK(out.size() == 2 * n); PLOT_CHECK(out.size() == 2 * n);
// x is relative to x0(): the oldest retained sample emits 0, the newest // x is relative to x0(): the oldest retained sample emits 0, the newest
// (n - 1) * dx, never the absolute data coordinates (relative-x contract). // (n - 1) * dx, never the absolute data coordinates (relative-x contract).
@@ -157,7 +157,7 @@ PLOT_TEST(stream_series_push_and_read_are_race_free) {
(void)s.sum(0, n); (void)s.sum(0, n);
(void)s.sumsq(0, n); (void)s.sumsq(0, n);
std::vector<f32> out; std::vector<f32> out;
s.gather(0, 0, n, out); // reads the oldest edge s.gather(0, 0, n, s.x0(), out); // reads the oldest edge
if (out.size() != 2 * n) sizes_ok = false; if (out.size() != 2 * n) sizes_ok = false;
} }
++reads; ++reads;
@@ -258,7 +258,7 @@ PLOT_TEST(stream_series_sliding_window_queries_match_bruteforce) {
// wholly at or after window index 0, `last` one past the last wholly inside // wholly at or after window index 0, `last` one past the last wholly inside
// the window -- so every emitted bucket's samples lie inside the window. // the window -- so every emitted bucket's samples lie inside the window.
std::vector<f32> out; std::vector<f32> out;
s.gather(1, 0, n, out); s.gather(1, 0, n, s.x0(), out);
const u64 first = const u64 first =
std::max(base / span + (base % span == 0 ? u64{0} : u64{1}), lbase); // ceil, then clamp std::max(base / span + (base % span == 0 ? u64{0} : u64{1}), lbase); // ceil, then clamp
const u64 last = std::min((base + n) / span, lbase + lsize); const u64 last = std::min((base + n) / span, lbase + lsize);
@@ -374,7 +374,7 @@ PLOT_TEST(gather_at_a_lod_level_reaches_both_ends_of_the_window) {
const auto check_ends = [&](u32 level) { const auto check_ends = [&](u32 level) {
std::vector<f32> out; std::vector<f32> out;
s.gather(level, 0, n, out); s.gather(level, 0, n, s.x0(), out);
PLOT_CHECK(out.size() >= 8u); PLOT_CHECK(out.size() >= 8u);
// Oldest: the first emitted pair is the retained sample at index 0, not the // Oldest: the first emitted pair is the retained sample at index 0, not the
// first whole bucket (which starts `lead` samples later). x is relative to // first whole bucket (which starts `lead` samples later). x is relative to
@@ -403,7 +403,7 @@ PLOT_TEST(gather_at_a_lod_level_reaches_both_ends_of_the_window) {
PLOT_CHECK((ny % 256u) != 0u); // 5000 = 19 * 256 + 136 at level 2 PLOT_CHECK((ny % 256u) != 0u); // 5000 = 19 * 256 + 136 at level 2
plot::data::ArraySeries a(y, 0.0, 1e-6); plot::data::ArraySeries a(y, 0.0, 1e-6);
std::vector<f32> out; std::vector<f32> out;
a.gather(2, 0, ny, out); a.gather(2, 0, ny, a.x0(), out);
PLOT_CHECK(out.size() >= 8u); PLOT_CHECK(out.size() >= 8u);
PLOT_CHECK_NEAR(out[0], 0.0, 1e-9); // level 2 starts on the grid PLOT_CHECK_NEAR(out[0], 0.0, 1e-9); // level 2 starts on the grid
PLOT_CHECK_NEAR(out[out.size() - 2], f32(f64(ny - 1) * 1e-6), 1e-9); PLOT_CHECK_NEAR(out[out.size() - 2], f32(f64(ny - 1) * 1e-6), 1e-9);
@@ -469,7 +469,7 @@ PLOT_TEST(gather_envelope_stays_inside_an_unaligned_range) {
const auto check_range = [&](u32 level) { const auto check_range = [&](u32 level) {
std::vector<f32> out; std::vector<f32> out;
s.gather(level, b, e, out); s.gather(level, b, e, s.x0(), out);
PLOT_CHECK(out.size() >= 4u); PLOT_CHECK(out.size() >= 4u);
for (plot::usize i = 0; i + 1 < out.size(); i += 2) { for (plot::usize i = 0; i + 1 < out.size(); i += 2) {
PLOT_CHECK(f64(out[i]) >= f64(b)); // emitted x inside [begin, end) PLOT_CHECK(f64(out[i]) >= f64(b)); // emitted x inside [begin, end)
@@ -499,7 +499,7 @@ PLOT_TEST(gather_envelope_stays_inside_an_unaligned_range) {
const u64 buckets = last - first; const u64 buckets = last - first;
PLOT_CHECK(lead > b && last * span < e); // both edge spans really are partial PLOT_CHECK(lead > b && last * span < e); // both edge spans really are partial
std::vector<f32> out; std::vector<f32> out;
s.gather(level, b, e, out); s.gather(level, b, e, s.x0(), out);
PLOT_CHECK(out.size() == 2 * ((lead - b) + 2 * buckets + (e - last * span))); PLOT_CHECK(out.size() == 2 * ((lead - b) + 2 * buckets + (e - last * span)));
plot::usize o = 0; plot::usize o = 0;
for (u64 i = b; i < lead; ++i, o += 2) { // the partial leading bucket, raw for (u64 i = b; i < lead; ++i, o += 2) { // the partial leading bucket, raw
@@ -544,7 +544,7 @@ PLOT_TEST(stream_gather_envelope_stays_inside_an_unaligned_range) {
const plot::Range exact = s.reduce(b, e); const plot::Range exact = s.reduce(b, e);
PLOT_CHECK(exact.max < 1.0 && exact.min > -1.0); PLOT_CHECK(exact.max < 1.0 && exact.min > -1.0);
std::vector<f32> out; std::vector<f32> out;
s.gather(1, b, e, out); s.gather(1, b, e, s.x0(), out);
PLOT_CHECK(out.size() >= 4u); PLOT_CHECK(out.size() >= 4u);
for (plot::usize i = 0; i + 1 < out.size(); i += 2) { for (plot::usize i = 0; i + 1 < out.size(); i += 2) {
PLOT_CHECK(f64(out[i]) >= f64(b)); // relative x inside [begin, end) PLOT_CHECK(f64(out[i]) >= f64(b)); // relative x inside [begin, end)
@@ -614,11 +614,11 @@ PLOT_TEST(gather_level_beyond_the_pyramid_clamps) {
for (u64 i = 0; i < y.size(); ++i) y[i] = f32(i % 13) - 6.0f; for (u64 i = 0; i < y.size(); ++i) y[i] = f32(i % 13) - 6.0f;
plot::data::ArraySeries s(std::move(y), 0.0, 1.0); plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
std::vector<f32> deepest; std::vector<f32> deepest;
s.gather(plot::data::kMaxPyramidLevel, 0, s.size(), deepest); s.gather(plot::data::kMaxPyramidLevel, 0, s.size(), s.x0(), deepest);
PLOT_CHECK(!deepest.empty()); PLOT_CHECK(!deepest.empty());
for (const u32 level : {u32{58}, u32{200}, u32{1000}, ~u32{0}}) { for (const u32 level : {u32{58}, u32{200}, u32{1000}, ~u32{0}}) {
std::vector<f32> out; std::vector<f32> out;
s.gather(level, 0, s.size(), out); s.gather(level, 0, s.size(), s.x0(), out);
PLOT_CHECK(out == deepest); // clamped to the same deepest geometry PLOT_CHECK(out == deepest); // clamped to the same deepest geometry
} }
// The cap is where the span shift still fits: level 57 spans exactly 2^63. // The cap is where the span shift still fits: level 57 spans exactly 2^63.
@@ -657,9 +657,9 @@ PLOT_TEST(gather_modes_envelope_nearest_mean) {
PLOT_CHECK(buckets == 16); PLOT_CHECK(buckets == 16);
std::vector<f32> env, near, mean; std::vector<f32> env, near, mean;
s.gather(level, 0, kN, env, plot::data::GatherMode::Envelope); s.gather(level, 0, kN, s.x0(), env, plot::data::GatherMode::Envelope);
s.gather(level, 0, kN, near, plot::data::GatherMode::Nearest); s.gather(level, 0, kN, s.x0(), near, plot::data::GatherMode::Nearest);
s.gather(level, 0, kN, mean, plot::data::GatherMode::Mean); s.gather(level, 0, kN, s.x0(), mean, plot::data::GatherMode::Mean);
// Envelope: two points per bucket, min at the bucket's first sample, max at // Envelope: two points per bucket, min at the bucket's first sample, max at
// its last (this sawtooth's per-bucket min is its first sample's value and // its last (this sawtooth's per-bucket min is its first sample's value and
@@ -692,7 +692,7 @@ PLOT_TEST(gather_modes_envelope_nearest_mean) {
// Level 0 and sub-bucket ranges are the raw samples for every mode. // Level 0 and sub-bucket ranges are the raw samples for every mode.
std::vector<f32> raw; std::vector<f32> raw;
s.gather(0, 5, 300, raw, plot::data::GatherMode::Mean); s.gather(0, 5, 300, s.x0(), raw, plot::data::GatherMode::Mean);
PLOT_CHECK(raw.size() == 2 * (300 - 5)); PLOT_CHECK(raw.size() == 2 * (300 - 5));
PLOT_CHECK_NEAR(raw[0], 5.0, 1e-9); PLOT_CHECK_NEAR(raw[0], 5.0, 1e-9);
} }
@@ -712,17 +712,17 @@ PLOT_TEST(gather_skips_all_nan_buckets) {
// first = 0, last = 256/128 = 2: buckets 0 and 1, no lead/trail raw spans. // first = 0, last = 256/128 = 2: buckets 0 and 1, no lead/trail raw spans.
std::vector<f32> env, mean, near; std::vector<f32> env, mean, near;
s.gather(1, 0, 256, env, plot::data::GatherMode::Envelope); s.gather(1, 0, 256, s.x0(), env, plot::data::GatherMode::Envelope);
PLOT_CHECK(env.size() == 4); // bucket 0's two pairs only PLOT_CHECK(env.size() == 4); // bucket 0's two pairs only
PLOT_CHECK_NEAR(env[0], 0.0, 1e-9); PLOT_CHECK_NEAR(env[0], 0.0, 1e-9);
PLOT_CHECK_NEAR(env[1], 1.0, 1e-9); PLOT_CHECK_NEAR(env[1], 1.0, 1e-9);
PLOT_CHECK_NEAR(env[2], 127.0, 1e-9); PLOT_CHECK_NEAR(env[2], 127.0, 1e-9);
PLOT_CHECK_NEAR(env[3], 1.0, 1e-9); PLOT_CHECK_NEAR(env[3], 1.0, 1e-9);
s.gather(1, 0, 256, mean, plot::data::GatherMode::Mean); s.gather(1, 0, 256, s.x0(), mean, plot::data::GatherMode::Mean);
PLOT_CHECK(mean.size() == 2); // bucket 1 has no finite mean: skipped PLOT_CHECK(mean.size() == 2); // bucket 1 has no finite mean: skipped
PLOT_CHECK_NEAR(mean[0], 64.0, 1e-9); PLOT_CHECK_NEAR(mean[0], 64.0, 1e-9);
PLOT_CHECK_NEAR(mean[1], 1.0, 1e-9); PLOT_CHECK_NEAR(mean[1], 1.0, 1e-9);
s.gather(1, 0, 256, near, plot::data::GatherMode::Nearest); s.gather(1, 0, 256, s.x0(), near, plot::data::GatherMode::Nearest);
PLOT_CHECK(near.size() == 4); // every bucket still has a centre sample PLOT_CHECK(near.size() == 4); // every bucket still has a centre sample
PLOT_CHECK_NEAR(near[0], 64.0, 1e-9); PLOT_CHECK_NEAR(near[0], 64.0, 1e-9);
PLOT_CHECK_NEAR(near[1], 1.0, 1e-9); PLOT_CHECK_NEAR(near[1], 1.0, 1e-9);
@@ -739,7 +739,7 @@ PLOT_TEST(stream_series_freeze_copies_the_last_acquire_window) {
const u64 n = s.size(); const u64 n = s.size();
const plot::Range bounds = s.y_bounds(); const plot::Range bounds = s.y_bounds();
std::vector<f32> gathered; std::vector<f32> gathered;
s.gather(1, 0, n, gathered); s.gather(1, 0, n, s.x0(), gathered);
const plot::Range reduced = s.reduce(0, n); const plot::Range reduced = s.reduce(0, n);
// Freeze now, while the ring still holds exactly the published window. // Freeze now, while the ring still holds exactly the published window.
@@ -754,7 +754,7 @@ PLOT_TEST(stream_series_freeze_copies_the_last_acquire_window) {
PLOT_CHECK_NEAR(fr.max, reduced.max, 1e-6); PLOT_CHECK_NEAR(fr.max, reduced.max, 1e-6);
PLOT_CHECK_NEAR(frozen.sum(0, n), s.sum(0, n), 1e-3); PLOT_CHECK_NEAR(frozen.sum(0, n), s.sum(0, n), 1e-3);
std::vector<f32> frozen_gathered; std::vector<f32> frozen_gathered;
frozen.gather(1, 0, n, frozen_gathered); frozen.gather(1, 0, n, frozen.x0(), frozen_gathered);
PLOT_CHECK(frozen_gathered == gathered); // same geometry, byte for byte PLOT_CHECK(frozen_gathered == gathered); // same geometry, byte for byte
// The producer now laps the ring (700 more pushes overwrite the oldest // The producer now laps the ring (700 more pushes overwrite the oldest
@@ -769,6 +769,46 @@ PLOT_TEST(stream_series_freeze_copies_the_last_acquire_window) {
PLOT_CHECK(frozen.reduce(0, n).max == reduced.max); PLOT_CHECK(frozen.reduce(0, n).max == reduced.max);
} }
// --- The gather origin: f32 precision against the view, not the window -------
PLOT_TEST(a_gather_origin_at_the_view_keeps_a_deep_zoom_on_the_sample_it_quotes) {
// 20 s of retained data at 1 kS/s examined through a 10 us window -- the shape of
// a scope zoomed in on a long ring. gather() stages x as f32, so what the origin
// decides is the span that f32 has to resolve: the whole retained window (which
// is what `x0()`, the old implicit origin, names) or the part of it on screen. A
// readout quotes `x_at()` in exact f64, so the difference between the two is the
// distance between where a sample is *drawn* and the x reported for it.
constexpr u64 kN = 20001;
std::vector<f32> y(static_cast<plot::usize>(kN));
for (u64 i = 0; i < kN; ++i) y[static_cast<plot::usize>(i)] = f32(i % 71);
const plot::data::ArraySeries s(std::move(y), /*x0=*/0.0, /*dx=*/1e-3);
PLOT_CHECK_NEAR(s.x_bounds().max, 20.0, 1e-9);
const f64 centre = s.x_at(kN - 2u); // 19.999 s, near the far end
const plot::Range view{centre - 5e-6, centre + 5e-6}; // 10 us across
const f64 px_per_x = 700.0 / (view.max - view.min); // a typical axis width
const auto worst_error_px = [&](f64 origin) {
std::vector<f32> out;
s.gather(0, kN - 3u, kN, origin, out);
PLOT_CHECK(out.size() == 6u);
f64 worst = 0.0;
for (u64 k = 0; k < 3u; ++k) {
// Exactly the renderer's reconstruction: the origin added back in f64.
const f64 drawn = origin + f64(out[static_cast<plot::usize>(2u * k)]);
worst = std::max(worst, std::abs(drawn - s.x_at(kN - 3u + k)) * px_per_x);
}
return worst;
};
// The visible range's minimum: the f32 resolves 2^-24 of what is on screen, so
// the error is a fixed fraction of a pixel at every zoom.
PLOT_CHECK(worst_error_px(view.min) < 0.05);
// The start of the retained window -- what every caller used to get -- resolves
// 2^-24 of 20 s instead, which at this zoom is most of the axis.
PLOT_CHECK(worst_error_px(s.x0()) > 10.0);
}
// --- Incremental append: ArraySeries::push() ---------------------------------- // --- Incremental append: ArraySeries::push() ----------------------------------
PLOT_TEST(array_series_push_matches_commit) { PLOT_TEST(array_series_push_matches_commit) {
+94
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");
@@ -257,6 +263,18 @@ PLOT_TEST(format_time_never_lets_a_field_reach_its_radix) {
plot::format_time(86399.5, b, sizeof b); PLOT_CHECK(std::string(b) == "1d"); plot::format_time(86399.5, b, sizeof b); PLOT_CHECK(std::string(b) == "1d");
plot::format_time(86400.0, b, sizeof b); PLOT_CHECK(std::string(b) == "1d"); plot::format_time(86400.0, b, sizeof b); PLOT_CHECK(std::string(b) == "1d");
// A mantissa may not reach its own radix either: three significant figures of a value a
// hair under a rung's top round up to 1000, and `%.3g` writes that `1e+03`. A one-second
// tick computed as 0.9999999 s read `1e+03ms` on a live axis, which is where this was seen.
plot::format_time(0.9999999, b, sizeof b); PLOT_CHECK(std::string(b) == "1s");
plot::format_time(0.9995, b, sizeof b); PLOT_CHECK(std::string(b) == "1s");
plot::format_time(0.9994, b, sizeof b); PLOT_CHECK(std::string(b) == "999ms");
plot::format_time(0.9999999e-3, b, sizeof b); PLOT_CHECK(std::string(b) == "1ms");
plot::format_time(0.9994e-3, b, sizeof b); PLOT_CHECK(std::string(b) == "999\u00b5s");
plot::format_time(0.9999999e-6, b, sizeof b); PLOT_CHECK(std::string(b) == "1\u00b5s");
plot::format_time(0.9999999e-9, b, sizeof b); PLOT_CHECK(std::string(b) == "1ns");
plot::format_time(-0.9999999, b, sizeof b); PLOT_CHECK(std::string(b) == "-1s");
// Sweep the boundary neighbourhoods and check every emitted field. // Sweep the boundary neighbourhoods and check every emitted field.
const double values[] = {0.4, 0.9995, 1.0, 59.4, 59.4999, 59.5, 59.9, 60.0, 90.0, 119.5, 3599.4, const double values[] = {0.4, 0.9995, 1.0, 59.4, 59.4999, 59.5, 59.9, 60.0, 90.0, 119.5, 3599.4,
3599.5, 3599.9, 86399.4, 86399.5, 86399.9, 86400.0, 172800.0, -119.5, 3599.5, 3599.9, 86399.4, 86399.5, 86399.9, 86400.0, 172800.0, -119.5,
@@ -268,3 +286,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
}
+7 -6
View File
@@ -52,7 +52,8 @@ PLOT_TEST(xy_series_reports_its_own_grid) {
PLOT_CHECK_NEAR(s.y_bounds().max, 48.0, 1e-6); PLOT_CHECK_NEAR(s.y_bounds().max, 48.0, 1e-6);
// Epoch-style offset: the same shape shifted by 1.7e9 keeps exact bounds, and // Epoch-style offset: the same shape shifted by 1.7e9 keeps exact bounds, and
// gather() emits x relative to x0() -- small numbers, not quantised epochs. // gather() emits x relative to the origin the caller names -- x0() here, so
// small numbers, not quantised epochs.
std::vector<f64> epoch = spread_x(500); std::vector<f64> epoch = spread_x(500);
const std::vector<f64> es = epoch; // un-moved copy for the expectations const std::vector<f64> es = epoch; // un-moved copy for the expectations
for (auto& v : epoch) v += 1.7e9; for (auto& v : epoch) v += 1.7e9;
@@ -60,7 +61,7 @@ PLOT_TEST(xy_series_reports_its_own_grid) {
PLOT_CHECK_NEAR(e.x_bounds().min, 1.7e9, 1e-3); PLOT_CHECK_NEAR(e.x_bounds().min, 1.7e9, 1e-3);
PLOT_CHECK_NEAR(e.x_bounds().max, 1.7e9 + (xs.back() - xs.front()), 1e-3); PLOT_CHECK_NEAR(e.x_bounds().max, 1.7e9 + (xs.back() - xs.front()), 1e-3);
std::vector<f32> out; std::vector<f32> out;
e.gather(0, 0, 500, out); e.gather(0, 0, 500, e.x0(), out);
PLOT_CHECK(out.size() == 1000); PLOT_CHECK(out.size() == 1000);
// x is relative to x0(): x_at(i) - x0(), the array's own offsets -- small // x is relative to x0(): x_at(i) - x0(), the array's own offsets -- small
// values even though the absolute epoch is 1.7e9. The expectations come from // values even though the absolute epoch is 1.7e9. The expectations come from
@@ -72,9 +73,9 @@ PLOT_TEST(xy_series_reports_its_own_grid) {
} }
PLOT_TEST(xy_series_neighbour_lookups_crosshair_style) { PLOT_TEST(xy_series_neighbour_lookups_crosshair_style) {
// The crosshair asks neighbours() at an arbitrary x and interpolates between // The crosshair asks neighbours() at an arbitrary x and reports whichever of
// the two samples it gets back. On a non-uniform grid that needs real // the two samples it gets back is nearer. On a non-uniform grid that needs
// searches: a uniform-grid formula would land megabytes off here. // real searches: a uniform-grid formula would land megabytes off here.
auto x = spread_x(1000); auto x = spread_x(1000);
const std::vector<f64> xs = x; const std::vector<f64> xs = x;
plot::data::XYSeries s(std::move(x), saw_y(1000)); plot::data::XYSeries s(std::move(x), saw_y(1000));
@@ -186,7 +187,7 @@ PLOT_TEST(xy_series_gather_envelope_matches_exact_reduces) {
const u64 span = u64{kBucket} << level; const u64 span = u64{kBucket} << level;
const u64 b = 500 + 33, e = 3500 - 57; // unaligned on the bucket grid const u64 b = 500 + 33, e = 3500 - 57; // unaligned on the bucket grid
std::vector<f32> out; std::vector<f32> out;
s.gather(level, b, e, out, plot::data::GatherMode::Envelope); s.gather(level, b, e, s.x0(), out, plot::data::GatherMode::Envelope);
PLOT_CHECK(out.size() >= 8u); PLOT_CHECK(out.size() >= 8u);
// Emitted x stays inside [x_at(begin), x_at(end)) and the geometry reaches // Emitted x stays inside [x_at(begin), x_at(end)) and the geometry reaches
// both the range's extremes (the lead/trail raw spans carry them). // both the range's extremes (the lead/trail raw spans carry them).