Compare commits

...
24 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
Martino FerrariandClaude Opus 4.6 e337c6a60c fix(ticks): label the tick at the origin 0, not from the atto end of the table
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 range straddling zero never lands *on* zero: the walk starts at
`ceil(min / stride) * stride`, which is not representable whenever the stride is
not a power of two -- 0.1 and 0.2 are the everyday ones -- so the tick that means
zero arrives a few ULP off and `format_si` labels it from six prefixes down.
`[-1, 1]` read `-1 -500m -139a 500m 1`. Deriving the tick by multiplication
rather than accumulation does not help, because the error is already in the
first term.

Every tick is an integer multiple of the stride, so the smallest magnitude a
non-zero tick can have is the stride itself, while the residue is a few ULP of
the range -- ten orders of magnitude apart. Anything inside a millionth of the
stride is therefore the origin and nothing else. It is stored as exactly `0.0`
rather than merely labelled so: `Axis::refresh()` re-formats a time axis from
`tick.value`, where the same residue reads `3.19e-13ms`. The walk variable is
left alone, since `minor_index_is_major` counts indices from `first_major` and
both operands have to stay on the walk's own grid.

The two narrow-y-title cases were calibrated on the defect -- the bogus `-55.5a`
was a six-character label, and its width is what spent the chrome allowance and
made the titled and untitled frames comparable. They are re-seated on a
milli-scale range, whose `-200m` is wide for a real reason, and the zero-width
strip the frame comparison depends on is now asserted rather than assumed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-02 00:52:20 +02:00
Martino FerrariandClaude Opus 4.6 525759f9a9 fix(render): filter the glyph atlas, snap chrome hairlines to pixels
Text was point-sampled (`GL_NEAREST` plus `floor(v_uv * u_tex_size)`), which
at a non-integer scale *deletes* texels instead of blending them. The library's
own default -- a 12 px style over the 13 px embedded bake -- dropped one of
every 8 columns and one of every 17 rows of every glyph, so whole strokes went
missing and which ones depended on where the unsnapped pen fell. `kTextFS` now
does a 2x2 bilinear fetch clamped to the instance's own cell: the clamp keeps
the no-bleed guarantee the sampler mode protects (the atlas is a tight strip of
cells with no padding), and at scale 1 with a whole pen the weights are exactly
0, so it stays as crisp as the point sample.

Grid and axis lines were staged in data space and mapped in the vertex shader,
so a 1 px line's centre landed on an arbitrary fraction -- x = 100.0 lights two
columns at half coverage, x = 100.5 lights one crisply, and a frame mixed both.
`u_snap` rounds an axis-aligned segment's ink *leading edge* onto a pixel
boundary, which reduces to integer+0.5 for odd widths and to an integer for
even ones with no case analysis. It is on for the chrome that frames a plot and
off for anything drawn at a data position, since the rounding moves a line by
up to half a pixel. Diagonals are untouched.

`text_pixel`'s threshold drops to 160 because filtered glyphs legitimately peak
at 0.844 coverage at 12/13; the 180 threshold found text only while the sampler
was also deleting a column and a row of it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-02 00:22:08 +02:00
Martino Ferrari 98cfc5805b Fixing minor issues in multifigure plots
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-01 15:32:05 +02:00
Martino Ferrari 38ef459897 feat(plot)!: harden pipeline, XY series, dash/markers, touch, packaging
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
Breaking: Series::gather emits x relative to x0() and takes a GatherMode;
consumers reconstruct absolute x in f64 before mapping (series.hpp documents
the contract).

Bugs, each pinned by a fail-before regression test:
- render: f32 staging quantised epoch-time x into 128 px columns; every
  data-space draw now stages to_space(x) - to_space(origin) with the origin
  folded into the offset uniform in f64 (epoch/zero-origin twins rasterise
  identically)
- ticks: major test was ULP-fragile and blanked epoch axes; majors are an
  integer-index test and are never truncated by the minor cap
- gather: LOD envelope emitted buckets straddling [begin,end); ceil/floor
  bucket edges + raw lead/trail spans
- live follow: log x axes held a linear span and zoomed every frame; span and
  offset now live in axis space (plot + both figure link-group paths)
- input: a zero-notch wheel disarmed auto_scale on every axis; dy==0 pans x,
  factor == 1 / inf refused in wheel and drag zoom
- render: the single text flush painted legend names over the crosshair
  readout panel; two batches, under flushed before the panel
- data: NaN dropped from min/max unless it seeded the fold; non-finite
  samples are now excluded from every aggregate and from cached bounds

Robustness: viewport normalisation (no clamp UB), non-finite range refusal,
pyramid level cap at 57 (no shift/div UB through public gather), rounded ring
capacity documented + pre-sized, GL blend func and UNPACK_ALIGNMENT
save/restore, Renderer::release_gl() teardown, u64 buffer epochs, annotation
captions clamped, button-aware GLFW row capture, cached figure row rects (no
per-event layout), ruler-pair measurement refused, span-nonfinite guards in
axis_ends.

Features: XY series (sorted x, binary-search neighbours/snap, LOD over sample
index); StreamSeries::freeze(); ArraySeries::push(); per-trace dash and
markers (scatter at width 0) with auto or literal stride; GatherMode
Envelope/Nearest/Mean via bucket_sum/sumsq; Style::line_aa_px; per-glyph text
colour (readout values in trace colour); wheel-dx x pan; arrow-key pan;
Escape cancels a drag and restores captured ranges; modifier-masked box zoom
(Shift both, Ctrl x, Alt y); double-click reset via PointerEvent::time_s;
ImGui key repeat unified; pinch gestures on Qt and GTK; pixelDelta wheel path
and DevicePixelRatioChange handling; GNUInstallDirs, SOVERSION,
SameMinorVersion, generated plot/version.hpp, relocatable plot.pc, licence
install, FILES_MATCHING, GitHub Actions CI (debug/coverage/asan).

Suite 283 -> 350 cases, 0 failed; coverage 93.9% (3730/3971) over 24 files,
floor 80/90 held; ASAN preset suite green; all four demos run headless (the
GTK scripted-resize check needs a window manager and fails identically on the
parent commit); readme_main.cpp committed and compiles with the README
command; pkg-config and find_package consumption verified from a relocated
install prefix.
2026-09-30 17:32:06 +02:00
84 changed files with 8677 additions and 853 deletions

No files matched your search

+92
View File
@@ -0,0 +1,92 @@
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: 2026 Martino Ferrari
#
# The three jobs docs/building-and-testing.md describes: the plain debug build
# with the whole suite and every adapter, the reproducible coverage report, and
# the AddressSanitizer run. Each drives the project through its own preset, so
# CI configures exactly what a reader following the docs configures. The test
# steps run under xvfb: the GL tests create their own headless EGL context, but
# a display costs nothing and keeps the run honest about where a frame would
# go. The ImGui adapter fetches its Dear ImGui sources at configure time, which
# needs the network a GitHub runner has.
name: CI
on:
push:
pull_request:
permissions:
contents: read
jobs:
debug:
name: debug (tests + all adapters)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
ninja-build python3-fonttools \
libglfw3-dev qt6-base-dev libgtk-4-dev \
libegl1 libglvnd-dev xvfb
- name: Configure (linux-debug)
run: cmake --preset linux-debug
- name: Build
run: cmake --build --preset linux-debug
- name: Test
run: xvfb-run -a ctest --test-dir build/debug --output-on-failure
coverage:
name: coverage (floor + measured table)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
ninja-build lcov python3-fonttools \
libegl1 libglvnd-dev xvfb
- name: Measure (tools/coverage.sh)
# The script configures build/cov from the linux-coverage preset, runs
# the suite, captures src/** with lcov, checks the floors and rewrites
# the measured table in docs/coverage.md. Non-zero exit fails the job.
run: xvfb-run -a tools/coverage.sh
- name: Upload the measured table
uses: actions/upload-artifact@v4
with:
name: coverage
path: docs/coverage.md
asan:
name: asan (AddressSanitizer)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
ninja-build python3-fonttools \
libglfw3-dev qt6-base-dev libgtk-4-dev \
libegl1 libglvnd-dev xvfb
- name: Configure (linux-asan)
run: cmake --preset linux-asan
- name: Build
run: cmake --build --preset linux-asan
- name: Test
run: xvfb-run -a ctest --test-dir build/asan --output-on-failure
+79 -36
View File
@@ -1,8 +1,19 @@
cmake_minimum_required(VERSION 3.24)
project(plot VERSION 0.1.0 LANGUAGES CXX)
project(plot VERSION 0.1.0
DESCRIPTION "An embeddable OpenGL time-series plotting library for real-time, high-sample-rate data"
LANGUAGES CXX)
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
include(plot_options)
include(GNUInstallDirs)
# The version header: generated into the build tree at configure time, from the
# template beside it. It is a public header -- the umbrella names it, the
# self-containment and umbrella checks in tests/ hold it to the same rules, and
# the install rules place it under include/plot/ with the rest of the surface --
# so a consumer can ask what it compiled against (see include/plot/version.hpp.in).
configure_file(include/plot/version.hpp.in
"${CMAKE_CURRENT_BINARY_DIR}/include/plot/version.hpp" @ONLY)
add_library(plot_core
src/core/status.cpp
@@ -11,6 +22,7 @@ add_library(plot_core
src/data/series.cpp
src/data/array_series.cpp
src/data/stream_series.cpp
src/data/xy_series.cpp
src/gl/loader.cpp
src/gl/handle.cpp
src/render/shaders.cpp
@@ -36,36 +48,32 @@ add_library(plot::core ALIAS plot_core)
# and every consumer, and this project's own comments -- says plot::core.
set_target_properties(plot_core PROPERTIES EXPORT_NAME core)
# The shared-library case names its file versions: the dynamic loader follows
# the SONAME (libplot_core.so.<SOVERSION>, the major component), so an
# ABI-breaking release moves it by bumping project()'s major. The static
# archive -- the default, and the only shape the presets build -- carries no
# such name, and setting the properties on it would be noise.
if(BUILD_SHARED_LIBS)
set_target_properties(plot_core PROPERTIES
VERSION ${PROJECT_VERSION}
SOVERSION ${PROJECT_VERSION_MAJOR})
endif()
target_include_directories(plot_core PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
# The generated version header lives here, not under the source root (see
# configure_file above); everything that compiles against plot::core reaches
# it through this interface, including the header checks in tests/.
$<BUILD_INTERFACE:${CMAKE_CURRENT_BINARY_DIR}/include>
$<INSTALL_INTERFACE:include>)
target_compile_features(plot_core PUBLIC cxx_std_20)
target_link_libraries(plot_core PRIVATE ${CMAKE_DL_LIBS})
plot_apply_warnings(plot_core)
# The optional runtime-font backend: plot::text::TrueTypeFont, which bakes a real
# TrueType/OpenType file into a fixed-cell atlas at run time (see
# include/plot/text/true_type_font.hpp). It is the one target that compiles the
# vendored third_party/stb_truetype.h, so it is opt-in and separable -- the core
# library, its public headers and its tests build without it -- and its default
# follows the vendored header's presence: a tree that has the file wants the
# feature, and one that does not cannot have it. Asking for it explicitly without
# the header is an error rather than a silent downgrade, because the option is
# then the only thing that would have said so.
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/third_party/stb_truetype.h")
set(PLOT_VENDORED_STB_TRUETYPE TRUE)
else()
set(PLOT_VENDORED_STB_TRUETYPE FALSE)
endif()
option(PLOT_BUILD_TTF_FONT
"Build the optional plot::text::TrueTypeFont backend (vendored stb_truetype)"
${PLOT_VENDORED_STB_TRUETYPE})
if(PLOT_BUILD_TTF_FONT AND NOT PLOT_VENDORED_STB_TRUETYPE)
message(FATAL_ERROR
"plot: PLOT_BUILD_TTF_FONT=ON needs third_party/stb_truetype.h, which this tree does "
"not have. Restore it -- include/plot/text/true_type_font.hpp records the version and "
"the source URL it came from -- or configure with -DPLOT_BUILD_TTF_FONT=OFF.")
endif()
# The optional runtime-font backend: plot::text::TrueTypeFont. The option and
# its guard live in cmake/plot_options.cmake with every other PLOT_* option;
# what is left here is the one thing an option cannot do for itself: adding the
# target. See the option's comment there for why it is opt-in and separable.
if(PLOT_BUILD_TTF_FONT)
add_subdirectory(src/text)
endif()
@@ -162,35 +170,70 @@ if(PLOT_INSTALL)
# asking for it twice puts the same directory in the export twice.
install(TARGETS plot_core ${PLOT_ADAPTER_EXPORT_TARGETS}
EXPORT plotTargets
ARCHIVE DESTINATION lib
LIBRARY DESTINATION lib
RUNTIME DESTINATION bin)
# The optional backend's header goes in only when that backend was built: an
# installed header with no library behind it is a promise the prefix cannot keep,
# and which configuration a prefix came from is not something a consumer can see.
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
# The public headers, and the generated version header with them. The
# directory rule takes the source tree (FILES_MATCHING, so the version.hpp.in
# template -- a .in, not a .hpp -- cannot travel); the generated
# include/plot/version.hpp is not under the source root, so it goes in by
# name. The optional backend's header goes in only when that backend was
# built: an installed header with no library behind it is a promise the
# prefix cannot keep, and which configuration a prefix came from is not
# something a consumer can see.
if(PLOT_BUILD_TTF_FONT)
install(DIRECTORY include/plot DESTINATION include)
install(DIRECTORY include/plot
DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
FILES_MATCHING PATTERN "*.hpp")
else()
install(DIRECTORY include/plot DESTINATION include
install(DIRECTORY include/plot
DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
FILES_MATCHING PATTERN "*.hpp"
PATTERN "true_type_font.hpp" EXCLUDE)
endif()
install(FILES "${CMAKE_CURRENT_BINARY_DIR}/include/plot/version.hpp"
DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/plot)
# The licences that travel with the installed files: this library's MIT text,
# and the OFL 1.1 text for the embedded font the headers bake in, so a
# redistributor of the prefix holds both grants without a second checkout.
install(FILES LICENSE licences/OFL-1.1.txt
DESTINATION ${CMAKE_INSTALL_DATADIR}/licenses/plot)
# pkg-config metadata, for consumers whose build is not CMake. find_package()
# and the package config below remain the primary interface; see plot.pc.in.
set(PLOT_PC_LIBDIR "${CMAKE_INSTALL_LIBDIR}")
set(PLOT_PC_INCLUDEDIR "${CMAKE_INSTALL_INCLUDEDIR}")
set(PLOT_PC_LIB "-lplot_core")
set(PLOT_PC_LIBS_PRIVATE "")
foreach(plot_pc_lib IN LISTS CMAKE_DL_LIBS)
list(APPEND PLOT_PC_LIBS_PRIVATE "-l${plot_pc_lib}")
endforeach()
string(REPLACE ";" " " PLOT_PC_LIBS_PRIVATE "${PLOT_PC_LIBS_PRIVATE}")
configure_file(plot.pc.in "${CMAKE_CURRENT_BINARY_DIR}/plot.pc" @ONLY)
install(FILES "${CMAKE_CURRENT_BINARY_DIR}/plot.pc"
DESTINATION ${CMAKE_INSTALL_LIBDIR}/pkgconfig)
install(EXPORT plotTargets
NAMESPACE plot::
FILE plotTargets.cmake
DESTINATION lib/cmake/plot)
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/plot)
configure_package_config_file(
cmake/plotConfig.cmake.in
"${CMAKE_CURRENT_BINARY_DIR}/plotConfig.cmake"
INSTALL_DESTINATION lib/cmake/plot)
INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/plot)
# SameMinorVersion, deliberately: the project is 0.x, where the major
# component carries no ABI promise and the minor is what moves when one
# breaks, so a consumer asking for 0.1 gets 0.1.x and nothing newer. It goes
# up to SameMajorVersion when the version leaves 0.
write_basic_package_version_file(
"${CMAKE_CURRENT_BINARY_DIR}/plotConfigVersion.cmake"
VERSION ${PROJECT_VERSION}
COMPATIBILITY SameMajorVersion)
COMPATIBILITY SameMinorVersion)
install(FILES
"${CMAKE_CURRENT_BINARY_DIR}/plotConfig.cmake"
"${CMAKE_CURRENT_BINARY_DIR}/plotConfigVersion.cmake"
DESTINATION lib/cmake/plot)
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/plot)
endif()
+70 -17
View File
@@ -14,9 +14,14 @@ built on top of it.
| Qt6 | `plot::qt6` | `adapters/qt6/` |
| GTK4 | `plot::gtk4` | `adapters/gtk4/` |
The visible x range is mapped onto sample indices through the series' own x
(`Series::neighbours()`, a binary search over the real stamps), never through the
`(x - x0()) / dx()` grid: `dx()` is a *nominal* spacing, and a series whose x is a streamed
timestamp would otherwise have its draw window land inside the visible range and lose the two
segments that enter and leave the frame — the empty left and right margins of a zoomed-in view.
Per-frame rendering cost is O(visible pixels + samples produced since the last frame),
not O(samples): a trace denser than one sample per pixel is drawn from a min/max LOD
pyramid rather than from raw samples. The `plot_multiplot_demo` run stages **18,651 GPU
pyramid rather than from raw samples. The `plot_multiplot_demo` run stages **20,901 GPU
segments** to draw **~4.5 million visible samples** across six traces — the frame is
bounded by the pixels it lights, not by the sample count behind them. That figure is from
a `linux-release` build under `xvfb-run` on **llvmpipe** (Mesa's software rasteriser); no
@@ -24,15 +29,44 @@ run touched a GPU, so read it as an upper bound and a record of the software pat
## 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_minor_visible`);
`grid_minor_visible`), each with its own colour, width and (for the minors) dash — the
subdivisions default to thinner, dashed and alpha-blended, so they read as texture under the
labelled grid rather than as a second set of ticks;
- x and y axes, with a vertical (bottom-to-top) y-axis title;
- cursors in both orientations — vertical markers and horizontal rulers;
- draggable regions and measurement bands with exact interval statistics;
- 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;
- 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
with binary-searched crosshair, measurement and index snapping;
- per-trace draw modes: dash patterns (`Trace::dash_on`/`dash_off`, logical px, 0 = solid)
and point markers (`Trace::marker`: cross, circle, square) with a manual
`marker_every` stride or an automatic one; `width == 0` draws the markers alone
(scatter);
- three downsample policies per trace (`Trace::mode`, `GatherMode`): `Envelope`
(min/max pairs — the default), `Nearest` (one raw sample per bucket) and `Mean`
(one averaged point per bucket);
- readout text colour: crosshair value rows render in their row's trace colour, labels in
the axis text colour;
- input parity across hosts: pinch-zoom and pan gestures on Qt6 and GTK4 drive the same
controller paths as mouse and wheel (touch parity), with keyboard panning
(arrow keys), Escape cancelling a drag or box-zoom, double-click re-fitting the axes,
and modifier-masked box zoom (Shift = both axes, Ctrl = x only, Alt = y only);
- DPI-consistent sizing: metrics are logical units converted once through
`Style::ui_scale`;
- an anti-aliased embedded monospaced font, plus an optional runtime TrueType backend
@@ -49,20 +83,32 @@ cmake --build build/release
cmake --install build/release --prefix /where/you/want
```
Install is on by default (`PLOT_INSTALL=ON`) and installs the `plot_core` archive, the
public headers under `include/plot/`, and a CMake package config under `lib/cmake/plot/`.
A consumer then needs only:
Install is on by default (`PLOT_INSTALL=ON`) and, via `GNUInstallDirs`, installs the
`plot_core` archive under `lib/`, the public headers under `include/plot/` — including the
generated `plot/version.hpp` — a CMake package config under `lib/cmake/plot/`, a pkg-config
file under `lib/pkgconfig/plot.pc`, and the licences under `share/licenses/plot/`. A
consumer then needs only:
```cmake
find_package(plot 0.1 REQUIRED)
target_link_libraries(my_app PRIVATE plot::core)
```
**Versioning.** The package config carries **SameMinorVersion** compatibility: at 0.x the
major component promises nothing and the minor is what moves when the surface breaks, so
`find_package(plot 0.1 REQUIRED)` accepts 0.1.x and refuses anything newer. A shared build
(`BUILD_SHARED_LIBS=ON`) names its ABI on the file itself — `libplot_core.so.0`, the
SOVERSION being the major component. What a header set *is* is in its generated
`<plot/version.hpp>`: `plot::version::major`/`minor`/`patch`/`string`, or the
`PLOT_VERSION_*` macros for a preprocessor test. The full layout is tabled in
[docs/building-and-testing.md](docs/building-and-testing.md).
## Embedding
A host owns the window, the GL context and the event loop; `plot` owns none of them. This
is the whole of a minimal GLFW program — the adapter opens the window, runs the loop and
feeds the controller, and the callback is where live data would be pushed:
feeds the controller, and the callback is where live data would be pushed. It is committed
at the repository root as `readme_main.cpp`:
```cpp
#include <glfw/glfw_host.hpp> // the optional GLFW adapter
@@ -90,10 +136,12 @@ int main() {
```
It compiles and links against a built tree (see [docs/getting-started.md](docs/getting-started.md)
for the same program written against `plot::core` directly, with no adapter):
for the same program written against `plot::core` directly, with no adapter). The one
non-obvious flag is `-I build/debug/include`: that is where CMake generates
`plot/version.hpp`, which the umbrella pulls in:
```sh
g++ -std=c++20 -I include -I adapters readme_main.cpp \
g++ -std=c++20 -I include -I adapters -I build/debug/include readme_main.cpp \
build/debug/adapters/glfw/libplot_glfw.a build/debug/libplot_core.a \
$(pkg-config --libs glfw3) -ldl -lpthread -o plot_readme
```
@@ -124,18 +172,18 @@ documentation.
```sh
cmake --preset linux-debug
cmake --build build/debug
ctest --test-dir build/debug --output-on-failure # 283 cases, 0 failed
ctest --test-dir build/debug --output-on-failure # 350 cases, 0 failed
tools/coverage.sh # configure+build+run+capture+floor check
```
The `linux-debug` suite runs **283 cases**; the `linux-coverage` preset builds **272** of
them, because its adapters are off and 11 cases need an adapter target (8 in
`test_imgui_widget.cpp`, 3 in `test_glfw_host.cpp`). Coverage is enforced by
The `linux-debug` suite runs **350 cases**; the `linux-coverage` preset builds **333** of
them, because its adapters are off and 17 cases need an adapter target (11 in
`test_imgui_widget.cpp`, 6 in `test_glfw_host.cpp`). Coverage is enforced by
`tools/coverage.sh` against `tools/coverage_floor.py`: **80%** per file under `src/` and
**90%** aggregate. The current measurement is an aggregate **95.3% (3284/3447 lines)** over
23 files, every file at or above 80%; the per-file table is in
[docs/coverage.md](docs/coverage.md), which the command regenerates.
**90%** aggregate. The measured aggregate and per-file table live in
[docs/coverage.md](docs/coverage.md), which the command regenerates — it is the single
source for those numbers, so this page carries none.
## Licence and third-party provenance
@@ -164,7 +212,12 @@ them, because its adapters are off and 11 cases need an adapter target (8 in
- **macOS and Windows are untested.** Development and CI are Linux/EGL + X11 only.
- **Fractional-scale compositing and touchpad scroll calibration are unverified** — the
`ui_scale` sources are correct per host, but no run exercised a real fractional DPI or a
calibrated scroll device.
calibrated scroll device. The new gesture paths (Qt6 and GTK4 pinch, the keyboard and
wheel paths behind the parity list above) are tested at the toolkit-free seams and
headless; no run has touched real touch hardware.
- **Figure row rects are one frame stale during a resize.** The hosts cache
`Figure::layout()`'s rows and refresh them once per frame, so an event arriving mid-resize
routes against the previous frame's rects (see [docs/embedding.md](docs/embedding.md)).
- **Untrusted font files are out of scope** for the runtime `plot::font_ttf` backend:
stb_truetype does no range checking, so `from_file()` must only be handed files the host
trusts.
+53 -19
View File
@@ -111,6 +111,10 @@ void Host::close() noexcept {
// hands each row its rect every frame). Dropping them would silently stop routing
// gestures to the rows on the second run.
have_pointer_ = false;
// The cached rows described a figure's geometry; the next run() re-reads them from
// scratch, and until then `controller_at()` answers the single-plot controller
// rather than rects from a window that no longer exists.
row_rects_.clear();
// A closed host reports no size: a second run() must not see the previous
// window's framebuffer size before its first refresh_sizes().
fb_width_ = 0;
@@ -210,13 +214,12 @@ bool Host::run(const RunConfig& cfg, const FrameFn& frame, Status* st) {
// that hands each row its rect (and so its own `Plot` its viewport). The single
// controller is untouched -- each row has its own, see controller_at().
figure_->set_viewport(viewport);
// Every row's controller gets that row's rect, so a gesture -- routed by
// pointer_target() or injected by a scripted run -- is hit-tested against the
// geometry the last frame drew, and the two readers of a row's geometry cannot
// disagree about it. The rows come off the one recomputing layout of this frame --
// calling `layout()` again here, or in `controller_at()`, would lay every row out a
// second and a third time for the same frame.
const std::vector<Rect>& rows = figure_->layout().rows;
// The frame's one layout of the figure, into the cache every event handler
// between now and the next frame reads (R7): the rows below, and the routing of
// whatever the next `glfwPollEvents()` batch delivers, all take *these* rects
// rather than each paying for another layout.
refresh_row_rects();
const std::vector<Rect>& rows = row_rects_;
for (u32 i = 0; i < row_controllers_.size(); ++i) {
if (i < rows.size()) row_controllers_[i]->set_viewport(rows[i]);
}
@@ -353,6 +356,7 @@ void Host::on_cursor_pos(f64 wx, f64 wy) noexcept {
have_pointer_ = true;
e.mods = mods_now(window_);
e.down = any_button_down(window_);
e.time_s = glfwGetTime(); // GLFW's monotonic clock; only differences are read
feed_pointer_event(e);
}
@@ -380,6 +384,7 @@ void Host::on_mouse_button(int button, int action, int mods) noexcept {
e.pressed = action == GLFW_PRESS;
e.released = action == GLFW_RELEASE;
e.down = any_button_down(window_);
e.time_s = glfwGetTime(); // the double-click detector reads differences of these
feed_pointer_event(e);
}
@@ -406,9 +411,16 @@ void Host::on_key(int key, int action, int mods) noexcept {
case GLFW_KEY_MINUS:
case GLFW_KEY_KP_SUBTRACT: mapped = input::kKeyZoomOut; break;
case GLFW_KEY_F: mapped = input::kKeyFit; break;
case GLFW_KEY_ESCAPE:
if (action == GLFW_PRESS) glfwSetWindowShouldClose(window_, GLFW_TRUE);
return;
// Arrows pan (10% of the span per press, repeats included) and Escape cancels
// the active drag: translated, exactly like the ASCII set above, and the
// controller decides what each key does. Escape no longer closes the window --
// the window manager's close button and Alt+F4 are the way to quit, and a key
// the controller has a gesture for must reach it.
case GLFW_KEY_ESCAPE: mapped = input::kKeyEscape; break;
case GLFW_KEY_LEFT: mapped = input::kKeyLeft; break;
case GLFW_KEY_RIGHT: mapped = input::kKeyRight; break;
case GLFW_KEY_UP: mapped = input::kKeyUp; break;
case GLFW_KEY_DOWN: mapped = input::kKeyDown; break;
default: return;
}
input::KeyEvent e;
@@ -438,12 +450,27 @@ void Host::set_figure(Figure* figure) noexcept {
spread_y_ = 0.0;
captured_ = false;
captured_row_ = 0u;
if (figure_ == nullptr) return;
captured_button_ = input::Button::Left;
if (figure_ == nullptr) {
row_rects_.clear(); // no figure, no rows: the handlers fall back to `controller_`
return;
}
const u32 rows = figure_->row_count();
row_controllers_.reserve(rows);
for (u32 i = 0; i < rows; ++i) {
row_controllers_.push_back(std::make_unique<input::Controller>(figure_->plot(i)));
}
// The new figure's rects go into the cache now, not at the next frame: an event
// arriving between set_figure() and the first frame -- a scripted run, the headless
// tests -- must be routed against the figure it was just handed, and the next
// `refresh_row_rects()` re-reads them once per frame as usual.
refresh_row_rects();
}
void Host::refresh_row_rects() noexcept {
// The one `Figure::layout()` per call: every event handler reads `row_rects_`
// instead of paying this again, which is the whole point of the cache (R7).
row_rects_ = figure_ != nullptr ? figure_->layout().rows : std::vector<Rect>{};
}
input::Controller* Host::row_controller(u32 row) noexcept {
@@ -452,10 +479,12 @@ input::Controller* Host::row_controller(u32 row) noexcept {
input::Controller& Host::controller_at(f32 x, f32 y) noexcept {
if (figure_ == nullptr || row_controllers_.empty()) return controller_;
// The figure's one layout for this call: both readers below take the row rects it produced, so an
// event lays the figure out once and not once per reader -- this is the hottest path a multi-row
// figure has, and a layout now rebuilds every row's ticks, grid and crosshair geometry.
const std::vector<Rect>& rows = figure_->layout().rows;
// The cached rects `refresh_row_rects()` filled -- the same ones the last frame
// drew -- and not a fresh `Figure::layout()`: an event pays no layout, which is
// what made a burst of pointer moves on a multi-row figure re-lay every row out
// per move (R7). Between a resize and the loop's next refresh the cache is one
// frame stale, which is the documented worst case.
const std::vector<Rect>& rows = row_rects_;
if (rows.empty() || rows.size() != row_controllers_.size()) return controller_;
(void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in()
const u32 row = row_in(rows, y);
@@ -472,13 +501,14 @@ void Host::feed_pointer_event(const input::PointerEvent& e) noexcept {
controller_.on_pointer(e);
return;
}
pointer_target(e.x, e.y, e.pressed, e.released).on_pointer(e);
pointer_target(e.x, e.y, e.button, e.pressed, e.released).on_pointer(e);
}
input::Controller& Host::pointer_target(f32 x, f32 y, bool pressed, bool released) noexcept {
input::Controller& Host::pointer_target(f32 x, f32 y, input::Button button, bool pressed,
bool released) noexcept {
(void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in()
if (figure_ == nullptr || row_controllers_.empty()) return controller_;
const std::vector<Rect>& rows = figure_->layout().rows;
const std::vector<Rect>& rows = row_rects_;
if (rows.empty() || rows.size() != row_controllers_.size()) return controller_;
u32 row = 0u;
@@ -488,12 +518,16 @@ input::Controller& Host::pointer_target(f32 x, f32 y, bool pressed, bool release
// to a controller that never saw the press, which would leave the owner's drag open
// for ever (and let a later gesture resume it from the old anchor).
row = captured_row_;
if (released) captured_ = false;
// Only the owning button's release ends the capture (R5): a second button's
// release -- pressed meanwhile as a chord -- must not strand the first button's
// drag, and its press must not steal the capture to the row it landed in.
if (released && button == captured_button_) captured_ = false;
} else {
row = row_in(rows, y);
if (pressed) {
captured_ = true;
captured_row_ = row;
captured_button_ = button;
}
}
// The captured row is re-read against this frame's rects: the figure can be resized
+51 -12
View File
@@ -128,9 +128,10 @@ class Host {
input::Controller* row_controller(u32 row) noexcept;
/// The controller an event at framebuffer pixel (x, y) belongs to: `controller_` --
/// the single-plot loop -- while no figure is set, and the controller of the figure
/// row that pixel lands in otherwise. The row rects come from `Figure::layout()`, the
/// same ones the renderer draws, so a gesture lands on the row it was aimed at; a
/// pixel inside no row -- the row gap a host asked for with `Figure::set_row_gap()`,
/// row that pixel lands in otherwise. The row rects come from the cache
/// `refresh_row_rects()` fills -- the same ones the renderer draws -- so a gesture
/// lands on the row it was aimed at and no event lays the figure out; a pixel
/// inside no row -- the row gap a host asked for with `Figure::set_row_gap()`,
/// or a margin -- is the *nearest* row rather than dropped or defaulted, so a drag
/// that leaves the data area still ends where it should.
///
@@ -147,13 +148,38 @@ class Host {
///
/// The routing is the row the event lands in, with one exception that is the whole
/// point of it: a press **captures** the row it landed in, and every motion and the
/// release until that button comes up go to the captured row. A drag that crosses
/// release until *that button* comes up go to the captured row. A drag that crosses
/// into a neighbouring figure row is therefore still the drag it began as -- the
/// owning controller sees the motion and the release that end it -- rather than
/// being handed to a controller whose `mode_ == None`, which would leave the owner's
/// drag open for ever and let a later gesture resume it from the old anchor.
///
/// Single-gesture policy: a figure routes one button-down gesture at a time. The
/// first button to press claims the capture; a second button pressed meanwhile --
/// a chord, or a trackpad that reports a second finger as a button -- neither
/// steals the capture to another row nor ends it, and only the owning button's
/// release ends it. Whatever the second button's events do to the controllers is
/// the controllers' business; the host only guarantees they all arrive at the row
/// the first press claimed.
void feed_pointer_event(const input::PointerEvent& e) noexcept;
/// Re-reads the figure's row rects into the cache every event handler below
/// reads -- the one `Figure::layout()` the routing costs between two frames. The
/// run loop calls this once per frame, right after the figure's viewport is set,
/// so the rects the handlers answer from are the rects that frame draws; the
/// handlers themselves never lay the figure out.
///
/// A driver that injects events without running the loop -- a scripted run, the
/// headless tests -- calls this after re-configuring the figure (a new viewport,
/// a new row gap, `set_figure()`): it is the scripted equivalent of the frame the
/// run loop would have staged. Until it runs, the handlers answer from the last
/// refreshed rects, which is also what happens across a window resize: GLFW
/// delivers the resize and any pointer events queued behind it in one batch, the
/// events before the resize see the rows the previous frame drew, and the loop's
/// next `refresh_row_rects()` brings the cache onto the new geometry -- rows are
/// at most one frame stale, never wrong by a second layout.
void refresh_row_rects() noexcept;
/// The gesture controller the host feeds. Valid for the host's whole life.
input::Controller& controller() noexcept { return controller_; }
@@ -198,11 +224,12 @@ class Host {
static u32 row_in(const std::vector<Rect>& rows, f32 y) noexcept;
/// The controller a pointer event at (x, y) belongs to, applying the button capture:
/// a press claims the row it landed in (`captured_row_`) and every motion and the
/// release until the button comes up are routed to it; `controller_at()` is the pure
/// query that never captures. Also hands the target its row's viewport, so the
/// hit-test geometry is the last frame's.
input::Controller& pointer_target(f32 x, f32 y, bool pressed, bool released) noexcept;
/// a press claims the row it landed in (`captured_row_` + `captured_button_`) and
/// every motion and the release until *that button* comes up are routed to it;
/// `controller_at()` is the pure query that never captures. Also hands the target
/// its row's viewport, so the hit-test geometry is the last frame's.
input::Controller& pointer_target(f32 x, f32 y, input::Button button, bool pressed,
bool released) noexcept;
/// The row the pointer's events currently go to: the captured row while a drag owns
/// the button, the row under the pointer otherwise. The run loop uses it to clear
@@ -254,11 +281,23 @@ class Host {
f64 spread_y_ = 0.0;
bool spread_active_ = false;
/// The row a button-down drag has captured: the row its press landed in, which
/// owns its motions and its release until the button comes up. `captured_` is
/// false between gestures, and cleared when another figure is set.
/// The row a button-down drag has captured, and the button that owns the capture:
/// the row its press landed in owns that button's motions and its release until
/// the button comes up. `captured_` is false between gestures, and cleared when
/// another figure is set. The button is recorded because only the owning button's
/// release ends the capture (R5): a second button pressed while the drag runs is
/// routed to the same row and must neither steal the capture nor end it, or its
/// release would strand the first button's drag.
bool captured_ = false;
u32 captured_row_ = 0u;
input::Button captured_button_ = input::Button::Left;
/// The figure's row rects as of the last `refresh_row_rects()` -- the cache the
/// event routing reads instead of laying the figure out per event (R7). Filled by
/// `set_figure()` and once per frame by the run loop; empty while no figure is
/// set, which is the state `controller_at()`/`pointer_target()` fall back to the
/// single-plot controller on.
std::vector<Rect> row_rects_;
u32 fb_width_ = 0; ///< framebuffer size of the last refresh_sizes()
u32 fb_height_ = 0;
+111
View File
@@ -229,4 +229,115 @@ inline f64 scroll_notches(f64 dy, ScrollUnit unit) noexcept {
return -dy;
}
/// The keyvals this adapter maps, as GDK's own numbers, mirrored here so the
/// mapping below -- and a test that drives it -- needs no GTK include directory.
/// `gtk_plot_area.cpp` static-asserts each against GDK's own macro.
///
/// These are the *function* keyvals (the `0xFF00` page), fixed by X11's keysym
/// convention GDK inherits; a keypad arrow (a keypad key with Num Lock off) is a
/// different keyval and deliberately unmapped, exactly as the GLFW adapter maps
/// the main-row arrows and not `GLFW_KEY_KP_4`.
constexpr u32 kGdkKeyEscape = 0xFF1Bu;
constexpr u32 kGdkKeyLeft = 0xFF51u;
constexpr u32 kGdkKeyUp = 0xFF52u;
constexpr u32 kGdkKeyRight = 0xFF53u;
constexpr u32 kGdkKeyDown = 0xFF54u;
/// One key-press translation, by keyval first and Unicode fallback second.
///
/// GTK names a key by its keyval, which is layout- and level-dependent: `+` is
/// usually shift+`=` and arrives as a different keyval, but both decode to the
/// same Unicode code point, which is why the printable keys go through
/// `gdk_keyval_to_unicode()`. The function keys decode to no code point at all,
/// so they are matched on the keyval itself, before that fallback. `0` means "no
/// plot key": the caller returns the event to GTK unhandled.
///
/// The keypad's plus and minus decode to the same code points as the main row's,
/// so they come along for free -- the same set the GLFW adapter maps with
/// `GLFW_KEY_KP_ADD`/`GLFW_KEY_KP_SUBTRACT`.
inline int key_from(u32 keyval, u32 unicode) noexcept {
switch (keyval) {
case kGdkKeyEscape: return input::kKeyEscape;
case kGdkKeyLeft: return input::kKeyLeft;
case kGdkKeyRight: return input::kKeyRight;
case kGdkKeyUp: return input::kKeyUp;
case kGdkKeyDown: return input::kKeyDown;
default: break;
}
switch (unicode) {
case static_cast<u32>(input::kKeyZoomIn):
case static_cast<u32>('='): return input::kKeyZoomIn;
case static_cast<u32>(input::kKeyZoomOut):
case static_cast<u32>('_'): return input::kKeyZoomOut;
case static_cast<u32>(input::kKeyFit):
case static_cast<u32>(input::kKeyFitUpper): return input::kKeyFit;
default: return 0;
}
}
/// The state one `GtkGestureZoom` is folded into `input::GestureEvent`s from.
///
/// `GtkGestureZoom` reports a **cumulative** scale (`gtk_gesture_zoom_get_scale_delta()`
/// and the `scale-changed` argument are the zoom since the gesture began, 1.0 at
/// the start) and knows the focal point only as the bounding box of the pointer
/// the gesture holds (`gtk_gesture_bounding_box_center()`), also cumulative in the
/// sense that it is where the fingers are *now*. The controller wants the
/// increments -- `GestureEvent::scale` is this frame's zoom factor about the focal
/// point, 1.0 = no change, and `(dx, dy)` the focal point's movement since the
/// previous gesture event -- so the area owns one of these and folds each
/// `scale-changed` exactly like the Qt adapter folds its pinch (`PinchTracker`).
///
/// The focal point is cached in **logical** widget pixels, like `DragAnchor` above,
/// for the same reason: the mapping's factor is re-derived on every `::resize`,
/// and a cache in device pixels would compare two points taken at different
/// factors. The difference of two logical points, scaled by the factor in force
/// now, is the movement the fingers actually made.
///
/// A plain value with no toolkit dependency, like every other type here: the area
/// owns one, and the tests drive it directly.
struct ZoomAnchor {
bool active = false; ///< true between the gesture's begin and its end
f32 last_scale = 1.0f; ///< the cumulative scale the previous update folded
f32 x = 0.0f; ///< last focal point, logical widget pixels
f32 y = 0.0f;
/// Starts a gesture at its first cumulative `scale` and logical focal point.
/// Records only: a zoom that just began has zoomed and moved nothing yet.
void begin(f32 scale, Vec2 logical_center) noexcept {
active = true;
last_scale = scale;
x = logical_center.x;
y = logical_center.y;
}
/// Folds one `scale-changed` into an incremental `GestureEvent`, in device
/// pixels at `mapping`: the zoom about the focal point, and the focal point's
/// movement since the previous update. A no-op before `begin()`, so a
/// `scale-changed` that arrives without a begin -- a host that missed the start
/// -- zooms nothing rather than zooming from a made-up anchor.
input::GestureEvent update(f32 scale, Vec2 logical_center,
const DeviceMapping& mapping) noexcept {
input::GestureEvent g;
if (!active) return g;
// This frame's zoom: the cumulative ratio over the one last folded. The first
// update after begin() divides the scale by itself -- 1.0, no zoom, as it
// should be when the fingers have not moved apart yet.
g.scale = last_scale != 0.0f ? scale / last_scale : 1.0f;
const Vec2 device = mapping.to_device(logical_center.x, logical_center.y);
g.x = device.x;
g.y = device.y;
const Vec2 moved{logical_center.x - x, logical_center.y - y};
const Vec2 device_moved = mapping.to_device(moved.x, moved.y);
g.dx = device_moved.x;
g.dy = device_moved.y;
last_scale = scale;
x = logical_center.x;
y = logical_center.y;
return g;
}
/// Ends the gesture: a straggler update after this zooms nothing.
void end() noexcept { active = false; }
};
} // namespace plot::gtk4
+321 -31
View File
@@ -18,6 +18,8 @@
#include <cstdio>
#include <cstring>
#include <dlfcn.h>
#include <limits>
#include <memory>
// The mirrored constants in gtk_input.hpp are pinned to GDK's here: a GDK that
// renumbered the scroll units or moved a modifier bit fails to compile rather
@@ -31,6 +33,11 @@ static_assert((1u << 2) == GDK_CONTROL_MASK, "mods_from's control bit must match
static_assert((1u << 3) == GDK_ALT_MASK, "mods_from's alt bit must match GDK's");
static_assert((1u << 26) == GDK_SUPER_MASK, "mods_from's super bit must match GDK's");
static_assert((1u << 28) == GDK_META_MASK, "mods_from's meta bit must match GDK's");
static_assert(plot::gtk4::kGdkKeyEscape == GDK_KEY_Escape, "the mirrored Escape keyval must match GDK's");
static_assert(plot::gtk4::kGdkKeyLeft == GDK_KEY_Left, "the mirrored Left keyval must match GDK's");
static_assert(plot::gtk4::kGdkKeyUp == GDK_KEY_Up, "the mirrored Up keyval must match GDK's");
static_assert(plot::gtk4::kGdkKeyRight == GDK_KEY_Right, "the mirrored Right keyval must match GDK's");
static_assert(plot::gtk4::kGdkKeyDown == GDK_KEY_Down, "the mirrored Down keyval must match GDK's");
namespace plot::gtk4 {
namespace {
@@ -158,6 +165,18 @@ PlotArea* PlotArea::create(Plot& plot) noexcept {
g_signal_connect(drag, "drag-end", G_CALLBACK(&PlotArea::on_drag_end), self);
gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(drag));
// The two-finger pinch: `scale-changed` carries the cumulative zoom since the
// gesture began, and the gesture's bounding-box centre is the focal point -- the
// pair `ZoomAnchor` folds into the incremental `GestureEvent` the controller
// takes (zoom about the focal point, focal movement as the pan). No button:
// unlike the click/drag pair this gesture is touch-only by nature, and a mouse
// never produces one.
GtkGesture* zoom = gtk_gesture_zoom_new();
g_signal_connect(zoom, "begin", G_CALLBACK(&PlotArea::on_zoom_begin), self);
g_signal_connect(zoom, "scale-changed", G_CALLBACK(&PlotArea::on_zoom_scale_changed), self);
g_signal_connect(zoom, "end", G_CALLBACK(&PlotArea::on_zoom_end), self);
gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(zoom));
// Vertical only, as the plan asks: a purely horizontal scroll is not a zoom, and
// because this controller never sees one it cannot be mistaken for one. GTK
// reports the unit per event, so a discrete wheel arrives in notches and a
@@ -200,9 +219,187 @@ void PlotArea::refresh_viewport() noexcept {
// chrome out with it) and refreshes the layout. `last_device_viewport()` is
// deliberately not assigned here -- it records the region a *frame* was drawn
// into, which is `render()`'s to set.
if (figure_ != nullptr) {
lay_figure_out(device_viewport());
return;
}
controller_.set_viewport(device_viewport());
}
// --- figure mode ----------------------------------------------------------
void PlotArea::set_figure(Figure* figure) noexcept {
figure_ = figure;
row_controllers_.clear();
last_row_ = 0;
// A new figure holds no crosshair this area has spread, and no captured press row:
// the controllers are rebuilt below, so an open press would name one that is gone.
spread_active_ = false;
spread_x_ = 0.0;
spread_y_ = 0.0;
press_open_ = false;
press_row_ = 0;
drag_.have = false;
zoom_.end();
if (figure_ == nullptr) {
row_rects_.clear(); // no figure, no rows: the routing falls back to `controller_`
return;
}
const u32 rows = figure_->row_count();
row_controllers_.reserve(rows);
for (u32 i = 0; i < rows; ++i) {
row_controllers_.push_back(std::make_unique<input::Controller>(figure_->plot(i)));
}
// The new figure's geometry goes into the cache now, not at the next frame: an event
// arriving between `set_figure()` and the first render -- which is every event a
// headless probe injects -- must be routed against the figure it was just handed.
lay_figure_out(device_viewport());
}
void PlotArea::lay_figure_out(Rect px) noexcept {
figure_->set_viewport(px);
refresh_row_rects();
for (u32 i = 0; i < row_controllers_.size() && i < row_rects_.size(); ++i) {
// Each row's own rect, which is the viewport `Figure::layout()` just gave that
// row's `Plot`: the controller and the plot must agree on it or a gesture lands on
// the wrong pixels. Set here rather than by the controller, which owns no viewport.
row_controllers_[i]->set_viewport(row_rects_[i]);
}
}
void PlotArea::refresh_row_rects() noexcept {
// The one `Figure::layout()` per call: every event handler reads `row_rects_`
// instead of paying this again, which is the whole point of the cache.
row_rects_ = figure_ != nullptr ? figure_->layout().rows : std::vector<Rect>{};
}
input::Controller* PlotArea::row_controller(u32 row) noexcept {
return row < row_controllers_.size() ? row_controllers_[row].get() : nullptr;
}
input::Controller& PlotArea::controller_at(f32 x, f32 y) noexcept {
if (figure_ == nullptr || row_controllers_.empty()) return controller_;
// The cached rects `refresh_row_rects()` filled -- the same ones the last frame drew
// -- and not a fresh `Figure::layout()`: an event pays no layout, or a burst of
// pointer motion over a multi-row figure would re-lay every row out per move.
if (row_rects_.empty() || row_rects_.size() != row_controllers_.size()) return controller_;
(void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in()
const u32 row = row_in(row_rects_, y);
input::Controller& controller = *row_controllers_[row];
controller.set_viewport(row_rects_[row]);
return controller;
}
u32 PlotArea::row_at(double x, double y) const noexcept {
if (figure_ == nullptr || row_rects_.empty()) return 0;
(void)x;
return row_in(row_rects_, to_device(x, y).y);
}
input::Controller& PlotArea::target_controller(f32 y) noexcept {
if (figure_ == nullptr || row_controllers_.empty()) return controller_;
if (row_rects_.empty() || row_rects_.size() != row_controllers_.size()) return controller_;
// The capture: while a press is open the gesture belongs to the row that press
// landed in, however far the pointer has travelled since. Handing a `drag-update` or
// the release to the row the pointer is over *now* would leave the first row's drag
// open for ever -- its controller never sees the release that ends it -- and let a
// later gesture resume it from the stale anchor.
u32 row = press_open_ ? press_row_ : row_in(row_rects_, y);
// Re-read against this frame's rects: the widget can be resized mid-drag, and the
// guard keeps a stale index from reaching past the controller list.
if (row >= row_controllers_.size()) row = 0;
last_row_ = row;
input::Controller& controller = *row_controllers_[row];
controller.set_viewport(row_rects_[row]);
return controller;
}
u32 PlotArea::row_in(const std::vector<Rect>& rows, f32 y) noexcept {
if (rows.empty()) return 0;
// The row the pixel is in, or -- when it is in none of them, which is the row gap a
// host asked for with `Figure::set_row_gap()` or the figure's own margins -- the row
// whose rect is nearest. By distance rather than by "the first that matches": the
// rows tile the figure at the default gap of 0, so this is unreachable until a host
// asks for a gap, and a gesture in a visible gap must not default to row 0.
u32 row = 0;
f32 best = std::numeric_limits<f32>::infinity();
for (u32 i = 0; i < rows.size(); ++i) {
const f32 top = rows[i].y;
const f32 bottom = rows[i].y + rows[i].h;
const f32 distance = y < top ? top - y : (y >= bottom ? y - bottom : 0.0f);
if (distance < best) {
best = distance;
row = i;
if (distance == 0.0f) break; // inside this row: nothing can be nearer
}
}
return row;
}
u32 PlotArea::pointer_row() const noexcept {
if (row_rects_.empty()) return 0;
// The captured row while a press owns the gesture, so an in-flight drag keeps its
// highlight even after the pointer has left the row it began in.
if (press_open_ && press_row_ < row_rects_.size()) return press_row_;
return row_in(row_rects_, pointer_.y);
}
void PlotArea::sync_figure_crosshair() noexcept {
if (row_rects_.empty() || row_controllers_.empty()) return;
const u32 row_count = static_cast<u32>(row_rects_.size());
// Which row's crosshair is the hover. With a pointer of this area's own it is the row
// that pointer's events go to -- the same rule the routing uses, so the hover follows
// the gesture it began. A headless probe injects its events straight into a row
// controller and the area never sees a pointer, so there the hover is the row whose
// crosshair is *not* the one last spread: the figure writes one value to every row,
// so a row holding anything else was changed from outside. Nothing differing means no
// new hover, and the figure keeps the one it has.
u32 hovered = row_count;
if (have_pointer_) {
hovered = pointer_row();
} else {
for (u32 i = 0; i < row_count; ++i) {
const Crosshair& candidate = figure_->plot(i).crosshair();
// A row differs when its active state, its x, or the y of *its own* horizontal
// line differs from what was spread: a pointer moving to another row at the same
// x is a new hover and must move the one horizontal line, not leave the old one.
const bool y_differs = candidate.horizontal && candidate.y != spread_y_;
if (candidate.active != spread_active_ ||
(candidate.active && (candidate.x != spread_x_ || y_differs))) {
hovered = i;
break;
}
}
if (hovered >= row_count) return;
}
// One x to every row, and the hovered row's **own** y only to that row (spec §17):
// the rows of a figure share x and not y -- row 0 may be volts and row 1 degrees --
// so a horizontal line spread across the figure would mean a different value in each.
// A row whose controller has *no* crosshair (the pointer left its axes, the only
// thing that clears one) clears the figure's, so the rows cannot be left showing a
// hover the pointer has abandoned.
const Plot& row = figure_->plot(hovered);
if (row.crosshair().active) {
if (row.crosshair().horizontal) {
figure_->set_crosshair(row.crosshair().x, hovered, row.crosshair().y, true);
} else {
// Vertical-only: a caller placed it through the single-coordinate
// `Figure::set_crosshair()`. The four-argument overload would turn the horizontal
// line on at the stored (meaningless) y == 0, clamped to the axes edge and made
// permanent, so only the vertical line is promoted.
figure_->set_crosshair(row.crosshair().x, true);
}
spread_x_ = row.crosshair().x;
spread_y_ = row.crosshair().y;
spread_active_ = true;
} else {
figure_->clear_crosshair();
spread_active_ = false;
}
}
// --- the GL signals -------------------------------------------------------
void PlotArea::realize() noexcept {
@@ -279,6 +476,7 @@ void PlotArea::unrealize() noexcept {
renderer_.reset();
press_open_ = false;
drag_.have = false;
zoom_.end();
}
void PlotArea::render() noexcept {
@@ -299,12 +497,36 @@ void PlotArea::render() noexcept {
// chrome out with it) and refreshes the layout, so every frame is laid out for
// the geometry of *this* frame. A resize changes the layout; it never scales a
// stale one.
if (figure_ != nullptr) {
// The figure's one layout for this frame: it hands every row its rect (and so
// every row's `Plot` its viewport) and fills the rect cache the routing of
// whatever GTK delivers before the next frame answers from.
lay_figure_out(viewport);
// One hover, every row: the crosshair the row under the pointer placed is applied
// to the whole figure here, after this frame's events have been handled and before
// anything lays the rows out again. Here rather than in a handler, so a headless
// probe that injects straight into a row controller is synced too.
sync_figure_crosshair();
// Only the row the pointer's events go to republishes its own hot handles, so a row
// the pointer has left would keep the `HotHandles` it last published and go on
// drawing a hovered marker or cursor grab as if the pointer were still on it. Clear
// every *other* row here, where that row is known.
if (have_pointer_ && !row_rects_.empty()) {
const u32 active = pointer_row();
for (u32 i = 0; i < row_rects_.size() && i < row_controllers_.size(); ++i) {
if (i != active) figure_->plot(i).set_hot_handles(HotHandles{});
}
}
} else {
controller_.set_viewport(viewport);
}
const f64 started = now_s();
const f64 dt = last_render_s_ >= 0.0 ? started - last_render_s_ : 0.0;
last_render_s_ = started;
if (frame_) frame_(*plot_, dt);
// A figure's rows take the pointer, but the frame callback is still given a `Plot&`:
// row 0 is the one it gets, so the callback argument and `figure()->plot(0)` agree.
if (frame_) frame_(figure_ != nullptr ? figure_->plot(0u) : *plot_, dt);
// The mapping's factor is the display's scale, so the renderer draws its logical
// style metrics at the same ratio the viewport and every forwarded coordinate are
@@ -313,7 +535,11 @@ void PlotArea::render() noexcept {
// ceiling under fractional scaling and would over-scale the chrome by up to 60%.
renderer_->style().ui_scale = mapping_.factor;
renderer_->begin_frame(static_cast<u32>(viewport.w), static_cast<u32>(viewport.h), viewport);
if (figure_ != nullptr) {
renderer_->draw(*figure_);
} else {
renderer_->draw(*plot_);
}
renderer_->end_frame();
const f32 frame_ms = static_cast<f32>((now_s() - started) * 1.0e3);
@@ -364,13 +590,19 @@ void PlotArea::on_resize(GtkGLArea*, int width, int height, void* self) noexcept
// --- pointer input --------------------------------------------------------
void PlotArea::pointer_press(input::Button button, u32 mods, double x, double y) noexcept {
void PlotArea::pointer_press(input::Button button, u32 mods, double x, double y,
u32 time_ms) noexcept {
if (press_open_) return; // one press per button-down sequence: see drag_begin
const Vec2 p = to_device(x, y);
// The figure row this gesture belongs to for its whole life, claimed from the press's
// own position *before* the event is dispatched (see `target_controller()`). Harmless
// and unread while no figure is set.
press_row_ = figure_ != nullptr ? row_in(row_rects_, p.y) : 0;
press_open_ = true;
press_button_ = button;
press_mods_ = mods;
const Vec2 p = to_device(x, y);
pointer_ = p;
have_pointer_ = true;
@@ -383,12 +615,17 @@ void PlotArea::pointer_press(input::Button button, u32 mods, double x, double y)
e.mods = mods;
e.down = true;
e.pressed = true;
controller_.on_pointer(e);
e.time_s = pointer_time_s(time_ms); // the double-click detector differences these
target_controller(p.y).on_pointer(e);
gtk_gl_area_queue_render(GTK_GL_AREA(widget_));
}
void PlotArea::pointer_release(Vec2 position) noexcept {
void PlotArea::pointer_release(Vec2 position, u32 time_ms) noexcept {
if (!press_open_) return;
// Resolved while the press is still open, so the release reaches the row the press
// claimed rather than the row the pointer ended over -- the controller that owns the
// drag is the only one that can close it.
input::Controller& target = target_controller(position.y);
press_open_ = false;
input::PointerEvent e;
@@ -400,7 +637,8 @@ void PlotArea::pointer_release(Vec2 position) noexcept {
e.mods = press_mods_;
e.down = false;
e.released = true;
controller_.on_pointer(e);
e.time_s = pointer_time_s(time_ms);
target.on_pointer(e);
gtk_gl_area_queue_render(GTK_GL_AREA(widget_));
}
@@ -412,7 +650,8 @@ void PlotArea::on_click_pressed(GtkGestureClick* gesture, int, double x, double
if (!button) return; // a button the controller has no gesture for
// The modifiers come off the *event*, not the gesture: GTK reports the state as
// it was before the press, which is what the controller's gesture table reads.
area->pointer_press(*button, gesture_mods(GTK_EVENT_CONTROLLER(gesture)), x, y);
area->pointer_press(*button, gesture_mods(GTK_EVENT_CONTROLLER(gesture)), x, y,
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture)));
}
void PlotArea::on_click_released(GtkGestureClick* gesture, int, double x, double y,
@@ -421,11 +660,11 @@ void PlotArea::on_click_released(GtkGestureClick* gesture, int, double x, double
auto* area = static_cast<PlotArea*>(self);
// The first release closes the gesture; a drag's own `drag-end` may already have
// done it (GTK emits both, `drag-end` first), and this is then a no-op.
area->pointer_release(area->to_device(x, y));
area->pointer_release(area->to_device(x, y),
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture)));
}
void PlotArea::on_click_stopped(GtkGestureClick* gesture, void* self) noexcept {
(void)gesture;
auto* area = static_cast<PlotArea*>(self);
// A sequence can be cancelled rather than released -- GTK stops the click
// gesture as soon as the drag gesture claims the same sequence, which happens on
@@ -434,7 +673,11 @@ void PlotArea::on_click_stopped(GtkGestureClick* gesture, void* self) noexcept {
// drag in progress (an exotic cancellation by another widget or a device) closes
// the press, which it has to, or the controller would stay in whatever mode the
// press began.
if (area->press_open_ && !area->drag_.have) area->pointer_release(area->pointer_);
if (area->press_open_ && !area->drag_.have) {
area->pointer_release(
area->pointer_,
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture)));
}
}
void PlotArea::on_drag_begin(GtkGestureDrag* gesture, double start_x, double start_y,
@@ -453,7 +696,8 @@ void PlotArea::on_drag_begin(GtkGestureDrag* gesture, double start_x, double sta
const std::optional<input::Button> button =
button_from(gtk_gesture_single_get_current_button(GTK_GESTURE_SINGLE(gesture)));
if (button) {
area->pointer_press(*button, gesture_mods(GTK_EVENT_CONTROLLER(gesture)), start_x, start_y);
area->pointer_press(*button, gesture_mods(GTK_EVENT_CONTROLLER(gesture)), start_x, start_y,
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture)));
}
}
@@ -482,13 +726,16 @@ void PlotArea::on_drag_update(GtkGestureDrag* gesture, double offset_x, double o
e.button = area->press_button_;
e.mods = area->press_mods_;
e.down = true;
area->controller_.on_pointer(e);
e.time_s = area->pointer_time_s(
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture)));
// The press's row, not the row the pointer has reached: `press_open_` is true here,
// so a drag that crosses a row boundary stays with the controller that began it.
area->target_controller(sample.position.y).on_pointer(e);
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
}
void PlotArea::on_drag_end(GtkGestureDrag* gesture, double offset_x, double offset_y,
void* self) noexcept {
(void)gesture;
auto* area = static_cast<PlotArea*>(self);
// The release carries the authoritative end of the gesture: the controller reads
// its position to finish a box zoom's selection, so this is the press point plus
@@ -496,7 +743,48 @@ void PlotArea::on_drag_end(GtkGestureDrag* gesture, double offset_x, double offs
// and not the last update's position.
const Vec2 position = area->drag_.end(offset_x, offset_y, area->mapping_);
area->pointer_ = position;
area->pointer_release(position);
area->pointer_release(
position, gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture)));
}
// --- the pinch ------------------------------------------------------------
void PlotArea::on_zoom_begin(GtkGestureZoom* gesture, GdkEventSequence*, void* self) noexcept {
auto* area = static_cast<PlotArea*>(self);
// The focal point is the centre of the bounding box around the pointers the
// gesture holds, in logical widget pixels -- the same space every other
// gesture coordinate here arrives in, and what `to_device()` maps.
double cx = 0.0;
double cy = 0.0;
gtk_gesture_get_bounding_box_center(GTK_GESTURE(gesture), &cx, &cy);
area->zoom_.begin(static_cast<f32>(gtk_gesture_zoom_get_scale_delta(gesture)),
Vec2{static_cast<f32>(cx), static_cast<f32>(cy)});
}
void PlotArea::on_zoom_scale_changed(GtkGestureZoom* gesture, double scale,
void* self) noexcept {
auto* area = static_cast<PlotArea*>(self);
double cx = 0.0;
double cy = 0.0;
gtk_gesture_get_bounding_box_center(GTK_GESTURE(gesture), &cx, &cy);
// The `scale` argument and `gtk_gesture_zoom_get_scale_delta()` are the same
// cumulative number -- the zoom since `::begin` -- so the folded event is this
// *frame's* zoom factor about the fingers' centre, plus the centre's movement,
// which is the two-finger pan. An update without a begin folds to a no-op (see
// `ZoomAnchor::update`), so a gesture whose start the host never saw zooms
// nothing rather than zooming from a made-up anchor.
const input::GestureEvent g = area->zoom_.update(
static_cast<f32>(scale), Vec2{static_cast<f32>(cx), static_cast<f32>(cy)}, area->mapping_);
// The folded event carries the focal point, which is the position this pinch belongs
// to; while a press is open the capture overrides it, as it does for a drag.
area->target_controller(g.y).on_gesture(g);
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
}
void PlotArea::on_zoom_end(GtkGestureZoom*, GdkEventSequence*, void* self) noexcept {
// No trailing event: the last `scale-changed` carried the gesture's final zoom,
// and `end` only closes the folding so a straggler update zooms nothing.
static_cast<PlotArea*>(self)->zoom_.end();
}
int PlotArea::on_scroll(GtkEventControllerScroll* scroll, double dx, double dy,
@@ -528,7 +816,7 @@ int PlotArea::on_scroll(GtkEventControllerScroll* scroll, double dx, double dy,
e.dx = static_cast<f32>(scroll_notches(dx, unit));
e.dy = static_cast<f32>(notches);
e.mods = gesture_mods(GTK_EVENT_CONTROLLER(scroll));
area->controller_.on_wheel(e);
area->target_controller(at.y).on_wheel(e);
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
return TRUE; // consumed: the plot zoomed
}
@@ -550,7 +838,9 @@ void PlotArea::on_motion(GtkEventControllerMotion* motion, double x, double y, v
e.dx = 0.0f;
e.dy = 0.0f;
e.down = false;
area->controller_.on_pointer(e);
e.time_s = PlotArea::pointer_time_s(
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(motion)));
area->target_controller(p.y).on_pointer(e);
}
// --- keyboard -------------------------------------------------------------
@@ -560,20 +850,14 @@ int PlotArea::on_key_pressed(GtkEventControllerKey* keys, unsigned int keyval,
(void)keycode;
(void)keys;
auto* area = static_cast<PlotArea*>(self);
// The keys are named by their *symbol*: GTK reports a layout's keyval, and the
// keypad's plus and minus are the same symbol as the main row's. `'='` is mapped
// with `'+'` so both spellings of "zoom in" work (on most layouts `+` *is*
// shift+`=`), the pair the GLFW and Qt adapters map.
int key = 0;
switch (gdk_keyval_to_unicode(keyval)) {
case static_cast<guint32>(input::kKeyZoomIn):
case static_cast<guint32>('='): key = input::kKeyZoomIn; break;
case static_cast<guint32>(input::kKeyZoomOut):
case static_cast<guint32>('_'): key = input::kKeyZoomOut; break;
case static_cast<guint32>(input::kKeyFit):
case static_cast<guint32>(input::kKeyFitUpper): key = input::kKeyFit; break;
default: return FALSE; // not a plot key: GTK and the window keep it
}
// The whole translation lives in gtk_input.hpp (see `key_from`): the function
// keys -- arrows, Escape -- are matched on the *keyval*, which is layout- and
// level-independent for them, and the printable ones on the Unicode they decode
// to, which is what makes `+` and shift+`=` (usually the same physical key) both
// zoom in, and the keypad's plus and minus come along for free. `0` is "no plot
// key": GTK and the window keep it.
const int key = key_from(static_cast<u32>(keyval), static_cast<u32>(gdk_keyval_to_unicode(keyval)));
if (key == 0) return FALSE;
input::KeyEvent e;
e.key = key;
@@ -581,7 +865,13 @@ int PlotArea::on_key_pressed(GtkEventControllerKey* keys, unsigned int keyval,
// GTK reports a held key as repeated presses and sends no release the controller
// would act on anyway (its actions are press-only), so the press is the event.
e.down = true;
area->controller_.on_key(e);
// A key has no position of its own, so it goes to the row the last event was routed
// to -- the row the user is looking at.
input::Controller& controller =
(area->figure_ == nullptr || area->last_row_ >= area->row_controllers_.size())
? area->controller_
: *area->row_controllers_[area->last_row_];
controller.on_key(e);
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
return TRUE; // consumed: the plot acted on it
}
+164 -4
View File
@@ -4,14 +4,17 @@
#pragma once
#include <functional>
#include <memory>
#include <optional>
#include <utility>
#include <vector>
#include <plot/core/rect.hpp>
#include <plot/core/status.hpp>
#include <plot/core/types.hpp>
#include <plot/input/controller.hpp>
#include <plot/math/vec2.hpp>
#include <plot/plot/figure.hpp>
#include <plot/plot/plot.hpp>
#include <plot/render/renderer.hpp>
@@ -32,10 +35,12 @@ typedef struct _GtkGLArea GtkGLArea;
typedef struct _GtkGesture GtkGesture;
typedef struct _GtkGestureClick GtkGestureClick;
typedef struct _GtkGestureDrag GtkGestureDrag;
typedef struct _GtkGestureZoom GtkGestureZoom;
typedef struct _GtkEventControllerScroll GtkEventControllerScroll;
typedef struct _GtkEventControllerKey GtkEventControllerKey;
typedef struct _GtkEventControllerMotion GtkEventControllerMotion;
typedef struct _GdkGLContext GdkGLContext;
typedef struct _GdkEventSequence GdkEventSequence;
namespace plot::gtk4 {
@@ -134,6 +139,10 @@ using FrameFn = std::function<void(Plot&, f64 dt)>;
/// release, with the button and the modifier state, exactly as the Qt
/// widget's `mousePressEvent`/`mouseReleaseEvent` do. It is also what begins
/// a cursor move: a ctrl+press on a cursor opens `DragMode::MoveCursor` there.
/// * `GtkGestureZoom` supplies the two-finger pinch: each `scale-changed` is
/// folded by `ZoomAnchor` (`gtk_input.hpp`) into one `GestureEvent` -- this
/// frame's zoom about the fingers' centre, and the centre's movement, which is
/// the two-finger pan a trackpad produces alongside it.
/// * `GtkEventControllerMotion` supplies hover (the cursor hit test reads the
/// last position even with no button down). Its positions are forwarded with
/// `down == false` on purpose: a motion event that claimed a button was down
@@ -165,6 +174,42 @@ using FrameFn = std::function<void(Plot&, f64 dt)>;
/// visible without waiting for the host's next frame, and the frame rate stays the
/// host's: the demo pumps `g_timeout_add(16, gtk_gl_area_queue_render)`.
///
/// ## Figure mode
///
/// `set_figure()` makes the area draw a whole `Figure` -- several rows sharing one
/// x axis -- instead of its own `Plot`, exactly as `glfw::Host::set_figure()` does
/// for the reference host, and for the same reason: the multi-row shape is one
/// `Figure`, not N widgets, because the x link groups, the shared y gutter and the
/// one shared x axis strip all live inside it and cannot be reassembled from
/// separate areas. The GTK plumbing above is unchanged by it; what changes is where
/// an event goes and what the renderer is handed.
///
/// A `Controller` is bound to a plot at construction, so figure mode holds **one
/// controller per row**, created by `set_figure()`, and routes each event to the row
/// it lands in (`controller_at()`). The row rects come from a cache
/// (`refresh_row_rects()`) that the render callback fills once per frame, so a burst
/// of pointer motion costs no layouts; between a resize and the next frame the cache
/// is one frame stale, which is the documented worst case.
///
/// A gesture belongs to the row its **press** landed in for its whole life: the
/// press claims the row, and every `drag-update`, pinch, wheel and the release that
/// follow while that press is open go there, however far the pointer travels. A drag
/// that crosses into the row below is therefore still the drag it began as, instead
/// of being handed to a controller that never saw the press -- which would leave the
/// first row's drag open for ever and let a later gesture resume it from the stale
/// anchor. The single open press `press_open_` already enforces (see the input
/// section) is that capture; this adapter needs no separate button bookkeeping the
/// way the GLFW host does, because GTK's two gestures share one sequence.
///
/// The **crosshair** is one crosshair over the figure and not one per row: the row
/// under the pointer places it, and `sync_figure_crosshair()` reads it back and
/// applies it to every row, so each row's readout lists its own traces at the one x
/// the pointer is over. Reading it back rather than deriving a second coordinate
/// here is what keeps the row's hover and the figure's the same number.
///
/// A key event has no position, so it goes to the row the last event was routed to,
/// which is the row the user is looking at.
///
/// ## Headless use
///
/// A `GtkGLArea` renders into its own FBO, so a screenshot of the *plot* is a
@@ -196,10 +241,56 @@ class PlotArea {
/// The plot this area renders. Valid for the widget's whole life.
Plot& plot() noexcept { return *plot_; }
const Plot& plot() const noexcept { return *plot_; }
/// The controller this area feeds, bound to `plot()`.
/// The controller this area feeds, bound to `plot()`. **Not** the one a figure's
/// gestures reach: see `row_controller()`.
input::Controller& controller() noexcept { return controller_; }
const input::Controller& controller() const noexcept { return controller_; }
/// Draws `figure` instead of this area's own plot, with one `input::Controller` per
/// row and every event routed to the row it lands in (see "Figure mode" above).
/// The figure is borrowed and must outlive the widget.
///
/// The frame callback is still handed a `Plot&`, and in figure mode it is **row
/// 0**, so the callback's argument and `figure()->plot(0)` are the same plot --
/// matching `glfw::Host`. A host that needs the figure itself captures it.
///
/// Passing null restores the single-plot path; `plot()` is untouched throughout,
/// so the area goes back to drawing exactly what it drew before.
void set_figure(Figure* figure) noexcept;
Figure* figure() noexcept { return figure_; }
const Figure* figure() const noexcept { return figure_; }
/// One figure row's controller -- the one a gesture on that row reaches -- or null
/// while no figure is set or the row does not exist. A host reads a drag mode or a
/// hover out of it.
input::Controller* row_controller(u32 row) noexcept;
/// The controller an event at device pixel `(x, y)` belongs to: `controller()`
/// while no figure is set, and otherwise the controller of the figure row that
/// pixel lands in -- a pixel inside no row (a `Figure::set_row_gap()` gap, a
/// margin) is the *nearest* row rather than dropped, so a drag that leaves the data
/// area still ends where it should. It also hands that row its own viewport, which
/// is what keeps the controller's hit-test geometry in step with the layout between
/// two frames.
///
/// The pure query: it reports what a gesture *would* land on and never claims the
/// press capture, so a host may call it freely.
input::Controller& controller_at(f32 x, f32 y) noexcept;
/// The figure row a point in the widget's **logical** coordinates lands in -- the
/// coordinates every GTK event controller reports, so a host's own drop target or
/// context menu can name the row a pointer is over without converting anything. The
/// same nearest-row rule as `controller_at()`, and 0 while no figure is set.
u32 row_at(double x, double y) const noexcept;
/// Re-reads the figure's row rects into the cache the event routing answers from --
/// the one `Figure::layout()` the routing costs between two frames. The render
/// callback runs it once per frame, right after the figure's viewport is set, so
/// the rects the handlers use are the rects that frame draws. A driver that injects
/// events without rendering (a headless probe) calls it after re-configuring the
/// figure; a no-op with no figure set.
void refresh_row_rects() noexcept;
/// The renderer, or null until a successful `::realize`. Valid from the first
/// render onwards, and after the widget's context exists.
Renderer* renderer() noexcept { return renderer_ ? &*renderer_ : nullptr; }
@@ -271,13 +362,46 @@ class PlotArea {
void refresh_viewport() noexcept;
/// One pointer event, from whichever gesture saw it first. Owns the
/// press/release bookkeeping the two gestures share.
void pointer_press(input::Button button, u32 mods, double x, double y) noexcept;
void pointer_release(Vec2 position) noexcept;
/// press/release bookkeeping the two gestures share. `time_ms` is the event's
/// own GDK stamp, in milliseconds, forwarded as the `PointerEvent::time_s` the
/// double-click detector differences (see `pointer_time_s()`).
void pointer_press(input::Button button, u32 mods, double x, double y,
u32 time_ms) noexcept;
void pointer_release(Vec2 position, u32 time_ms) noexcept;
/// The gesture event's stamp, in the seconds `PointerEvent::time_s` speaks: GDK
/// counts milliseconds since an arbitrary epoch, and only differences are read,
/// so the epoch does not matter -- the unit does.
static f64 pointer_time_s(u32 time_ms) noexcept { return static_cast<f64>(time_ms) * 1.0e-3; }
/// Translates the gesture-reported position/delta in *logical* widget pixels
/// into device pixels. The one place that mapping exists.
Vec2 to_device(double x, double y) const noexcept;
/// The controller one event of this area's goes to, given the event's device y.
/// `controller_` while no figure is set; otherwise the row the gesture *belongs*
/// to: the row the open press claimed while a press is open, and the row under `y`
/// between gestures. That is the capture `controller_at()` deliberately omits, and
/// it is why this -- not `controller_at()` -- is what every handler below calls.
///
/// Hands the chosen row its own viewport, so the geometry it hit-tests is the
/// geometry the last frame drew.
input::Controller& target_controller(f32 y) noexcept;
/// The row of `row_rects_` a device y belongs to: the row it is in, or the nearest
/// one when it is in none (a row gap, a margin outside the figure). Selection by
/// distance rather than "the first that matches" is what makes a pixel in a visible
/// gap belong to the row it is *near* instead of to row 0.
static u32 row_in(const std::vector<Rect>& rows, f32 y) noexcept;
/// The row this area's events currently go to: the press's row while one is open,
/// so an in-flight gesture keeps its highlight, and the row under the last pointer
/// position otherwise. 0 with no rows.
u32 pointer_row() const noexcept;
/// Spreads one row's crosshair over the whole figure (see "Figure mode"). Run once
/// per frame, from the render callback, after this frame's row rects are cached.
void sync_figure_crosshair() noexcept;
/// `figure_->set_viewport(px)`, one `layout()` into the rect cache, and each row's
/// own rect to its controller. The figure-mode half of `refresh_viewport()` and of
/// `render()`, which must not drift apart.
void lay_figure_out(Rect px) noexcept;
static void on_realize(GtkGLArea* area, void* self) noexcept;
static void on_unrealize(GtkGLArea* area, void* self) noexcept;
/// `gboolean` is `int`; a GtkGLArea render handler ignores the context.
@@ -289,6 +413,15 @@ class PlotArea {
static void on_click_released(GtkGestureClick* gesture, int n_press, double x, double y,
void* self) noexcept;
static void on_click_stopped(GtkGestureClick* gesture, void* self) noexcept;
/// The pinch: `begin` anchors the folding, `scale-changed` folds one cumulative
/// scale into an incremental `GestureEvent`, and `end` closes it. `begin`/`end`
/// carry the sequence, which a one-active-pinch adapter never needs.
static void on_zoom_begin(GtkGestureZoom* gesture, GdkEventSequence* sequence,
void* self) noexcept;
static void on_zoom_scale_changed(GtkGestureZoom* gesture, double scale,
void* self) noexcept;
static void on_zoom_end(GtkGestureZoom* gesture, GdkEventSequence* sequence,
void* self) noexcept;
static void on_drag_begin(GtkGestureDrag* gesture, double start_x, double start_y,
void* self) noexcept;
static void on_drag_update(GtkGestureDrag* gesture, double offset_x, double offset_y,
@@ -307,6 +440,26 @@ class PlotArea {
Plot* plot_ = nullptr; ///< declared first: the controller is built over it
input::Controller controller_;
/// The figure this area draws instead of `plot_`, and one controller per row for it
/// -- one per `Plot`, because a `Controller` is bound to a plot at construction and
/// this is what makes a gesture on any row reach that row. Both are empty until
/// `set_figure()` is called, and the single-plot path reads neither.
Figure* figure_ = nullptr;
std::vector<std::unique_ptr<input::Controller>> row_controllers_;
/// The figure's row rects as of the last `refresh_row_rects()`: the cache the event
/// routing reads instead of laying the figure out per event. Empty while no figure
/// is set, which is the state the routing falls back to `controller_` on.
std::vector<Rect> row_rects_;
/// The row the last event was routed to, which is where a key event goes: a key has
/// no position of its own.
u32 last_row_ = 0;
/// The crosshair last spread over the figure (see `sync_figure_crosshair()`): what a
/// row is compared against to find the one something outside changed, since the
/// figure writes one value to every row. `spread_active_` false means "spread
/// nothing" -- the state before the first sync, and the one a clear leaves.
f64 spread_x_ = 0.0;
f64 spread_y_ = 0.0;
bool spread_active_ = false;
GtkWidget* widget_ = nullptr;
std::optional<Renderer> renderer_;
FrameFn frame_;
@@ -318,12 +471,19 @@ class PlotArea {
bool press_open_ = false;
input::Button press_button_ = input::Button::Left;
u32 press_mods_ = 0;
/// The figure row the open press landed in -- the gesture's capture (see
/// `target_controller()`). Meaningless while `press_open_` is false and while no
/// figure is set.
u32 press_row_ = 0;
/// The drag gesture's anchor: its press point and last offset, in *logical*
/// pixels. Logical on purpose: a factor change between two samples of a drag
/// cannot then turn into a phantom movement (see `DragAnchor`). The offset is
/// cached rather than re-read per event, so a `drag-update` reports the movement
/// since the previous one, and `have` is whether a drag is recognised.
DragAnchor drag_;
/// The pinch gesture's folding state (see `ZoomAnchor`): one per area, alive
/// between `::begin` and `::end` of the one zoom the gesture recognises.
ZoomAnchor zoom_;
/// The last pointer position seen, in device pixels: the wheel's anchor when a
/// scroll event carries no position of its own.
Vec2 pointer_{};
+23 -9
View File
@@ -201,6 +201,11 @@ void PlotWidget::forward_input(bool hovered) {
e.down = any_down;
e.pressed = pressed;
e.released = released;
// ImGui's own clock: monotonic seconds since the context was created. The
// double-click detector differences two presses' stamps, so only the unit
// matters -- and ImGui's backend-driven time keeps running while the pointer
// is off-window, which is exactly what a wall clock should do.
e.time_s = ImGui::GetTime();
controller_->on_pointer(e);
};
@@ -231,6 +236,15 @@ void PlotWidget::forward_input(bool hovered) {
// Keyboard gestures, on the same hover gate as the pointer: a key pressed
// while the plot is under the pointer is aimed at the plot. `+` on most
// layouts is shift+`=`, and either spelling zooms in, as in the GLFW adapter.
//
// Every mapped key is asked with repeat = true. This is the adapter's whole key
// -repeat policy, and the odd one out among the hosts: GLFW, Qt and GTK each
// deliver the OS's auto-repeat as extra press events, while ImGui edge-detects
// presses once and only re-fires `IsKeyPressed` while the key is *held* if it is
// asked to. Holding `+` or an arrow on a plot is a zoom/pan the user expects to
// keep going (docs/embedding.md's shared policy: a held key repeats in every
// adapter), so the repeat is requested here. Escape repeats harmlessly --
// cancelling an already-cancelled drag is a no-op.
if (hovered && !io.WantTextInput) {
const auto key = [&](int code) {
input::KeyEvent e;
@@ -239,15 +253,15 @@ void PlotWidget::forward_input(bool hovered) {
e.down = true; // ImGui already edge-detected the press
controller_->on_key(e);
};
if (ImGui::IsKeyPressed(ImGuiKey_Equal, false) ||
ImGui::IsKeyPressed(ImGuiKey_KeypadAdd, false)) {
key(input::kKeyZoomIn);
}
if (ImGui::IsKeyPressed(ImGuiKey_Minus, false) ||
ImGui::IsKeyPressed(ImGuiKey_KeypadSubtract, false)) {
key(input::kKeyZoomOut);
}
if (ImGui::IsKeyPressed(ImGuiKey_F, false)) key(input::kKeyFit);
const auto pressed = [](ImGuiKey k) { return ImGui::IsKeyPressed(k, true); };
if (pressed(ImGuiKey_Equal) || pressed(ImGuiKey_KeypadAdd)) key(input::kKeyZoomIn);
if (pressed(ImGuiKey_Minus) || pressed(ImGuiKey_KeypadSubtract)) key(input::kKeyZoomOut);
if (pressed(ImGuiKey_F)) key(input::kKeyFit);
if (pressed(ImGuiKey_LeftArrow)) key(input::kKeyLeft);
if (pressed(ImGuiKey_RightArrow)) key(input::kKeyRight);
if (pressed(ImGuiKey_UpArrow)) key(input::kKeyUp);
if (pressed(ImGuiKey_DownArrow)) key(input::kKeyDown);
if (pressed(ImGuiKey_Escape)) key(input::kKeyEscape);
}
prev_down_[0] = down[0];
+105 -11
View File
@@ -11,6 +11,9 @@
#include <QOpenGLContext>
#include <QPointF>
#include <QWheelEvent>
#include <QEvent>
#include <QGestureEvent>
#include <QPinchGesture>
#include <chrono>
#include <cmath>
@@ -93,6 +96,10 @@ PlotWidget::PlotWidget(Plot& plot, QWidget* parent)
// The controller's keys (+, - and f) are input to the plot, and a widget without
// a focus policy never receives a key event.
setFocusPolicy(Qt::StrongFocus);
// Two-finger zoom/pan arrives as one gesture, which no mouse-event handler sees:
// grabbing it routes QEvent::Gesture here, and `event()` folds it into the
// controller's `GestureEvent`s (zoom about the focal point, focal pan).
grabGesture(Qt::PinchGesture);
}
PlotWidget::~PlotWidget() {
@@ -243,6 +250,10 @@ void PlotWidget::forward_pointer(QMouseEvent* event, bool pressed, bool released
e.down = any_plot_button(event->buttons());
e.pressed = pressed;
e.released = released;
// Qt's input-event stamp is milliseconds since an arbitrary epoch (the system
// monotonic clock, in practice); the controller only ever differences them, for
// the double-click detector, so the epoch does not matter -- the unit does.
e.time_s = static_cast<f64>(event->timestamp()) * 1.0e-3;
controller_.on_pointer(e);
event->accept();
update();
@@ -256,16 +267,26 @@ void PlotWidget::mouseReleaseEvent(QMouseEvent* event) { forward_pointer(event,
void PlotWidget::mouseDoubleClickEvent(QMouseEvent* event) { forward_pointer(event, true, false); }
void PlotWidget::wheelEvent(QWheelEvent* event) {
// Qt reports eighths of a degree, 120 to a notch; the controller takes notches,
// positive = zoom in, which is Qt's positive angleDelta().y() as well (the wheel
// pushed away from the user). Passing the raw delta would zoom 120x too far.
const f32 notches = static_cast<f32>(event->angleDelta().y()) / 120.0f;
if (notches == 0.0f) {
// A horizontal-only wheel is not a zoom, and it must not reach the controller:
// a zero-notch zoom changes no range but still runs the range setters, which
// clear auto scale -- so a stray horizontal scroll would silently stop a live
// axis from following its stream. Ignored, so an enclosing scroll area may
// still act on it.
// Two units arrive, one per axis: Qt's `angleDelta()` is in eighths of a degree,
// 120 to a notch, while `pixelDelta()` is in (high-resolution) scroll pixels and
// is the only non-zero delta a device that reports pixels produces. A pixel
// delta has no notch size Qt defines, so the divisor is this adapter's
// **calibration**, not a conversion: 40 scroll pixels are taken to be one notch,
// the order of a deliberate one- or two-finger scroll gesture, so such a gesture
// zooms about one detent instead of a fraction of one (the same reasoning, and
// the same role, as the GTK adapter's kSurfacePixelsPerNotch; a value measured
// on real hardware should replace it).
const QPoint angle = event->angleDelta();
const QPoint pixels = event->pixelDelta();
const bool pixel_scrolled = angle == QPoint(0, 0) && !pixels.isNull();
const f32 notches = pixel_scrolled ? static_cast<f32>(pixels.y()) / 40.0f
: static_cast<f32>(angle.y()) / 120.0f;
const f32 notches_x = pixel_scrolled ? static_cast<f32>(pixels.x()) / 40.0f
: static_cast<f32>(angle.x()) / 120.0f;
if (notches == 0.0f && notches_x == 0.0f) {
// No zoom and no pan: the event moves nothing, so it must not reach the
// controller -- a zero-delta event would still run the range setters and clear
// auto scale. Ignored, so an enclosing scroll area may still act on it.
event->ignore();
return;
}
@@ -273,7 +294,10 @@ void PlotWidget::wheelEvent(QWheelEvent* event) {
input::WheelEvent e;
e.x = p.x;
e.y = p.y;
e.dx = static_cast<f32>(event->angleDelta().x()) / 120.0f;
// The horizontal delta is forwarded: the controller pans x by it (5% of the span
// a notch) instead of zooming, which is what a sideways scroll on a trackpad
// means -- and what every other adapter's events already let it do.
e.dx = notches_x;
e.dy = notches;
e.mods = mods_from(event->modifiers());
controller_.on_wheel(e);
@@ -292,6 +316,16 @@ void PlotWidget::keyPressEvent(QKeyEvent* event) {
case Qt::Key_Minus:
case Qt::Key_Underscore: mapped = input::kKeyZoomOut; break;
case Qt::Key_F: mapped = input::kKeyFit; break;
// Arrows pan (10% of the span per press, repeats included) and Escape cancels
// the active drag -- translated, like the ASCII set above, and the controller
// decides what each does. Not offered to the parent widget when handled: an
// Escape that reaches a dialog's reject button would cancel the *window*, not
// the plot's drag, because the widget has focus.
case Qt::Key_Escape: mapped = input::kKeyEscape; break;
case Qt::Key_Left: mapped = input::kKeyLeft; break;
case Qt::Key_Right: mapped = input::kKeyRight; break;
case Qt::Key_Up: mapped = input::kKeyUp; break;
case Qt::Key_Down: mapped = input::kKeyDown; break;
default:
QOpenGLWidget::keyPressEvent(event); // not a plot key: nobody else wants it
return;
@@ -307,4 +341,64 @@ void PlotWidget::keyPressEvent(QKeyEvent* event) {
update();
}
bool PlotWidget::event(QEvent* event) {
if (event->type() == QEvent::DevicePixelRatioChange) {
// The window moved to a display with another scale factor (or the scale of the
// screen it is on changed). Everything the ratio decides -- the device
// viewport the controller hit-tests against, and the `ui_scale` the next
// frame's paint re-reads -- is refreshed here, before any event queued behind
// this one maps a gesture through the old geometry. `paintGL()` would catch up
// one frame later; a drag mid-gesture would pan by the wrong distance in
// exactly that frame.
refresh_viewport();
}
if (event->type() == QEvent::Gesture) {
auto* gesture_event = static_cast<QGestureEvent*>(event);
// The widget grabbed Qt::PinchGesture, so its pinch events arrive here; a
// gesture this widget does not know is left for whoever else may want it.
if (QPinchGesture* pinch =
static_cast<QPinchGesture*>(gesture_event->gesture(Qt::PinchGesture));
pinch != nullptr) {
forward_pinch(pinch, gesture_event);
}
}
return QOpenGLWidget::event(event);
}
void PlotWidget::forward_pinch(QPinchGesture* gesture, QGestureEvent* event) {
// The gesture reports cumulative values; `PinchTracker` folds them into the
// incremental zoom and focal movement the controller's `GestureEvent` wants.
// Positions are logical widget pixels here, exactly like a mouse event's, and
// go through the same `devicePixelRatioF()` mapping `to_device()` applies.
const QPointF logical_center = gesture->centerPoint();
const f64 dpr = static_cast<f64>(devicePixelRatioF());
const Vec2 logical{static_cast<f32>(logical_center.x()), static_cast<f32>(logical_center.y())};
switch (gesture->state()) {
case Qt::GestureStarted:
// The begin records the anchor only: a pinch that just started has zoomed
// and moved nothing yet.
pinch_.begin(static_cast<f32>(gesture->totalScaleFactor()), logical);
break;
case Qt::GestureUpdated:
case Qt::GestureFinished: {
// The finished state's last update is the gesture's final zoom: folded like
// an update, so nothing the fingers did is dropped for ending.
const input::GestureEvent g =
pinch_.update(static_cast<f32>(gesture->totalScaleFactor()), logical, dpr);
controller_.on_gesture(g);
break;
}
default:
// Canceled: the fingers left the surface mid-gesture. No event -- whatever
// zoom the updates delivered stands, and the next pinch begins fresh.
break;
}
if (gesture->state() == Qt::GestureFinished || gesture->state() == Qt::GestureCanceled) {
pinch_.end();
}
event->accept(gesture); // consumed: the plot zoomed/panned, no parent gets it too
update();
}
} // namespace plot::qt
+25 -4
View File
@@ -34,6 +34,9 @@ class QKeyEvent;
class QMouseEvent;
class QPointF;
class QWheelEvent;
class QEvent;
class QGestureEvent;
class QPinchGesture;
namespace plot::qt {
@@ -106,10 +109,15 @@ namespace plot::qt {
/// reinterpretation rather than the edge synthesis the ImGui adapter needs.
/// Wheel input is the one unit conversion: Qt's `angleDelta()` is in eighths of a
/// degree, 120 to a notch, while `Controller::on_wheel()` takes notches, positive
/// = zoom in -- which is Qt's positive `angleDelta().y()` too. A wheel event with
/// no vertical component is refused before it reaches the controller, because a
/// zero-notch zoom changes no range but still runs the range setters, which clear
/// auto scale: a horizontal scroll must not silently stop a live axis following.
/// = zoom in -- which is Qt's positive `angleDelta().y()` too. A wheel event whose
/// deltas both vanish -- no angle and no pixels -- is refused before it reaches the
/// controller, because a zero-notch, zero-pan event changes nothing and must not
/// clear auto scale; anything with a vertical *or* horizontal component is
/// forwarded, and the controller decides what each does (zoom on `dy`, pan on
/// `dx`). The widget is also a pinch source: `Qt::PinchGesture` is grabbed in the
/// constructor and folded into `input::GestureEvent`s by `PinchTracker`
/// (`qt_pointer.hpp`), so a two-finger zoom-about-focal-point and a two-finger pan
/// reach the controller like the equivalent wheel + drag pair would.
///
/// ## Threading
///
@@ -170,6 +178,12 @@ class PlotWidget : public QOpenGLWidget, protected QOpenGLFunctions {
void initializeGL() override;
void resizeGL(int w, int h) override;
void paintGL() override;
/// The one virtual that sees what no dedicated handler does: the device pixel
/// ratio change (`QEvent::DevicePixelRatioChange`) and the grabbed pinch
/// (`QEvent::Gesture`). Both refresh or feed the controller before the next
/// paint, so a gesture arriving between the change and the frame maps against
/// the geometry the ratio now implies.
bool event(QEvent* event) override;
void mousePressEvent(QMouseEvent* event) override;
void mouseMoveEvent(QMouseEvent* event) override;
@@ -196,6 +210,10 @@ class PlotWidget : public QOpenGLWidget, protected QOpenGLFunctions {
/// Sets the plot's viewport to the current device rect (through the controller,
/// which refreshes the layout with it).
void refresh_viewport();
/// Folds one pinch update into a `GestureEvent` through `PinchTracker` and hands
/// it to the controller. `event` is accepted for the pinch gesture, so Qt does
/// not offer it to parents too.
void forward_pinch(QPinchGesture* gesture, QGestureEvent* event);
Plot* plot_ = nullptr; ///< declared first: the controller is built over it
input::Controller controller_;
@@ -207,6 +225,9 @@ class PlotWidget : public QOpenGLWidget, protected QOpenGLFunctions {
/// reports no delta of its own, and the ratio can change between two events
/// (see `PointerAnchor` for why the anchor must not be device pixels).
PointerAnchor anchor_;
/// The pinch gesture's folding state (see `PinchTracker`): one per widget, alive
/// between `Qt::GestureStarted` and `Finished`/`Canceled`.
PinchTracker pinch_;
u64 frames_ = 0;
f32 last_frame_ms_ = 0.0f;
+66
View File
@@ -4,6 +4,7 @@
#pragma once
#include <plot/core/types.hpp>
#include <plot/input/events.hpp>
#include <plot/math/vec2.hpp>
namespace plot::qt {
@@ -49,4 +50,69 @@ struct PointerAnchor {
}
};
/// The state one pinch gesture is folded into `input::GestureEvent`s from.
///
/// Qt reports a pinch as **cumulative** values with no delta of its own:
/// `QPinchGesture::totalScaleFactor()` is the zoom since the gesture began, and
/// `centerPoint()` the focal point now, both absolute in the gesture's life. The
/// controller wants the opposite of both -- `GestureEvent::scale` is *this frame's*
/// zoom factor (1.0 = no change) and `(dx, dy)` the focal point's movement since the
/// previous gesture event -- so the widget owns one of these and folds each update:
/// the zoom arrives as the ratio of the two cumulative scales, the pan as the
/// difference of two focal points.
///
/// The focal point is cached in **logical** widget pixels, like `PointerAnchor`
/// above, for the same reason: `devicePixelRatioF()` can change mid-gesture (the
/// window dragged onto another display), and a cache in device pixels would compare
/// a point taken at the old ratio with one taken at the new one -- a phantom zoom
/// anchor movement, delivered to the controller as a pan. The difference of two
/// logical points, scaled by the ratio in force now, is the movement the fingers
/// actually made.
///
/// A plain value with no toolkit dependency: the widget owns one, and this is the
/// part of the adapter's pinch translation a test can drive directly (see
/// tests/test_qt_pointer.cpp).
struct PinchTracker {
bool active = false; ///< true between the gesture's begin and its end/cancel
f32 last_scale = 1.0f; ///< the cumulative scale the previous update folded
f32 x = 0.0f; ///< last focal point, logical widget pixels
f32 y = 0.0f;
/// Starts a gesture at Qt's first cumulative `scale` and logical focal point.
/// The first event must carry no zoom and no movement -- a pinch that begins has
/// zoomed nothing yet -- so this only records where the folding starts from.
void begin(f32 scale, Vec2 logical_center) noexcept {
active = true;
last_scale = scale;
x = logical_center.x;
y = logical_center.y;
}
/// Folds one update into an incremental `GestureEvent`, in device pixels at
/// `dpr`: the zoom about the focal point, and the focal point's movement since
/// the previous update. Zero zoom and zero movement before `begin()`, so an
/// update that arrives without a begin -- a host that missed the start -- is a
/// no-op rather than a zoom from a made-up anchor.
input::GestureEvent update(f32 scale, Vec2 logical_center, f64 dpr) noexcept {
input::GestureEvent g;
if (!active) return g;
// The frame's zoom factor: Qt's cumulative ratio over the one this tracker
// last folded. The first update after begin() divides the scale by itself,
// which is 1.0 -- a pinch that has not moved has zoomed nothing.
g.scale = last_scale != 0.0f ? scale / last_scale : 1.0f;
g.x = static_cast<f32>(static_cast<f64>(logical_center.x) * dpr);
g.y = static_cast<f32>(static_cast<f64>(logical_center.y) * dpr);
g.dx = static_cast<f32>((static_cast<f64>(logical_center.x) - static_cast<f64>(x)) * dpr);
g.dy = static_cast<f32>((static_cast<f64>(logical_center.y) - static_cast<f64>(y)) * dpr);
last_scale = scale;
x = logical_center.x;
y = logical_center.y;
return g;
}
/// Ends the gesture: an update after this (a straggler `GestureUpdated` Qt
/// delivers out of order, a cancel that still reports) is again a no-op.
void end() noexcept { active = false; }
};
} // namespace plot::qt
+39 -2
View File
@@ -16,6 +16,30 @@ option(PLOT_BUILD_GLFW "Build the GLFW adapter and demo" OFF)
option(PLOT_BUILD_IMGUI "Build the Dear ImGui adapter and demo" OFF)
option(PLOT_BUILD_QT6 "Build the Qt6 adapter and demo" OFF)
option(PLOT_BUILD_GTK4 "Build the GTK4 adapter and demo" OFF)
# The optional runtime-font backend: plot::text::TrueTypeFont, which bakes a real
# TrueType/OpenType file into a fixed-cell atlas at run time (see
# include/plot/text/true_type_font.hpp). It is the one target that compiles the
# vendored third_party/stb_truetype.h, so it is opt-in and separable -- the core
# library, its public headers and its tests build without it -- and its default
# follows the vendored header's presence: a tree that has the file wants the
# feature, and one that does not cannot have it. Asking for it explicitly without
# the header is an error rather than a silent downgrade, because the option is
# then the only thing that would have said so. The top-level CMakeLists.txt adds
# the target itself; this file only decides whether it exists.
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/third_party/stb_truetype.h")
set(PLOT_VENDORED_STB_TRUETYPE TRUE)
else()
set(PLOT_VENDORED_STB_TRUETYPE FALSE)
endif()
option(PLOT_BUILD_TTF_FONT
"Build the optional plot::text::TrueTypeFont backend (vendored stb_truetype)"
${PLOT_VENDORED_STB_TRUETYPE})
if(PLOT_BUILD_TTF_FONT AND NOT PLOT_VENDORED_STB_TRUETYPE)
message(FATAL_ERROR
"plot: PLOT_BUILD_TTF_FONT=ON needs third_party/stb_truetype.h, which this tree does "
"not have. Restore it -- include/plot/text/true_type_font.hpp records the version and "
"the source URL it came from -- or configure with -DPLOT_BUILD_TTF_FONT=OFF.")
endif()
option(PLOT_INSTALL "Generate install and export rules (find_package(plot))" ON)
set(PLOT_SANITIZE "" CACHE STRING
"Sanitizers to build with, as the list a -fsanitize= flag takes (e.g. address); empty for none")
@@ -27,9 +51,22 @@ set(PLOT_SANITIZE "" CACHE STRING
# presets leave it off, so their target lists, configure time and build product
# are exactly what they were before this option existed.
option(PLOT_COVERAGE "Add the `coverage` target (needs a --coverage build tree)" OFF)
if(PLOT_COVERAGE AND NOT CMAKE_CXX_FLAGS MATCHES "--coverage")
# The instrumented tree is one whose *effective* flags carry --coverage, and the
# base CMAKE_CXX_FLAGS is only part of those: the active configuration's own
# profile (CMAKE_CXX_FLAGS_<CONFIG>) is applied on top, and --coverage is
# equally at home in either. The linux-coverage preset sets the base; a user who
# sets it on the Debug profile instead gets exactly the same instrumented tree,
# which the old base-only string match rejected. Matching the two together is
# still just an option check on the configuration profile -- the guard does not
# try to read the compiler's mind, only the flags this build will pass it.
string(TOUPPER "${CMAKE_BUILD_TYPE}" plot_coverage_config)
set(plot_coverage_flags
"${CMAKE_CXX_FLAGS} ${CMAKE_CXX_FLAGS_${plot_coverage_config}}")
if(PLOT_COVERAGE AND NOT plot_coverage_flags MATCHES "--coverage")
message(FATAL_ERROR
"plot: PLOT_COVERAGE=ON needs a --coverage build (CMAKE_CXX_FLAGS is \"${CMAKE_CXX_FLAGS}\"). "
"plot: PLOT_COVERAGE=ON needs a --coverage build (CMAKE_CXX_FLAGS is "
"\"${CMAKE_CXX_FLAGS}\" and CMAKE_CXX_FLAGS_${plot_coverage_config} is "
"\"${CMAKE_CXX_FLAGS_${plot_coverage_config}}\"). "
"Configure with --preset linux-coverage, or drop -DPLOT_COVERAGE=ON.")
endif()
if(PLOT_COVERAGE AND NOT PLOT_BUILD_TESTS)
+41
View File
@@ -32,6 +32,21 @@
# to the list below; a caller may override it, and
# emptying it is how a test proves the exemption is what
# keeps a header out of the "missing" report.
# PLOT_EXTRA_PUBLIC_HEADERS generated public headers, relative to
# PLOT_INCLUDE_DIR, that the glob below cannot see
# because configure_file() writes them into the build
# tree (plot/version.hpp). They are checked like any
# other public header: the umbrella has to name them,
# and the dangling-include check cannot demand them
# under PLOT_INCLUDE_DIR, because their existence is
# pinned by the configure that generated them. Defaults
# to the project's own list, on the same terms as the
# optional list: exact paths, because a hole should be
# a list of files.
# PLOT_GENERATED_INCLUDE_DIR the build-tree directory those headers live in,
# consulted when one of them is not under
# PLOT_INCLUDE_DIR. Unset in a tree with no generated
# headers.
if(NOT DEFINED PLOT_OPTIONAL_HEADERS)
set(PLOT_OPTIONAL_HEADERS
@@ -42,6 +57,18 @@ if(NOT DEFINED PLOT_OPTIONAL_HEADERS)
"plot/text/true_type_font.hpp")
endif()
if(NOT DEFINED PLOT_EXTRA_PUBLIC_HEADERS)
set(PLOT_EXTRA_PUBLIC_HEADERS
# <plot/version.hpp>, generated by configure_file() from
# include/plot/version.hpp.in into the build tree and installed with the
# public headers. The glob cannot see it -- it is not under the source
# include root -- and neither can the dangling-include check, so the
# default list declares it: the umbrella must still carry its include, and
# a script run without the caller's -D inputs (the exemption test, a bare
# -P invocation) judges the umbrella exactly as the build does.
"plot/version.hpp")
endif()
if(NOT DEFINED PLOT_INCLUDE_DIR)
message(FATAL_ERROR "plot: PLOT_INCLUDE_DIR is not set")
endif()
@@ -52,6 +79,9 @@ endif()
file(GLOB_RECURSE plot_umbrella_headers
RELATIVE "${PLOT_INCLUDE_DIR}"
"${PLOT_INCLUDE_DIR}/plot/*.hpp")
if(PLOT_EXTRA_PUBLIC_HEADERS)
list(APPEND plot_umbrella_headers ${PLOT_EXTRA_PUBLIC_HEADERS})
endif()
list(SORT plot_umbrella_headers)
file(READ "${PLOT_UMBRELLA}" plot_umbrella_text)
@@ -100,9 +130,20 @@ string(REGEX MATCHALL "#include <plot/[^>]+>" plot_umbrella_includes "${plot_umb
set(plot_umbrella_dangling "")
foreach(plot_umbrella_include IN LISTS plot_umbrella_includes)
string(REGEX REPLACE "#include <([^>]+)>" "\\1" plot_umbrella_header "${plot_umbrella_include}")
# A declared generated header is exempt from the existence demand: it lives
# in the build tree by construction -- the configure that produced this
# umbrella also produced the header -- so a run without
# PLOT_GENERATED_INCLUDE_DIR set (a bare -P invocation) judges it on the
# umbrella naming it, which the required-set loop above already checked.
if(plot_umbrella_header IN_LIST PLOT_EXTRA_PUBLIC_HEADERS)
continue()
endif()
if(NOT EXISTS "${PLOT_INCLUDE_DIR}/${plot_umbrella_header}")
if(NOT DEFINED PLOT_GENERATED_INCLUDE_DIR OR
NOT EXISTS "${PLOT_GENERATED_INCLUDE_DIR}/${plot_umbrella_header}")
list(APPEND plot_umbrella_dangling "${plot_umbrella_header}")
endif()
endif()
endforeach()
if(plot_umbrella_dangling)
+31 -13
View File
@@ -41,9 +41,27 @@ ctest --test-dir build/debug --output-on-failure
ctest registers four tests: `plot_tests` (the test binary), the two header/umbrella build
checks (`plot_headers_self_contained`, `plot_umbrella_covers_public_headers`) and
`plot_umbrella_check`. The `plot_tests` binary itself runs **283 cases** in the `linux-debug`
preset; its harness prints one line per case and a final `283 cases, 0 failed` summary line.
The case counts per file are the `PLOT_TEST(...)` declarations under `tests/`.
`plot_umbrella_check`. The `plot_tests` binary itself runs **350 cases** in the `linux-debug`
preset; its harness prints one line per case and a final `350 cases, 0 failed` summary line.
The case counts per file are the `PLOT_TEST(...)` declarations under `tests/` — recount them
(`grep -c '^PLOT_TEST(' tests/test_*.cpp`) after adding or removing cases.
## Install layout
`cmake --install` follows `GNUInstallDirs`, so every path honours the standard cache
variables (`CMAKE_INSTALL_LIBDIR`, `CMAKE_INSTALL_INCLUDEDIR`, …) and their distribution
defaults — plain `lib`, `include` and `share` unless a packager overrides them:
| prefix-relative path | what |
|---|---|
| `lib/` | the `plot_core` archive (and `libplot_core.so` with its version and SONAME symlinks when built with `BUILD_SHARED_LIBS=ON`) |
| `include/plot/` | every public header, including the generated `plot/version.hpp` (`plot::version::major/minor/patch/string` and the `PLOT_VERSION_*` macros) |
| `lib/cmake/plot/` | the CMake package: `plotConfig.cmake`, `plotConfigVersion.cmake` (Compatibility **SameMinorVersion**, so `find_package(plot 0.1 …)` accepts 0.1.x only), `plotTargets.cmake` |
| `lib/pkgconfig/plot.pc` | pkg-config metadata for consumers whose build is not CMake |
| `share/licenses/plot/` | `LICENSE` (MIT) and `licences/OFL-1.1.txt` (the embedded font) |
Adapters that were built and exported install themselves alongside the core (their headers
under `include/<adapter>/`, their targets in the same package).
## Headless demo modes
@@ -95,15 +113,15 @@ The floors are enforced by `tools/coverage_floor.py`:
| every file under `src/` | **80%** of its instrumented lines |
| `src/` as a whole | **90%** of instrumented lines |
Branch coverage is deliberately off. The current measurement (from `build/cov/coverage.info`,
re-read with `python3 tools/coverage_floor.py build/cov/coverage.info`) is an **aggregate
95.3% (3284/3447 lines)** over 23 files, every file at or above 80% — the floor is satisfied.
The method, the full per-file table and the reproducibility notes are in
[docs/coverage.md](coverage.md); re-run the command for the live numbers.
Branch coverage is deliberately off. This page carries no measured totals — a number written
here would be stale the moment `src/` moved, and the report is reproducible: the method, the
per-file table and the floor verdict live in [docs/coverage.md](coverage.md), which
`tools/coverage.sh` rewrites from the very run that measured them. Re-run the command for the
live numbers.
**One thing to know about the coverage preset:** it builds **272 of the 283 cases**. The 11
that are missing are the adapter cases that need an adapter target to exist — 8 in
`test_imgui_widget.cpp` and 3 in `test_glfw_host.cpp` — and the coverage preset turns the
adapters off. The toolkit-free adapter seams still run there: `test_qt_pointer.cpp` (2 cases)
and `test_gtk_input.cpp` (8 cases) compile without Qt or GTK. So the coverage number measures
**One thing to know about the coverage preset:** it builds **333 of the 350 cases**. The 17
that are missing are the adapter cases that need an adapter target to exist — 11 in
`test_imgui_widget.cpp` and 6 in `test_glfw_host.cpp` — and the coverage preset turns the
adapters off. The toolkit-free adapter seams still run there: `test_qt_pointer.cpp` (5 cases)
and `test_gtk_input.cpp` (11 cases) compile without Qt or GTK. So the coverage number measures
the library plus its unit suite, not the adapter bodies (which the headless demo runs cover).
+15 -14
View File
@@ -43,35 +43,36 @@ when one is missing.
<!-- BEGIN GENERATED: tools/coverage.sh -->
```
coverage_floor: ok -- 23 files under src/, aggregate 95.2% lines (3302/3469), every file at or above 80%, aggregate target 90%
coverage_floor: ok -- 24 files under src/, aggregate 93.9% lines (3730/3971), every file at or above 80%, aggregate target 90%
```
| File | Lines | Line rate | Functions |
| --- | ---: | ---: | ---: |
| `src/data/array_series.cpp` | 43/43 | 100.0% | 100.0% |
| `src/data/minmax_pyramid.cpp` | 163/163 | 100.0% | 100.0% |
| `src/data/series.cpp` | 105/107 | 98.1% | 100.0% |
| `src/data/stream_series.cpp` | 39/39 | 100.0% | 100.0% |
| `src/gl/handle.cpp` | 256/256 | 100.0% | 100.0% |
| `src/data/array_series.cpp` | 56/56 | 100.0% | 100.0% |
| `src/data/minmax_pyramid.cpp` | 170/171 | 99.4% | 96.4% |
| `src/data/series.cpp` | 161/164 | 98.2% | 100.0% |
| `src/data/stream_series.cpp` | 45/45 | 100.0% | 100.0% |
| `src/data/xy_series.cpp` | 102/120 | 85.0% | 100.0% |
| `src/gl/handle.cpp` | 259/259 | 100.0% | 100.0% |
| `src/gl/loader.cpp` | 81/81 | 100.0% | 100.0% |
| `src/input/controller.cpp` | 329/343 | 95.9% | 100.0% |
| `src/input/controller.cpp` | 430/465 | 92.5% | 97.4% |
| `src/math/transform.cpp` | 28/28 | 100.0% | 100.0% |
| `src/plot/axis.cpp` | 12/12 | 100.0% | 100.0% |
| `src/plot/figure.cpp` | 218/223 | 97.8% | 95.5% |
| `src/plot/figure.cpp` | 229/234 | 97.9% | 95.8% |
| `src/plot/grid.cpp` | 15/16 | 93.8% | 100.0% |
| `src/plot/measure.cpp` | 21/21 | 100.0% | 100.0% |
| `src/plot/plot.cpp` | 743/763 | 97.4% | 98.9% |
| `src/plot/ticks.cpp` | 93/99 | 93.9% | 100.0% |
| `src/plot/plot.cpp` | 767/793 | 96.7% | 98.9% |
| `src/plot/ticks.cpp` | 105/111 | 94.6% | 100.0% |
| `src/render/buffer.cpp` | 88/98 | 89.8% | 100.0% |
| `src/render/embedded_mono_font.cpp` | 45/45 | 100.0% | 100.0% |
| `src/render/primitives.cpp` | 71/75 | 94.7% | 100.0% |
| `src/render/primitives.cpp` | 75/79 | 94.9% | 100.0% |
| `src/render/program.cpp` | 26/27 | 96.3% | 100.0% |
| `src/render/renderer.cpp` | 530/609 | 87.0% | 93.7% |
| `src/render/renderer.cpp` | 616/722 | 85.3% | 93.9% |
| `src/render/text.cpp` | 27/30 | 90.0% | 100.0% |
| `src/render/text_atlas.cpp` | 40/41 | 97.6% | 100.0% |
| `src/render/vertex_array.cpp` | 59/59 | 100.0% | 100.0% |
| `src/render/vertex_array.cpp` | 62/62 | 100.0% | 100.0% |
| `src/text/true_type_font.cpp` | 270/291 | 92.8% | 96.8% |
| **src/ aggregate** | **3302/3469** | **95.2%** | **98.3%** |
| **src/ aggregate** | **3730/3971** | **93.9%** | **98.0%** |
<!-- END GENERATED: tools/coverage.sh -->
+94 -24
View File
@@ -2,20 +2,27 @@
## The sample stores
All three series derive from `plot::data::Series` (`include/plot/data/series.hpp`), which
describes a **uniform-grid** series: sample `i` lives at `x = x0() + i * dx()`.
All series derive from `plot::data::Series` (`include/plot/data/series.hpp`), which
describes the sample-indexing contract; a **uniform-grid** series additionally has
sample `i` at `x = x0() + i * dx()` (`ArraySeries`, `StreamSeries`), while
**`XYSeries`** carries its own sorted, ascending x array for data that is not on a
uniform grid.
```cpp
class Series {
public:
virtual u64 size() const;
virtual f64 x0() const; // x of sample 0 (the oldest retained sample)
virtual f64 dx() const;
virtual f64 dx() const; // 0 for XYSeries, which owns its x array
virtual Range x_bounds() const;
virtual Range y_bounds() const;
virtual const MinMaxPyramid& pyramid() const;
virtual bool streaming() const noexcept; // default false
virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const;
virtual f64 x_at(u64 i) const; // x0() + i*dx(), or x()[i]
virtual bool neighbours(f64 x, u64& i0, u64& i1) const; // bracketing samples
virtual u64 snap_index(f64 x) const; // nearest sample
virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
GatherMode mode = GatherMode::Envelope) const;
Range reduce(u64 begin, u64 end) const; // exact min/max over [begin, end)
f64 sum(u64 begin, u64 end) const; // exact sum over [begin, end)
@@ -29,11 +36,65 @@ Indices are the series' **own window**: index 0 is the oldest sample it currentl
**`ArraySeries`** (`array_series.hpp`) owns a `std::vector<f32>` of y samples plus an
`x0`/`dx` grid. Construct with `ArraySeries(std::vector<f32> y, f64 x0, f64 dx)`. The vector
is taken by value and owned, so the samples cannot change behind the series' back; the store
changes only through `reset(y, x0, dx)` (a whole new vector, then a rebuild) or `commit()`,
which rebuilds the pyramid and cached bounds from the current `y`.
changes only through `reset(y, x0, dx)` (a whole new vector, then a rebuild), `commit()`,
which rebuilds the pyramid and cached bounds from the current `y`, or `push(y)`, which
appends one sample incrementally — the pyramid folds it in amortised O(1) and the bounds
extend in place, NaN-aware, instead of rebuilding.
**`StreamSeries`** (`stream_series.hpp`) is the single-producer / single-consumer streaming
store: `StreamSeries(u64 capacity, f64 x0, f64 dx)`.
store: `StreamSeries(u64 capacity, f64 x0, f64 dx)`. `capacity` is rounded up to a power of
two by the ring, `size()` saturates at that rounded `capacity()` (a `StreamSeries(100)`
retains up to 128 samples), and the pyramid is reserved for the rounded value up front.
`freeze()` copies the window published by the last `acquire()` into a fresh `ArraySeries`
(consumer thread only): a stable snapshot that no later production can move.
## Non-finite samples
The library-wide NaN policy: **a non-finite sample is excluded from every aggregate** —
min, max, sum, sum of squares and the pyramid's per-bucket finite count — and from the
cached bounds. A window with no finite samples at all aggregates to a **zero-span
`Range` / zero sums**, never to NaN. The policy is applied everywhere an aggregate is
built: `MinMaxPyramid::append` (a NaN occupies its absolute position, so bucket
boundaries stay positional, but folds nothing), the edge folds in `Series::reduce`,
`sum` and `sumsq`, `ArraySeries::commit` and `push` bounds, and
`StreamSeries::acquire` bounds. A bucket whose samples were all non-finite is stored as
an *empty* aggregate (count 0, zero sums) that every reader skips — it is absent, not
zero-valued. `gather()` follows suit: Envelope and Mean emit nothing for such a bucket,
and Nearest draws its centre sample raw. Hosts that want gaps in the data should draw
them with NaN samples and will get exactly that: excluded from statistics, present in
raw geometry.
## Gather x is relative, and what each mode emits
**`Series::gather` emits x relative to `x0()`** — exactly `i * dx` for a uniform
series, `x_at(i) - x0()` for `XYSeries` — never the absolute coordinate. y is
unchanged. The renderer reconstructs absolute data x in f64 (`x0() + emitted`) before
transforming, so no precision is lost at the f32 narrowing: the staged value is
O(visible pixels), not O(epoch). (Staging an absolute epoch x as f32 would quantise it
to a grid coarser than the pixel — f32 carries 24 bits, about 1 s at 1.7e9 — while the
relative value is small and exact.) The emitted geometry always spans
`[begin, end)` exactly and never reaches outside it: at level ≥ 1 the bucket edges are
selected ceil/floor (`first` = first bucket starting at or after `begin`, `last` = one
past the last bucket ending at or before `end`) and the two partial edge spans are
drawn from raw samples, which is what keeps an unaligned window envelope-exact
(previously the buckets straddling the range leaked samples from outside it into the
geometry). `level` is clamped to `kMaxPyramidLevel` (57 — the deepest level whose
bucket span fits a `u64`), so any `u32` level is safe.
The `GatherMode` decides what each bucket contributes, at the same level the renderer
already picked:
- **`Envelope`** (the default): one (min, max) pair per bucket, min drawn at the
bucket's first sample and max at its last. The dense, extreme-reading geometry a
time-series trace wants.
- **`Nearest`**: one raw sample per bucket — the centre sample, the pick
`pyramid().snap()` would make — drawn at its own x. Thinner geometry for traces
whose *shape* matters more than their extremes.
- **`Mean`**: one point per bucket, y = the bucket's finite samples' mean (the exact
`bucket_sum()` the pyramid stores divided by its finite count), x at the bucket
centre. A smoothed trace for noisy data.
Level 0 is the raw samples for every mode.
## The SPSC contract
@@ -82,11 +143,12 @@ the visible pixels rather than on the sample count.
and, when it completes, pushes it and walks up, stopping at the first level that gains no
entry: an append costs **amortised O(1)**. `drop_before(u)` slides the window's leading
edge, bounding the memory by the retained window rather than by everything ever appended.
- An entry is 24 bytes (`MinMax` plus the exact `sum`/`sumsq` the exact range queries fold
over), so level 0 costs `0.375n` bytes and the geometric tail brings the whole pyramid to
about **`0.75n` bytes — roughly 19% of the `4n` bytes of raw samples** (`f32`). A
min/max-only index would be `0.25n` (~6%); the two doubles are the price of answering
mean/RMS/integral from whole buckets instead of raw samples.
- An entry is 32 bytes (`MinMax` plus the exact `sum`/`sumsq` the exact range queries fold
over and the finite-sample `count` the mean folds by), so level 0 costs `0.5n` bytes and
the geometric tail brings the whole pyramid to about **`1n` bytes — roughly 25% of the
`4n` bytes of raw samples** (`f32`). A min/max-only index would be `0.25n` (~6%); the
extra words are the price of answering mean/RMS/integral from whole buckets instead of
raw samples.
Queries on the pyramid are **bucket-grained** and named `*_buckets` (`reduce_buckets`,
`sum_buckets`, `sumsq_buckets`): a pyramid stores aggregates, so it can only answer exactly
@@ -104,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
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**
agree:
Every number the library reports is one the samples actually hold. What differs between
these two is *how many* samples answer the question:
- **`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
@@ -117,15 +179,23 @@ agree:
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
unaligned interval.
- **The crosshair readout is interpolated, and says so.** `crosshair_span(series, x)`
returns the two samples `x` lies between (`begin`, `end`, `t`), and
`crosshair_interpolate(y_begin, y_end, t)` does a linear interpolation: it answers "what
is the signal *at* this instant", which is generally between two samples — a different
question from an interval statistic. The lookup is O(1) (one division and a floor) and
allocation-free; `Plot::crosshair_value(trace, x, out_y)` exposes the same number to a
host. 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 the
nearest sample's.
- **The crosshair readout is a single sample — the nearest one — by default.**
`crosshair_span(series, x)` returns the two samples `x` lies between (`begin`, `end`, `t`),
and `CrosshairValue::NearestSample` takes whichever of the two is nearer: `t <= 0.5` the
earlier, otherwise the later. It answers "which datum is under the pointer", not "what is
the signal *at* this instant" — a linearly interpolated y is a number the series does not
contain, and on a source whose samples are already averaged or decimated at ingest it is a
claim about a signal nobody measured. That is why it is the default; a host whose x is a
quantity it is willing to interpolate states `CrosshairValue::Interpolated` and gets
`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**,
`sum(begin, end) * dx` — the samples taken as the value at the left edge of each step. That
+98 -13
View File
@@ -41,6 +41,48 @@ scale:
| Qt6 | `devicePixelRatioF()` | re-read every `paintGL()` |
| GTK4 | `mapping_.factor` = device size / logical allocation | the two sizes `::resize` reports, not `gtk_widget_get_scale_factor()` |
**Keys, key repeat, timestamps and touch parity.** The controller's keys and gestures
arrive through the adapters translated from each toolkit's own events, and four
conventions hold across all four hosts:
* **The key set is the same everywhere**: `+`/`-` zoom (with the `=`/`_` spellings a
shifted layout produces), `f`/`F` re-fits, the four arrows pan (10% of the span per
press), and Escape cancels the active drag. Each host translates its native key codes
to `input::kKey*` before calling the controller.
* **A held key repeats in every adapter.** GLFW, Qt and GTK deliver the OS auto-repeat
as extra press events and forward each one. ImGui edge-detects a press exactly once,
so its widget asks `IsKeyPressed(..., repeat = true)` for every key it maps and
ImGui's own repeat timer supplies what the OS would have.
* **`PointerEvent::time_s` is the host's monotonic clock, in seconds.** Only
differences are read: the double-click (two presses of the same button within 0.35 s
and 4 px re-fits every axis) compares two stamps, never an epoch. A host that leaves
the field 0 never produces a double-click, by contract.
| host | key repeat | `time_s` source | touch / pinch |
|---|---|---|---|
| GLFW | the OS auto-repeat, forwarded as repeated presses | `glfwGetTime()` | none — GLFW has no touch input |
| ImGui | `IsKeyPressed(..., repeat = true)` on every mapped key | `ImGui::GetTime()` | none — no touch input to forward |
| Qt6 | Qt repeats a held key as press events | `QInputEvent::timestamp()` / 1000 | `Qt::PinchGesture` → `GestureEvent`, folded by `PinchTracker` (`qt_pointer.hpp`) |
| GTK4 | GTK repeats a held key as press events | the GDK event's time / 1000 | `GtkGestureZoom` → `GestureEvent`, folded by `ZoomAnchor` (`gtk_input.hpp`) |
The two pinch folds are the same fold in two namespaces, and both live in
toolkit-free headers precisely so the headless tests can drive them: a toolkit
reports a pinch as **cumulative** values (total scale since the gesture began, the
focal point now), while `GestureEvent` wants the increments (this frame's zoom factor
about the focal point, 1.0 = no change, and the focal point's movement as `dx`/`dy`).
The folds also keep their focal anchor in *logical* pixels, like every other adapter
anchor, so a window dragged onto a display with another scale factor mid-gesture
cannot read the ratio change as a phantom pan.
**DPR / display changes.** The scale each host states to the renderer is re-read
whenever it can change, not latched: GLFW re-derives it in `refresh_sizes()` (every
frame, and from the resize callback), ImGui every frame from
`io.DisplayFramebufferScale`, Qt every `paintGL()` **and** on
`QEvent::DevicePixelRatioChange` — which also refreshes the controller's viewport
before any event queued behind the change maps a gesture through the old geometry —
and GTK in `::resize` through `DeviceMapping`. An input coordinate is always scaled by
the ratio in force at that event, never by one cached at startup.
**Headless verification.** Every demo supports `PLOT_DEMO_EXIT_AFTER_MS=<ms>` (stop the
loop after that long) and `PLOT_DEMO_SCREENSHOT=<path>` (write the final frame as a PPM).
This is how the project runs its own GUI demos in CI under Xvfb, with no display access
@@ -93,16 +135,39 @@ What `run()` does, in order:
Pointer translation: `on_cursor_pos()` multiplies the GLFW window coordinates by
`fb_scale_x_`/`fb_scale_y_` (framebuffer ÷ window) and synthesises `dx`/`dy` itself (GLFW
reports no delta); `down` comes from `glfwGetMouseButton`, and the modifier mask is read
from the key state, because GLFW's pointer and scroll callbacks carry no mask. Wheel
notches are forwarded as `WheelEvent`, and keys as `KeyEvent`.
reports no delta); `down` comes from `glfwGetMouseButton`, the modifier mask is read
from the key state, because GLFW's pointer and scroll callbacks carry no mask, and
`time_s` is `glfwGetTime()`. Wheel notches are forwarded as `WheelEvent` unchanged
(zero deltas included — the controller owns that case), and keys as `KeyEvent`: the
arrows pan, Escape cancels the active drag, and neither closes the window (the window
manager's close button and Alt+F4 are the way to quit).
Figure mode: `host.set_figure(&figure)` makes the loop lay the figure over the framebuffer,
create **one `Controller` per row**, route each pointer/wheel/key event to the row it lands
in (`controller_at(x, y)`), and draw with `Renderer::draw(Figure&)`. The host also spreads
in (`controller_at(x, y)`), and draw it with `Renderer::draw(Figure&)`. The host also spreads
one hover over every row (`sync_figure_crosshair`), since only the host knows which row the
pointer is in.
Routing has two rules worth knowing from the outside:
* **One gesture at a time (button-aware capture).** A press captures the row it landed
in *and the button that pressed*, and every motion and the release of *that button* go
to the captured row until it comes up — so a drag that crosses into a neighbouring row
still ends where it began. A second button pressed meanwhile (a chord) neither steals
the capture to another row nor ends it; only the owning button's release ends it. What
the second button's events do inside the controller is the controller's business; the
host guarantees they all arrive at the row the first press claimed.
* **Row rects are cached, refreshed once per frame.** The events never call
`Figure::layout()` — a non-const `layout()` recomputes, and a burst of pointer moves
would re-lay every row out per move. The run loop calls `refresh_row_rects()` once per
frame (right after the figure's viewport is set), `set_figure()` fills the cache
immediately so a scripted run routes against the figure it was just handed, and every
handler reads the cache. The consequence across a resize: GLFW delivers the resize and
any events queued behind it in one batch, so events before the resize see the rows the
previous frame drew — at most one frame stale — and the loop's next refresh brings the
cache onto the new geometry. A driver that injects events without running the loop
calls `refresh_row_rects()` itself; it is the scripted equivalent of the frame.
`run()` leaves the window open on success so a producer thread can be joined before the GL
resources go away; `close()` (or the destructor) releases the renderer, context and window
on the calling thread. A run with `PLOT_DEMO_EXIT_AFTER_MS` set creates the window hidden.
@@ -131,7 +196,11 @@ Because ImGui reports button *levels* and the controller wants *edges*, the widg
remembers the previous frame's buttons and synthesises `pressed`/`released`; a button
pressed over the child keeps being delivered until every button is up, so a drag that
leaves the region still ends. ImGui's `(-FLT_MAX, -FLT_MAX)` "no pointer" sentinel is
treated as no position update (no movement, buttons still flow).
treated as no position update (no movement, buttons still flow). Every pointer event is
stamped with `ImGui::GetTime()`, which is what makes the controller's double-click
(re-fit) exist on this host; and every key the widget maps is asked with
`IsKeyPressed(..., repeat = true)`, because ImGui does not auto-repeat on its own —
the shared "a held key repeats" policy is opt-in here (see the conventions above).
Coordinate assumption: the points→pixels mapping is the whole framebuffer's only when the
host's GL viewport is the whole framebuffer at origin `(0,0)` and the bound target is the
@@ -168,12 +237,22 @@ viewport on the controller, runs the frame callback, sets `ui_scale` to
Input translation: Qt reports press/release edges directly, so `mousePressEvent` /
`mouseMoveEvent` / `mouseReleaseEvent` / `mouseDoubleClickEvent` / `wheelEvent` /
`keyPressEvent` become `PointerEvent`/`WheelEvent`/`KeyEvent`. Every position is multiplied
by `devicePixelRatioF()`; the *anchor* a move's delta is measured from is kept in logical
`keyPressEvent` become `PointerEvent`/`WheelEvent`/`KeyEvent`, each stamped with
`QInputEvent::timestamp()` in seconds. Every position is multiplied by
`devicePixelRatioF()`; the *anchor* a move's delta is measured from is kept in logical
pixels, so a drag crossing onto a display with another scale factor pans by the distance
moved rather than a cross-scale jump (`PointerAnchor`, `qt_pointer.hpp`). Qt's
`angleDelta()` is in eighths of a degree (120 per notch) and is converted; a wheel with no
vertical component is refused before it reaches the controller.
moved rather than a cross-scale jump (`PointerAnchor`, `qt_pointer.hpp`). The wheel is
converted from eighths of a degree, 120 to a notch; when a device reports
`pixelDelta()` instead (and the angle deltas are both zero), 40 scroll pixels are taken
to be one notch — a calibration, not a conversion, the same role the GTK adapter's
`kSurfacePixelsPerNotch` plays. A wheel event with neither a vertical nor a horizontal
component is refused before it reaches the controller; anything with one is forwarded,
and the controller pans x by the horizontal component. The widget also grabs
`Qt::PinchGesture`: each pinch update is folded by `PinchTracker` (`qt_pointer.hpp`)
into an incremental `GestureEvent` — this frame's zoom about the focal point, the focal
point's movement as the pan — and a `QEvent::DevicePixelRatioChange` refreshes the
controller's viewport (and, one frame later at the latest, the `ui_scale`) before any
event queued behind it.
Include-order note: this header includes `plot/gl/api.hpp` before `<QOpenGLWidget>`,
because Qt reaches `<GL/gl.h>` whose macros would rewrite `plot::gl`'s enumerators. A
@@ -202,10 +281,16 @@ and the device size GTK reports, and refreshes the viewport; `::render` sets the
the controller, runs the frame callback, sets `ui_scale` to the mapping's factor, and draws.
Input translation uses GTK's gesture recognisers: `GtkGestureDrag` supplies motion,
`GtkGestureClick` supplies the press/release edges, `GtkEventControllerMotion` supplies
`GtkGestureClick` supplies the press/release edges (stamped with the GDK event's time,
in seconds, like every pointer event this adapter forwards), `GtkGestureZoom` supplies
the two-finger pinch — each `scale-changed` folded by `ZoomAnchor` (`gtk_input.hpp`)
into an incremental `GestureEvent`: this frame's zoom about the fingers' bounding-box
centre, the centre's movement as the pan — `GtkEventControllerMotion` supplies
hover (forwarded with `down == false`, so a drag is not applied twice), and
`GtkEventControllerScroll` (vertical) and `GtkEventControllerKey` supply the wheel and the
`+`/`-`/`f` keys. GTK emits `drag-begin` before the click's `pressed`, so the adapter opens
`GtkEventControllerScroll` (vertical) and `GtkEventControllerKey` supply the wheel and
the keys (`+`/`-`/`f`, the arrows, Escape — the full shared key set, mapped in
`gtk_input.hpp`'s `key_from()` where the headless tests drive it). GTK emits
`drag-begin` before the click's `pressed`, so the adapter opens
its gesture from the first press and closes it from the first release. The area queues a
render after every input event that can change the picture (not on hover).
+26 -2
View File
@@ -51,6 +51,29 @@ directory as its public interface and nothing else. The exported adapters are na
`plot::imgui` is a build-tree target only, because ImGui is supplied by `FetchContent` and
there is no ImGui package for an installed config file to point at.
The version you ask for follows the package's own compatibility rule, **SameMinorVersion**:
`find_package(plot 0.1 REQUIRED)` accepts 0.1.x and refuses 0.2+, because at 0.x the minor
component is what moves when the surface breaks. What the surface *is* — the version this
header set was generated from — is in `<plot/version.hpp>`, installed with the other public
headers: `plot::version::major`, `::minor`, `::patch` and `::string` (plus the
`PLOT_VERSION_*` macros) for a compile-time answer, no `pkg-config --modversion` round-trip
needed:
```cpp
#include <plot/version.hpp>
static_assert(plot::version::major == 0, "plot 0.x only");
```
A build that is not CMake can use the installed pkg-config file instead:
```sh
pkg-config --cflags --libs plot # -I<includedir> -L<libdir> -lplot_core
```
Both grants travel with the install: `LICENSE` (MIT) and the embedded font's
`licences/OFL-1.1.txt` (SIL OFL 1.1) are under `share/licenses/plot/` in the prefix.
Every public component is reachable through the umbrella header:
```cpp
@@ -68,10 +91,11 @@ and draws one trace. It uses the GLFW toolkit for the window and `plot::core` di
it does not use the shipped `plot::glfw::Host` adapter, so it also shows the pieces the
adapter hides (window, context, loader, renderer, controller, frame loop).
It was compiled and linked as part of writing this page:
It was compiled and linked as part of writing this page (`-I build/debug/include` is where
CMake generates `plot/version.hpp`, which the umbrella pulls in):
```sh
g++ -std=c++20 -I include main.cpp build/debug/libplot_core.a \
g++ -std=c++20 -I include -I build/debug/include main.cpp build/debug/libplot_core.a \
$(pkg-config --libs glfw3) -ldl -lpthread -o plot_quickstart
```
+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
crosshair. `set_crosshair_style(const CrosshairStyle&)` overrides this plot's stroke and
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
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
a figure-wide horizontal line would be a value that means something different in each row.
Readout values are **interpolated**, not interval statistics (see
[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.
### What a readout is: the two value modes
`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)
+30 -3
View File
@@ -36,8 +36,8 @@ Colours (neither logical nor device — nothing to scale):
| field | default | meaning |
|---|---|---|
| `background` | `0x101418` | frame clear colour and legend/readout panel base |
| `grid_major` | `0x2A3038` | major (labelled-tick) grid lines |
| `grid_minor` | `0x1C2126` | minor (subdivision) grid lines |
| `grid_major` | `0x39414C` | major (labelled-tick) grid lines |
| `grid_minor` | `0x1C2126` at 50% alpha | minor (subdivision) grid lines |
| `axis_line` | `0x8A94A6` | axis lines |
| `axis_text` | `0xC8D0DC` | tick labels and axis titles |
| `cursor` | `0xFFB020` | cursor markers; also the hovered/grab highlight |
@@ -56,8 +56,12 @@ Logical metrics (scaled by `device()`):
|---|---|---|
| `axis_font_px` | `12.0` | logical em the axis text is drawn at |
| `grid_label_pad` | `4.0` | gap between a tick label and the axes, and the inset inside a title strip |
| `marker_below_samples` | `50` | a dot per raw sample when the window holds at most this many (`0` = never) |
| `marker_px` | `3.0` | logical size of that dot |
| `grid_major_width` | `1.0` | width of a major grid line |
| `grid_minor_width` | `1.0` | width of a minor grid line |
| `grid_minor_width` | `0.75` | width of a minor grid line (sub-pixel on purpose) |
| `grid_minor_dash_on` | `3.0` | minor grid dash: ink length in logical pixels |
| `grid_minor_dash_off` | `3.0` | minor grid dash: gap; `0` draws the minor grid solid |
| `grid_major_visible` | `true` | stage the major grid at all |
| `grid_minor_visible` | `true` | stage the minor grid at all |
| `line_width_px` | `1.5` | width of the strokes framing the data (axis lines, cursor markers) |
@@ -91,6 +95,29 @@ transparent. `Grid` therefore carries both classes' geometry whatever the style
caller that draws the grid itself applies the same two switches. `Grid::vertical` and
`Grid::horizontal` (both `true`) control which directions emit lines at all.
The labelled class is the **structure** and the subdivisions are **texture**, and the minor
grid's three defaults are what makes that read: it is thinner than the major hairline
(`grid_minor_width`, a sub-pixel width that at 1x is expressed as coverage and on a HiDPI
display as a narrower line), its colour carries an **alpha** so it blends into the
background instead of sitting on it, and it is **dashed** (`grid_minor_dash_on`/`_off`) so an
unlabelled line at an unlabelled position is never mistaken for a labelled one. Together
those put a minor line at an order of magnitude less ink than a major one — at full opacity and the
same width a dense minor grid reads as a second set of ticks. The dash is a fragment-shader
discard, not extra geometry: the vertices are the line either way. Set
`grid_minor_dash_off = 0` for the old solid subdivision.
## A cursor's stroke
`set_cursor_color()` gives a cursor its own colour and `set_cursor_stroke()` its own width and
dash (`Cursor::width`, `Cursor::dash`). Per cursor rather than per plot, because a host uses
cursors for two different jobs: the instruments that frame a reading — a marker at an edge, a pull
handle — and the *readout marks* a computation places on the data (a measured level, a peak, a
pulse's edges). Those want different weights. `width <= 0` means the style's default for that
cursor (`handle_width_px` while it is hovered or grabbed, `line_width_px` otherwise), and a hover
can only make the stroke *thicker*: the cue that the pointer has hold of something must not be the
thing that hides it. `dash.off <= 0` (the default) is solid — the same convention as a trace's,
the crosshair's and the grid's.
## The crosshair's dash
`crosshair_dash_on`/`crosshair_dash_off` are the fragment shader's discard pattern, so no
@@ -0,0 +1,305 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
# Hardening and features v0.2 — shared contracts
Design record for the bug-fix + robustness + feature wave. **This file is the
interface bible for the five parallel streams.** File ownership is exclusive:
a stream edits only the files listed under it. Everything not listed is the
integrator's.
## Bug list (B1–B7) and robustness list (R1–R9) being fixed
- **B1** f32 staging of large-magnitude x destroys geometry (epoch axes).
- **B2** tick major/minor classification ULP-fragile (`ticks.cpp:176`).
- **B3** LOD gather emits buckets straddling `[begin,end)` (`series.cpp:168`).
- **B4** `XFollow::Live` on log x holds linear span (`plot.cpp:494`, `figure.cpp`).
- **B5** zero-notch wheel disarms auto_scale; `factor==inf` unguarded
(`controller.cpp:158`, `glfw` forwards dy=0).
- **B6** single text flush paints legend text over the crosshair readout panel.
- **B7** NaN policy incoherent (min/max drop unless seeding; sum propagates).
- **R1** `std::clamp` UB on negative viewport (`plot.cpp:886`).
- **R2** `set_x_range`/`set_y_range` store non-finite verbatim (`plot.cpp:377-384`).
- **R3** `kBucket << level` UB for level >= 58 via public `gather`.
- **R4** ring capacity rounds up but docs/reserve assume raw capacity.
- **R5** GLFW figure capture cleared by any button release.
- **R6** renderer residue: blend func, UNPACK_ALIGNMENT, no GL shutdown,
epoch() wrap, annotation caption overflow.
- **R7** GLFW host re-lays out the figure on every event.
- **R8** measure accepts horizontal rulers, silently measures [0,0].
- **R9** minor-tick cap truncates the far end of the axis, majors included.
## Cross-cutting decisions
- **NaN policy (library-wide, documented):** a non-finite sample is *excluded*
from every aggregate (min, max, sum, sumsq) and from cached bounds. A window
with no finite samples aggregates to a zero-span Range / zero sums. Applies
in `MinMaxPyramid::append`, `Series::reduce/sum/sumsq` edge folds,
`ArraySeries::commit`, `StreamSeries::acquire` bounds. `docs/data-and-performance.md`
gets a "Non-finite samples" paragraph.
- **Gather x contract (breaking, B1):** `Series::gather` now emits x
**relative to `x0()`** (exactly `i*dx` for uniform series, `x_at(i)-x0()` for
XY), never absolute x. y is unchanged. The renderer reconstructs absolute
data x in f64 (`x0() + rel`) before transforming, so no precision is lost at
the f32 narrowing: the staged f32 is `to_space(x_abs) - to_space(origin)`,
which is O(visible pixels), not O(epoch).
- **Live follow on log axes (B4):** `Axis::offset` and the live-window span
live in **axis space** (`log10` when `log`), matching how the controller's
gestures already work (`to_space`/`from_space`). `place_live_x_window` and
the figure link-group copies hold the log-space span constant.
- **Key-repeat policy (library-wide):** a held key repeats in every adapter
(GLFW/Qt/GTK already do; ImGui switches to `repeat=true`).
- **Modifiers for box zoom:** Shift+Left = both axes (unchanged, default);
Ctrl+Left = x-only; Alt+Left = y-only. Other modifier combos = both.
## Shared-header changes (made by the integrator before the fan-out)
- `include/plot/input/events.hpp`
- `PointerEvent` gains `f64 time_s = 0.0;` (host-supplied monotonic seconds;
powers double-click). Hosts that leave 0 simply never double-click.
- New `GestureEvent { f32 x, y; f32 scale = 1.0f; f32 dx = 0.0f, dy = 0.0f; }`
— pinch: zoom about (x, y) by `scale`, pan by (dx, dy). `Controller`
gains `void on_gesture(const GestureEvent&)`.
- New key constants: `kKeyEscape = 27`, `kKeyLeft = 256`, `kKeyRight = 257`,
`kKeyUp = 258`, `kKeyDown = 259` (host-translated, like the ASCII ones).
- `include/plot/data/series.hpp`
- `enum class GatherMode { Envelope, Nearest, Mean };` — downsample policy.
`Envelope` = today's min/max pairs. `Nearest` = one raw sample per bucket
(via `snap`), x at the sample. `Mean` = one point per bucket, y = mean
(bucket `sum` / count), x at bucket centre.
- `virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
GatherMode mode = GatherMode::Envelope) const` — signature change; every
override updates; renderer passes the mode explicitly.
- New virtuals (uniform default, XY override):
`virtual f64 x_at(u64 i) const` (`x0() + i*dx()`),
`virtual bool neighbours(f64 x, u64& i0, u64& i1) const` (false when x is
outside `[x_at(0), x_at(n-1)]`; else the bracketing indices),
`virtual u64 snap_index(f64 x) const` (nearest index, clamped).
- `MinMaxPyramid` gains `f64 bucket_sum(u32 l, u64 i) const` and
`f64 bucket_sumsq(u32 l, u64 i) const` (Mean/RMS need them).
- `include/plot/plot/trace.hpp` — `Trace`/`TraceDesc` gain (trailing, defaults
keep aggregate init source-compatible):
`GatherMode mode = GatherMode::Envelope;`
`f32 dash_on = 0.0f, dash_off = 0.0f;` (logical px, 0 = solid),
`enum class Marker { None, Cross, Circle, Square }`, `Marker marker = Marker::None;`
`f32 marker_size_px = 3.0f; u32 marker_every = 0;` (0 = auto: stride
`ceil(samples_per_pixel)`, so markers only appear where samples are sparse).
`width == 0` draws markers only (scatter) — documented.
- `include/plot/render/style.hpp` — gains `f32 line_aa_px = 1.0f;` (logical,
the AA fringe half-width, clamped >= 0.25f at use). No text-colour fields:
text colour becomes a per-call parameter (see C).
- `include/plot/plot/plot.hpp` — `set_viewport` normalises negative w/h to 0 (R1).
- ~~`include/plot/plot/crosshair.hpp` — `CrosshairRow` gains `Color color`~~
DROPPED: `CrosshairRow` already names its trace; the renderer resolves the
colour as `plot.trace(row.trace).color` at draw time. No field added.
## Stream A — data layer (exclusive)
Files: `include/plot/data/*`, `src/data/*`, `tests/test_series.cpp`,
`tests/test_ring_buffer.cpp`, `tests/test_minmax_pyramid.cpp`,
`tests/test_xy_series.cpp` (new), plus the `Series::gather` override
forwarders and `docs/data-and-performance.md` NaN/policy paragraphs.
1. **B3**: bucket-edge selection uses `first = max(ceil_div(origin+b, span), lbase)`,
`last = min((origin+e)/span, lend)`; lead/trail raw spans cover the rest.
Regression: gather at unaligned `begin`/`end` mid-window asserts no emitted
x outside `[begin, end)` and envelope values equal exact reduce over the range.
2. **B7**: NaN policy per above; regression: NaN at first/middle/last position
— bounds and reduce stay finite, sum skips NaN.
3. **R3**: `level_span` caps at level 57 (span 2^63); `gather(level > 57)`
clamps; no UB/div-zero for any u32 level.
4. **R4**: docs say "at most `capacity()` rounded up to a power of two";
`StreamSeries` reserves the *rounded* `ring_.capacity()`.
5. **GatherMode** implementation in `gather_impl` (Mean/Nearest via the new
pyramid accessors); `XY`/`Array`/`Stream` forwarders updated; relative-x
contract implemented everywhere (x emitted as `x_at(i) - x0()`).
6. **XY series** (full feature): `include/plot/data/xy_series.hpp`,
`src/data/xy_series.cpp`, target `plot_core`. Sorted-ascending x
(documented precondition, `assert` in debug); binary-search
`neighbours`/`snap_index`; pyramid over sample index; `commit()` rebuilds.
7. **Snapshot**: `ArraySeries StreamSeries::freeze() const` — consumer-thread
only; copies the retained window (`copy_raw`) into a fresh ArraySeries with
this window's `x0()`/`dx()`. Freezes against the *last acquire* (documented).
8. **Incremental append**: `void ArraySeries::push(f32)` — appends, folds into
the pyramid (`append`), extends bounds incrementally (NaN-aware), extends
`x_bounds_`. `commit()` stays the wholesale path.
Acceptance: new tests for every item above; `plot_tests` builds and passes.
## Stream B — plot + input (exclusive)
Files: `src/plot/ticks.cpp`, `src/plot/plot.cpp`, `src/plot/figure.cpp`,
`src/input/controller.cpp`, `include/plot/input/controller.hpp`,
`include/plot/plot/axis.hpp` (offset doc), `tests/test_ticks.cpp`,
`tests/test_controller.cpp`, `tests/test_plot.cpp`, `tests/test_figure.cpp`,
`tests/test_measure.cpp`, and the plot-layer consumers of the new `Series`
virtuals (`crosshair_value`, `crosshair_span`, `covered_samples`,
`measurement` — route through `x_at`/`neighbours`/`snap_index` so an XY series
crosshairs and measures correctly).
1. **B2**: major = integer-index test (`k % ratio == 0` on the minor index,
`k` derived from `(v - first_major)/minor_step` rounded); no float tolerance.
Regression: epoch range 1.75e9 ± 300 s at 800 px produces labelled majors.
2. **R9**: majors are never truncated; only minors count against `kMaxTicks`.
3. **B4**: live window placement in axis space (log-aware span/offset) in
`Plot::place_live_x_window`, `Plot::set_x_range` live branch, and the two
figure link-group copies. Regression: streaming log axis holds visible
decades constant across samples; scrolled-back window keeps span.
4. **B5**: `on_wheel` — `dy == 0 && dx == 0` returns; `dy == 0, dx != 0` pans x
only (`dx` notches, 5% of the x span per notch, log-aware via `to_space`);
zoom path rejects `factor == 1` and non-finite. Same guard in the
middle-drag `axis_zoom`.
5. **R1**: `layout()` clamp guards `hi >= lo` before clamping (no UB even for a
hostile viewport; `set_viewport` normalises, already done by integrator).
6. **R2**: range setters refuse non-finite endpoints (Off branch + y), keep the
previous range and `auto_scale`.
7. **R8**: `add_measure`/`measurement` refuse horizontal-ruler (and
vertical-ruler dead-field) cursors — `valid` stays false, `add_measure`
returns an id that reports invalid.
8. **Interaction features**:
- Keyboard pan: Left/Right/Up/Down pan 10% of the span per press/repeat
(x-only for Left/Right, y-only for Up/Down; log-aware).
- Escape (`kKeyEscape`): cancels the active drag — restores the ranges the
axes had at `begin_drag` (captured then), drops box-zoom without applying,
clears grab state.
- Box-zoom masks per the modifier table above; applied per-axis at release.
- Double-click reset: two presses of the same button within 0.35 s and 4 px
(uses `PointerEvent::time_s`) re-fits every axis (`auto_scale = true` +
fit). Second press does not begin a drag.
Acceptance: controller tests for each gesture incl. cancel-restore; tick
regressions; XY crosshair/measure unit tests via an `XY` fixture.
## Stream C — render (exclusive)
Files: `src/render/*`, `include/plot/render/*` (renderer/primitives/text/buffer
interfaces), `src/gl/handle.cpp`, `include/plot/gl/handle.hpp`,
`tests/test_render_smoke.cpp`, `tests/test_primitives.cpp`.
1. **B1**: implement the relative-x staging contract (see Cross-cutting):
trace staging reconstructs `x_abs = series.x0() + rel` in f64, stages
`f32(to_space(x_abs) - to_space(origin))`; every data-space line draw
(grid, cursors, regions, measures, crosshair, annotations) picks a natural
origin (its own reference data x/y) and `mapping()` folds
`origin` into the offset uniform in f64 before the single narrowing.
Regression: EGL smoke renders an epoch-window series and a zero-origin
twin and asserts both rasterise to the same pixel column set (tolerance).
2. **B6**: two text batches — `text_under_` (legend names, cursor labels,
measure labels, annotation captions) flushed *immediately before*
`draw_crosshair_readout`; `text_over_` (readout text, tick labels, titles)
flushed at `end_frame`. The readout panel occludes under-batch text.
Regression: legend + overlapping readout frame — legend name pixels are not
lit inside the readout panel rect.
3. **R6**: `begin_frame` saves `GL_BLEND_SRC/_DST` via `glGetIntegerv` and
`end_frame` restores function + enable bit; `Texture::upload_rgba`
saves/restores `GL_UNPACK_ALIGNMENT`; `Renderer::release_gl()` (public)
destroys all GL handles explicitly, called first by the destructor (safe
when the context is current) and documented for host teardown order;
`epoch()` widened to u64 end-to-end (buffer generation + slot epoch +
VAO cached epoch); annotation captions clamp their box inside `axes`
(skip the caption when it cannot fit).
4. **Dash**: trace dash reaches `u_dash` per trace flush (device px, logical
converted); legend swatch samples the dash. `dash_off == 0` = solid.
5. **Markers**: staged as line segments through the existing batch
(Cross 2 segs, Circle 8-gon, Square 4 segs), colour = trace colour, size =
`device(marker_size_px)`, stride `marker_every` or auto `ceil(spp)`;
`width == 0` draws markers only. Render-smoke regression: a marker'd trace
lights vertices a plain line does not, and a `marker_every = 3` trace
stages 1/3 the marker count.
6. **AA knob**: `u_aa` uniform = `max(0.25, device(style.line_aa_px))`; shaders
drop the hardcoded fringe. Default output unchanged (1.0 logical).
7. **Text colour**: per-vertex `a_colour` (vec4); `stage_text(..., Color)`;
`flush_text` stops setting `u_color`; crosshair readout value rows draw in
their row's trace colour (`CrosshairRow::color`), labels in `axis_text`.
Smoke regression: a coloured readout row differs from the axis-text pass.
8. **GatherMode**: renderer passes `trace.mode` to `gather`.
Acceptance: smoke tests above; full suite green; no uniform/attribute drift
(cross-check against GLSL in the same PR).
## Stream D — adapters (exclusive)
Files: `adapters/**` only, `tests/test_glfw_host.cpp`, `tests/test_qt_pointer.cpp`,
`tests/test_gtk_input.cpp`, `tests/test_imgui_widget.cpp`,
`docs/embedding.md` (shared conventions: key repeat, touch parity, DPR note).
1. **R5**: GLFW figure capture records the button; only the owning button's
release ends it; a second button does not steal or clear the capture
(documented single-gesture policy). Test: press left row0, press right row1,
release right — left drag still routes to row0.
2. **R7**: `Host` caches row rects; invalidated on resize + `set_figure`,
refreshed once per frame; event handlers read the cache (one-frame-stale
rows during resize — documented).
3. Key repeat: ImGui `IsKeyPressed(..., repeat=true)`; policy comment.
4. **Timestamps**: all four adapters fill `PointerEvent::time_s`
(glfwGetTime / QEvent timestamps /1000 / gdk time /1000 / ImGui::GetTime).
5. **New keys**: GLFW maps arrows + Escape to the new constants (with
`GLFW_KEY_LEFT` etc.); Qt maps `Key_Left...`; GTK maps `GDK_KEY_Left...`;
ImGui `ImGuiKey_LeftArrow...`, `ImGuiKey_Escape`.
6. **Qt**: `wheelEvent` uses `pixelDelta()/40` notches when `angleDelta` is 0
(pixelDelta path); `DevicePixelRatioChange` refreshes the controller
viewport + scales; pinch via `grabGesture(Qt::PinchGesture)` ->
`GestureEvent`.
7. **GTK4**: `GtkGestureZoom` attached in the plot area factory ->
`GestureEvent` (scale + focal delta); scroll `ScrollUnit::Surface` path
already exists — keep.
8. **GLFW**: `on_scroll` forwards unchanged (controller owns the dy=0 case).
Acceptance: adapter-gated tests updated; toolkit-free seams (qt_pointer,
gtk_input) carry the new logic so headless tests cover it.
## Stream E — build, packaging, docs, CI (exclusive)
Files: root `CMakeLists.txt`, `cmake/*`, `CMakePresets.json`,
`.github/workflows/ci.yml` (new), `plot.pc.in` (new),
`docs/building-and-testing.md`, `docs/getting-started.md`, `README.md`
(feature list + versioning), `tests/test_series.cpp` (delete the wall-clock
steady-state case only), `readme_main.cpp` (new, referenced by README).
1. `GNUInstallDirs` for every install path; `VERSION`/`SOVERSION` on
`plot_core` when shared; `SameMinorVersion` compatibility (0.x reality);
generated `include/plot/version.hpp` (`configure_file`, installed, added to
the umbrella + exemption handling as needed); `plot.pc` pkg-config file;
install `LICENSE` + `licences/OFL-1.1.txt` under
`${CMAKE_INSTALL_DATADIR}/licenses/plot/`; `install(DIRECTORY)` gets
`FILES_MATCHING PATTERN "*.hpp"`.
2. `PLOT_BUILD_TTF_FONT` moves to `cmake/plot_options.cmake` (docs match);
coverage-flag detection switches to a `PLOT_COVERAGE` option check on the
config profile, not a `CMAKE_CXX_FLAGS` string match.
3. CI: `.github/workflows/ci.yml` — ubuntu-latest, apt deps (libglfw3-dev,
qt6-base-dev, libgtk-4-dev, libegl1, libglvnd-dev, xvfb, lcov,
python3-fonttools), jobs: debug (configure/build/xvfb ctest), coverage
(tools/coverage.sh, uploads the table), asan (linux-asan preset). `continue-on-error`
on the ImGui fetch is not needed (network is available on GH runners).
4. Docs: correct the case-count math (recount from the tree at integration
time), remove the stale hard-coded coverage figures from README and
building-and-testing (coverage.md stays the single source, regenerated),
document the new keys/gestures/touch parity in `interaction.md` +
`embedding.md` (note: B/C own feature *code*; E only documents what the
contract above specifies).
Acceptance: `cmake --install` smoke into a temp prefix finds `plot` via
`find_package` in a throwaway consumer; umbrella tests stay green; suite
counts match docs.
## Integration (integrator only)
Merge all streams; reconcile `include/plot/plot.hpp` umbrella; full
configure+build+ctest; regenerate coverage via `tools/coverage.sh`; update the
per-file coverage table and README numbers from the fresh run; run the GLFW
demo headless under Xvfb as the final smoke; update this design record's
status.
## Status: COMPLETE (2026-09-30)
All five streams landed and were integrated on `main` (uncommitted working
tree). `Controller::on_gesture` was implemented by the integrator (Stream B was
cut off before it). Incident: the root filesystem hit 100% mid-wave and
truncated `src/render/renderer.cpp` to 0 bytes; Stream C restored it from its
transcript and re-verified. Final state:
- `linux-debug` suite: **350 cases, 0 failed** (was 283); 4/4 ctest entries pass.
- Coverage: **93.9% aggregate (3730/3971) over 24 files**, every file >= 80%
(floor 80/90) — regenerated in `docs/coverage.md` by `tools/coverage.sh`.
- Headless smoke: `plot_glfw_demo` + `plot_multiplot_demo` under `xvfb-run`,
exit 0, screenshots contain rendered ink (65.4% / 20.0% non-background at
1280x720); the multiplot run reports **20,901 staged GPU segments for
~4.5M visible samples, GL error 0** (README paragraph updated).
- Known follow-ups: real touch hardware has never exercised the Qt/GTK pinch
paths; macOS/Windows remain untested; CI YAML is written but has run only
locally in structure (no GitHub remote execution during this wave).
+11 -1
View File
@@ -22,6 +22,10 @@ class ArraySeries final : public Series {
void reset(std::vector<f32> y, f64 x0, f64 dx);
/// Rebuilds the pyramid and cached bounds from y_ as it stands now.
void commit();
/// Appends one sample incrementally: the pyramid folds it (`append`, amortised
/// O(1)) and the cached bounds extend in O(1), NaN-aware, instead of commit()
/// rebuilding the whole store. The wholesale path stays `commit()`.
void push(f32 y);
u64 size() const override;
f64 x0() const override;
@@ -29,7 +33,8 @@ class ArraySeries final : public Series {
Range x_bounds() const override;
Range y_bounds() const override;
const MinMaxPyramid& pyramid() const override;
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const override;
void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode = GatherMode::Envelope) const override;
protected:
void copy_raw(u64 begin, u64 end, f32* out) const override;
@@ -41,6 +46,11 @@ class ArraySeries final : public Series {
MinMaxPyramid pyramid_;
Range x_bounds_{};
Range y_bounds_{};
/// False until a finite sample has been seen: `y_bounds_` is then a
/// placeholder (zero-span), not an aggregate, and the next finite sample
/// seeds it. This is what makes push()'s incremental extension and commit()'s
/// rebuild agree on a window that holds non-finite samples.
bool y_has_finite_ = false;
};
} // namespace plot::data
+36 -11
View File
@@ -19,6 +19,12 @@ struct MinMax {
/// series and the level maths is a shift.
inline constexpr u32 kBucket = 64;
/// Deepest pyramid level. `kBucket << 57` is `2^63`, the widest bucket span a
/// u64 sample count can name; past it the shift would overflow (UB), so every
/// level computation clamps here and a `gather()` asked for a deeper level
/// simply gets this one (whose buckets are already wider than any window).
inline constexpr u32 kMaxPyramidLevel = 57;
/// Incremental min/max LOD pyramid, indexed in absolute bucket space.
///
/// Level `l` bucket `k` covers the absolute samples
@@ -37,14 +43,14 @@ inline constexpr u32 kBucket = 64;
/// `drop_before(u)` slides the window's leading edge to absolute sample `u`: each
/// level discards the buckets whose span starts before `u`, so the memory is
/// bounded by the retained window and not by the number of samples ever appended.
/// A bucket entry is 24 bytes -- `MinMax` plus the exact `sum`/`sumsq` the exact
/// range queries fold over -- so level 0 costs 0.375n bytes and the geometric tail
/// brings the whole pyramid to about 0.75n bytes, ~19% of the 4n bytes of raw
/// samples. (A min/max-only index would be 0.25n, ~6%: the two doubles are the
/// price of answering mean/RMS/integral from whole buckets instead of raw
/// samples.) Every stored entry is the complete aggregate of its own span and
/// every stored span lies inside the retained window, so no aggregate is ever
/// recomputed or invalidated.
/// A bucket entry is 32 bytes -- `MinMax` plus the exact `sum`/`sumsq` the exact
/// range queries fold over and the finite-sample `count` the mean folds by -- so
/// level 0 costs 0.5n bytes and the geometric tail brings the whole pyramid to
/// about 1n bytes, ~25% of the 4n bytes of raw samples. (A min/max-only index
/// would be 0.25n, ~6%: the extra words are the price of answering mean/RMS/
/// integral from whole buckets instead of raw samples.) Every stored entry is
/// the complete aggregate of its own span and every stored span lies inside the
/// retained window, so no aggregate is ever recomputed or invalidated.
///
/// Queries are in *window* coordinates: index 0 is the oldest retained sample,
/// `size()` is the retained sample count, and `window_base()` is the absolute
@@ -64,6 +70,11 @@ class MinMaxPyramid {
/// appends never grows the level vector. The bucket deques themselves grow in
/// chunks: a sliding window cannot know their final length up front.
void reserve(u64 n);
/// Folds one sample into the pyramid. A non-finite sample advances the
/// absolute sample count (bucket boundaries are positional) but is excluded
/// from the aggregates: min/max/sum/sumsq/count never see it, and a bucket
/// whose samples were all non-finite is stored as an empty aggregate
/// (count 0, zero sums) that every reader treats as absent.
void append(f32 v);
/// Slides the retained window's leading edge to absolute sample index
/// `absolute_sample`, dropping every aggregate that covered a sample before it.
@@ -100,6 +111,15 @@ class MinMaxPyramid {
/// Aggregate of the absolute level-`l` bucket `i`. Precondition:
/// level_base(l) <= i < level_base(l) + level_size(l).
MinMax bucket(u32 l, u64 i) const;
/// The bucket's exact `sum`/`sumsq` folds -- what `GatherMode::Mean` needs to
/// average a bucket and what an RMS readout folds without touching raw
/// samples. Same precondition as `bucket()`.
f64 bucket_sum(u32 l, u64 i) const;
f64 bucket_sumsq(u32 l, u64 i) const;
/// How many *finite* samples the bucket aggregated: the divisor that makes
/// `bucket_sum()` a mean under the non-finite-exclusion policy, and 0 exactly
/// when the bucket holds no usable sample. Same precondition as `bucket()`.
u32 bucket_count(u32 l, u64 i) const;
/// Min/max over the whole level-0 buckets fully contained in the window range
/// [begin, end). Partial edge buckets are excluded, never approximated; an
@@ -119,13 +139,18 @@ class MinMaxPyramid {
u64 snap(f64 x, f64 x0, f64 dx) const;
private:
/// One bucket: its extremes plus its own exact sum and sum of squares. The two
/// doubles take a bucket from 8 bytes to 24, which is what lets mean/RMS/
/// integral come from whole buckets instead of raw samples.
/// One bucket: its extremes plus its own exact sum and sum of squares, and
/// how many finite samples produced them. The extra words take a bucket from
/// 8 bytes to 32, which is what lets mean/RMS/integral come from whole
/// buckets instead of raw samples -- and the count is what keeps a bucket of
/// nothing (all its samples non-finite) empty rather than zero-valued: an
/// entry with count 0 has zero sums and a meaningless mm, and every reader
/// skips it.
struct Entry {
MinMax mm;
f64 sum = 0.0;
f64 sumsq = 0.0;
u32 count = 0; ///< finite samples folded in; 0 = an empty bucket
};
/// One level's complete buckets, in absolute bucket-index order.
struct Level {
+7 -2
View File
@@ -12,8 +12,13 @@ namespace plot::data {
/// Single-producer / single-consumer ring of at most `capacity` elements.
///
/// The capacity is rounded up to a power of two so slot addressing is a mask
/// instead of a modulo. The producer writes a slot and then publishes the new
/// The capacity is rounded up to a power of two -- `capacity()` reports the
/// *rounded* value, not the requested one, and `size()` saturates there -- so
/// slot addressing is a mask instead of a modulo. Callers that need the
/// retained window's exact bound must read `capacity()`, never their own
/// constructor argument (see `StreamSeries`, which reserves the rounded value).
///
/// The producer writes a slot and then publishes the new
/// sample count with a release store; a consumer that observes that count with an
/// acquire load is guaranteed to see the slot contents written before it.
///
+70 -16
View File
@@ -9,6 +9,20 @@
namespace plot::data {
/// What geometry a gather level produces for its buckets. The LOD *level* is
/// chosen exactly as before; the mode decides what each bucket contributes.
enum class GatherMode {
/// One (min, max) envelope pair per bucket: the default, and what a dense
/// time-series trace wants -- the eye reads the extremes.
Envelope,
/// One raw sample per bucket (the pyramid's `snap` pick), x at the sample:
/// a thinner geometry for traces whose *shape* matters more than extremes.
Nearest,
/// One point per bucket, y = the bucket mean (`sum` / count), x at the
/// bucket centre: a smoothed trace for noisy data.
Mean,
};
/// A uniform-grid series of y samples: sample i lives at x = x0() + i*dx().
///
/// Every concrete series answers range queries exactly, and never by scanning the
@@ -43,21 +57,60 @@ class Series {
/// so the series that owns the sliding window is the one that answers.
virtual bool streaming() const noexcept { return false; }
/// Appends interleaved (x, y) pairs in data coordinates for level `level`.
/// Appends interleaved (x, y) pairs for level `level`.
///
/// Level 0 emits the raw samples of [begin, end). Level >= 1 emits one (min, max)
/// envelope pair per *complete* level bucket wholly inside the retained window,
/// bounded by the two partial buckets at the edges of the requested range — the
/// bucket the window starts inside and the bucket the newest samples are in —
/// which it emits as raw samples through copy_raw(). A bucket that is only partly
/// retained holds aggregates of samples the window no longer has and a partial
/// trailing bucket is not stored at all, so neither may be drawn as an envelope.
/// **x is relative to `x_origin`**: the emitted value is `x_at(i) - x_origin` as f32, never
/// the absolute coordinate. A consumer reconstructs the absolute x in f64
/// (`x_origin + emitted`) before mapping it to the screen; staging an absolute epoch-time x
/// as f32 would quantise it to a grid coarser than the pixel (f32 carries 24 bits -- 1 s at
/// 1.7e9). y is unchanged.
///
/// The emitted geometry therefore always spans [begin, end) exactly, for at most
/// two buckets of extra raw work (about two pixels at the level level_for()
/// picks), and never reaches outside the retained window. Pairs are appended to
/// out_xy, which is never cleared.
virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const = 0;
/// **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
/// geometry element per *complete* level bucket wholly inside the retained
/// window, bounded by the two partial buckets at the edges of the requested
/// range -- the bucket the window starts inside and the bucket the newest
/// samples are in -- which it emits as raw samples through copy_raw(). A
/// bucket that is only partly retained holds aggregates of samples the
/// window no longer has and a partial trailing bucket is not stored at all,
/// so neither may be drawn from aggregates. `mode` picks what a bucket
/// contributes (see `GatherMode`); the edge raw samples are the same for
/// every mode. A bucket whose samples are all non-finite is empty, not
/// zero-valued (the library-wide NaN policy): Envelope and Mean emit nothing
/// for it, Nearest draws the raw centre sample. `level` is clamped to
/// `kMaxPyramidLevel`.
///
/// The emitted geometry therefore always spans [begin, end) exactly, for at
/// most two buckets of extra raw work (about two pixels at the level
/// level_for() picks), and never reaches outside the retained window -- nor
/// outside [begin, end). Pairs are appended to out_xy, which is never
/// cleared.
virtual void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode = GatherMode::Envelope) const = 0;
/// Absolute x of sample `i` (`x0() + i*dx()` on a uniform-grid series).
/// Clamping is the caller's business: out-of-range `i` is the caller's bug.
virtual f64 x_at(u64 i) const;
/// The samples bracketing data-space `x`: sets `i0` to the last index with
/// `x_at(i0) <= x` and `i1` to the first with `x_at(i1) >= x` (equal when x
/// lands exactly on a sample). False when x lies outside the series' x
/// bounds; `i0`/`i1` are then untouched. The crosshair and measurement
/// paths read coordinates only through this and `x_at()`, so a series with
/// its own x array brackets exactly.
virtual bool neighbours(f64 x, u64& i0, u64& i1) const;
/// Index of the sample nearest data-space `x`, clamped to `[0, size()-1]`;
/// 0 for an empty series.
virtual u64 snap_index(f64 x) const;
/// Exact min/max, sum and sum-of-squares over [begin, end).
/// Preconditions: begin < end, end <= size(); a degenerate range yields a
@@ -75,8 +128,9 @@ class Series {
virtual void copy_raw(u64 begin, u64 end, f32* out) const = 0;
/// The shared body of the concrete gather() overrides: raw samples at level 0,
/// bucket min/max envelope above it.
void gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const;
/// bucket geometry above it (per `mode`).
void gather_impl(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode) const;
private:
/// [begin, end) split into the bucket-aligned interior the pyramid can answer
@@ -97,7 +151,7 @@ class Series {
/// 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:
/// 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
+19 -1
View File
@@ -5,6 +5,7 @@
#include <vector>
#include <plot/core/types.hpp>
#include <plot/data/array_series.hpp>
#include <plot/data/minmax_pyramid.hpp>
#include <plot/data/ring_buffer.hpp>
#include <plot/data/series.hpp>
@@ -57,6 +58,11 @@ namespace plot::data {
/// class specifies.
class StreamSeries final : public Series {
public:
/// `capacity` is the sample count to retain; it is rounded up to a power of
/// two by the ring (see `RingBuffer`), and `size()` saturates at that rounded
/// `capacity()` -- a `StreamSeries(100)` retains up to 128 samples. The
/// pyramid's level table is reserved for the rounded capacity up front, so a
/// saturated stream never grows it.
explicit StreamSeries(u64 capacity, f64 x0, f64 dx);
/// Producer thread only.
@@ -68,6 +74,17 @@ class StreamSeries final : public Series {
/// Consumer thread only: snapshots the produced count and brings the pyramid
/// and bounds up to it.
void acquire();
/// Consumer thread only: copies the window published by the *last acquire()*
/// into a fresh `ArraySeries` with this window's `x0()`/`dx()`. The frozen
/// series never changes again, no matter what the producer pushes afterwards
/// -- it answers from its own copy, not from the ring -- which is what a
/// consumer that needs a stable window for longer than one read wants. (The
/// ring itself keeps overwriting; only the copy is frozen, so this costs one
/// window's worth of samples. It reads through `copy_raw()`, so like every
/// read it is a snapshot of *which* absolute indices to walk, not a guarantee
/// about slots a producer is overwriting -- call it on the consumer thread,
/// interleaved with `acquire()`, per the class note.)
ArraySeries freeze() const;
u64 size() const override;
f64 x0() const override;
@@ -75,7 +92,8 @@ class StreamSeries final : public Series {
Range x_bounds() const override;
Range y_bounds() const override;
const MinMaxPyramid& pyramid() const override;
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const override;
void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode = GatherMode::Envelope) const override;
/// Always true: this series' `x_bounds()` is the retained ring window, which
/// slides with every `acquire()` -- the property `XFollow::Live` follows.
bool streaming() const noexcept override { return true; }
+87
View File
@@ -0,0 +1,87 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <vector>
#include <plot/core/types.hpp>
#include <plot/data/minmax_pyramid.hpp>
#include <plot/data/series.hpp>
namespace plot::data {
/// Series over its own x array: sample i lives at `x()[i]`, and the samples are
/// sorted by ascending x. This is the store for data whose x is *not* on a
/// uniform grid -- scattered measurements, event times, a log-swept frequency
/// axis -- where `ArraySeries`' fixed `dx` cannot hold.
///
/// **Precondition: `x` is sorted ascending.** The O(log n) crosshair and
/// nearest-sample lookups are binary searches, which are only correct on sorted
/// input; the constructor and `reset()` assert sortedness in debug builds and
/// the behaviour with unsorted input is undefined in release ones. Callers with
/// a possibly-unsorted source sort it first (sorting the *pairs*, not the
/// arrays independently, or the samples get scrambled).
///
/// Everything the base `Series` contracts to holds, with the sample index as
/// the grid: the pyramid buckets samples by *index* (`kBucket` samples per
/// level-0 bucket, exactly as for a uniform series) and `gather()` emits x
/// relative to `x0()` -- `f32(x_at(i) - x0())`, never the absolute
/// coordinate -- so an epoch-scale x array narrows to f32 the same safe,
/// O(visible pixels) way a uniform one does. The queries that need a *uniform*
/// grid are overridden with the binary-search answer instead: `x_at()` reads
/// the array, `neighbours()` brackets with `upper_bound`/`lower_bound`, and
/// `snap_index()` compares the two candidates the search leaves. `dx()` is
/// meaningless here and returns 0: nothing that routes through
/// `x_at`/`neighbours`/`snap_index` consults it.
class XYSeries final : public Series {
public:
/// Takes both arrays by value. `x.size() == y.size()` and `x` sorted
/// ascending (asserted in debug).
XYSeries(std::vector<f64> x, std::vector<f32> y);
/// Replaces both arrays, then rebuilds derived state (== commit()). Same
/// preconditions as the constructor.
void reset(std::vector<f64> x, std::vector<f32> y);
/// Rebuilds the pyramid and the cached bounds from the arrays as they stand.
void commit();
u64 size() const override;
/// x of sample 0: the first (smallest) x in the array.
f64 x0() const override;
/// Always 0: the grid is the x array, not a uniform step.
f64 dx() const override;
/// From the first and last x; a zero span for an empty series.
Range x_bounds() const override;
Range y_bounds() const override;
const MinMaxPyramid& pyramid() const override;
/// Same contract as `Series::gather`, with x emitted relative to `x_origin`.
void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode = GatherMode::Envelope) const override;
/// The sample's own x.
f64 x_at(u64 i) const override;
/// Binary-search bracket of data-space `x`: false outside
/// `[x_at(0), x_at(size() - 1)]`, else the last sample at or left of `x` and
/// the first at or right of it (equal on an exact hit).
bool neighbours(f64 x, u64& i0, u64& i1) const override;
/// Binary-search nearest sample, clamped to `[0, size() - 1]`; ties go to the
/// lower index, and an empty series returns 0.
u64 snap_index(f64 x) const override;
/// The x array itself (sorted ascending).
const std::vector<f64>& x() const noexcept { return x_; }
protected:
void copy_raw(u64 begin, u64 end, f32* out) const override;
private:
std::vector<f64> x_;
std::vector<f32> y_;
MinMaxPyramid pyramid_;
Range x_bounds_{};
Range y_bounds_{};
/// False until a finite sample has been seen (see `ArraySeries`): the cached
/// y bounds are then a placeholder, and the first finite sample seeds them.
bool y_has_finite_ = false;
};
} // namespace plot::data
+8
View File
@@ -58,8 +58,14 @@ enum : GLenum {
// Blending.
GL_BLEND = 0x0BE2,
GL_ZERO = 0x0000,
GL_ONE = 0x0001,
GL_SRC_ALPHA = 0x0302,
GL_ONE_MINUS_SRC_ALPHA = 0x0303,
GL_BLEND_SRC_RGB = 0x80C9 /* 1.4 */,
GL_BLEND_DST_RGB = 0x80C8 /* 1.4 */,
GL_BLEND_SRC_ALPHA = 0x80CB /* 1.4 */,
GL_BLEND_DST_ALPHA = 0x80CA /* 1.4 */,
// Pixel store state.
GL_UNPACK_ALIGNMENT = 0x0CF5,
@@ -127,7 +133,9 @@ enum : GLenum {
X(void, glClearColor, (float, float, float, float)) \
X(void, glClear, (GLbitfield)) \
X(void, glEnable, (GLenum)) \
X(GLboolean, glIsEnabled, (GLenum)) \
X(void, glBlendFunc, (GLenum, GLenum)) \
X(void, glBlendFuncSeparate, (GLenum, GLenum, GLenum, GLenum)) /* 1.4 */ \
X(void, glPixelStorei, (GLenum, GLint)) \
X(void, glDisable, (GLenum)) \
X(void, glScissor, (GLint, GLint, GLsizei, GLsizei)) \
+8 -2
View File
@@ -36,7 +36,10 @@ class Handle {
/// already used, so state that captured the old object -- the attribute
/// pointers of a vertex array above all -- must compare this counter rather
/// than the name (see `render::detail::InstanceStore::epoch()`).
u32 generation() const noexcept { return generation_; }
///
/// 64-bit on purpose: `epoch()` sums this with a slot counter, and a long-lived
/// streaming frame loop must not be able to wrap a u32 sum into a stale value.
u64 generation() const noexcept { return generation_; }
/// Deletes the GL object, if any, and empties the handle.
void reset() noexcept {
@@ -73,7 +76,7 @@ class Handle {
GLuint id_ = 0;
private:
u32 generation_ = 0;
u64 generation_ = 0;
};
/// Vertex buffer object. All fallible operations report through `Status`;
@@ -172,6 +175,9 @@ class Texture final : public Handle<Texture> {
bool create_r8(u32 width, u32 height, Status* st = nullptr) noexcept;
/// Replaces the whole image with `width * height * 4` bytes of RGBA data.
/// Sets `GL_UNPACK_ALIGNMENT` to 4 for the upload and restores the caller's
/// value afterwards, so a host that shares the context never inherits this
/// call's pixel-store state.
void upload_rgba(u32 width, u32 height, const void* pixels) const noexcept;
/// Replaces the whole image with `width * height` bytes of single-channel
+80 -4
View File
@@ -3,6 +3,8 @@
#pragma once
#include <vector>
#include <plot/core/rect.hpp>
#include <plot/core/types.hpp>
#include <plot/input/events.hpp>
@@ -47,14 +49,21 @@ using plot::RegionGrab;
/// | gesture | mode | effect |
/// |---|---|---|
/// | shift + left drag | `BoxZoom` | both ranges from the selection rect |
/// | ctrl + left drag | `BoxZoom` | x range only from the selection rect |
/// | alt + left drag | `BoxZoom` | y range only from the selection rect |
/// | left drag on a handle | `MoveCursor` / `MoveRegion` / `ResizeRegion*` | move the handle the press grabbed |
/// | left drag elsewhere | `Pan` | translate both ranges |
/// | right drag | `Pan` | same, for hosts that reserve left |
/// | middle drag on the x label strip | `AxisZoomX` | x-only zoom |
/// | middle drag on the y label strip | `AxisZoomY` | y-only zoom |
/// | wheel (shift: x only, ctrl: y only) | -- | zoom about the pointer |
/// | wheel `dx` (no `dy`) | -- | pan x, 5% of the span per notch |
/// | `+` / `-` | -- | zoom about the plot centre |
/// | `f` | -- | auto-fit every axis |
/// | Left/Right | -- | pan every x axis, 10% of its span |
/// | Up/Down | -- | pan every y axis, 10% of its span |
/// | Escape | -- | cancel the active drag: the ranges captured at `begin_drag` are restored, a box zoom applies nothing |
/// | double click (same button, 0.35 s, 4 px, `PointerEvent::time_s`) | -- | re-fit every axis; the second press begins no drag |
///
/// The right button pans *and* grabs: both are buttons a host drives drags with, so
/// neither one is singled out for handles. Middle keeps its own role on the label
@@ -74,9 +83,10 @@ using plot::RegionGrab;
/// Cursors and regions are *grabbable*: a press on one enters a drag that moves it,
/// with no modifier, and a press anywhere else pans exactly as it always has -- so
/// the new gesture is a superset of the old one and every host that drives a drag
/// keeps panning. `Ctrl` is no longer needed for a cursor move and does not change
/// what a press on one does; it only means the same thing it always did (a press
/// away from a handle pans, with or without it).
/// keeps panning. `Ctrl` and `Alt` no longer fall through to that press: they are
/// the modifier box zoom's masks now (see the gesture table -- Ctrl frames the x
/// range only, Alt the y range only, from the selection, over whatever was under
/// the press), which is why a *plain* press is the one that grabs a handle or pans.
///
/// One hit test runs for both, in this order, and that precedence is the whole rule:
///
@@ -203,9 +213,18 @@ class Controller {
/// zeroed: a zero-notch zoom changes no range but still goes through the range
/// setters, which clear auto scale -- so a sanitised amount would quietly disarm
/// auto-fit. An unusable amount leaves the plot exactly as it was; an unusable
/// *position* only loses its anchor, falling back to the last known one.
/// *position* only loses its anchor, falling back to the last known one. The
/// same refusal covers `dx`, which pans when `dy` is zero.
void on_wheel(const WheelEvent& e);
void on_key(const KeyEvent& e);
/// A pinch frame from a touch host (see `GestureEvent`): `scale` zooms about
/// `(x, y)` exactly like a wheel notch pair would -- the same setters, the
/// same live-axis behaviour, the same guards -- and `(dx, dy)` pans by the
/// focal point's movement. A frame with `scale == 1` and no movement is
/// refused outright, for the same reason a zero-notch wheel is: the setters
/// would clear `auto_scale` for no visible change. An amount that is not a
/// number is refused rather than sanitised.
void on_gesture(const GestureEvent& e);
DragMode drag_mode() const noexcept { return mode_; }
/// The current box-zoom selection in pixels; meaningful while BoxZoom.
@@ -237,6 +256,35 @@ class Controller {
void update_drag(const PointerEvent& e);
void end_drag(const PointerEvent& e);
/// The ranges (and, for x, the live offsets and auto-scale flags) every axis held
/// when a drag began -- what Escape restores. Captured at `begin_drag`, before the
/// gesture can move anything.
void capture_ranges();
/// Escape: the active drag is cancelled -- the ranges captured at `begin_drag`
/// are restored, a box zoom applies nothing, and the grab state is cleared. The
/// restore goes through the plot's own setters, so a live axis is restored as the
/// span and offset it held (the setters' native reading) rather than as positions
/// that the next frame's follow would overwrite anyway.
void cancel_drag();
/// Re-fits every axis (`auto_fit_x()` plus one `auto_fit_y()` per y axis), which
/// re-arms auto scale with the fit: the double-click reset.
void fit_all();
/// True when `e` is the second press of a double-click: the same button, within
/// 0.35 s (both timestamps > 0 -- a host that leaves `time_s` at 0 never produces
/// one) and within 4 logical px of the recorded press. Records `e` as the press a
/// third one would be measured against either way; a detected double-click clears
/// the record, so a third press starts a fresh gesture rather than re-firing.
bool double_click(const PointerEvent& e);
/// Pans every x axis by `fraction` of its own span, in axis space (log-aware:
/// a log axis translates multiplicatively). A positive fraction scrolls toward
/// larger x. The wheel's `dx` path and the Left/Right arrows share it.
void shift_x_axes(f64 fraction);
/// Same for every y axis: a positive fraction scrolls toward larger y (the Up
/// arrow). Screen y is inverted, so this is *not* the sign `pan()` derives from a
/// drag's `dy` -- an arrow says where to look, a drag says where the data goes.
void shift_y_axes(f64 fraction);
void pan(const PointerEvent& e);
void box_zoom_update(const PointerEvent& e);
void apply_box_zoom();
@@ -317,6 +365,34 @@ class Controller {
Rect selection_{};
f32 origin_x_ = 0.0f;
f32 origin_y_ = 0.0f;
/// The box-zoom mask, resolved once at `begin_drag` from the modifier table
/// (Shift = both, Ctrl = x-only, Alt = y-only, any other combo = both): which
/// axes the selection's release frames. `apply_box_zoom()` reads these for the
/// per-axis application *and* for the click threshold -- a masked-out dimension
/// of a thin selection does not make the gesture a click.
bool box_zoom_x_ = true;
bool box_zoom_y_ = true;
/// The range state a drag began with, per axis in order: what `cancel_drag()`
/// restores. Empty while no drag is open.
struct SavedX {
Range range{};
f64 offset = 0.0;
bool auto_scale = false;
};
struct SavedY {
Range range{};
bool auto_scale = false;
};
std::vector<SavedX> saved_x_;
std::vector<SavedY> saved_y_;
/// The last press seen, for the double-click reset: the button, the host
/// timestamp, and the position it was made at. `has_click_` is false until the
/// first press arrives.
bool has_click_ = false;
int click_button_ = -1;
f64 click_time_s_ = 0.0;
f32 click_x_ = 0.0f;
f32 click_y_ = 0.0f;
u64 drag_cursor_ = kInvalid;
/// The region drag's state. `drag_region_` is the region and `drag_zone_` the zone
/// the press resolved, kept for the whole gesture so the highlight a grab started
+31
View File
@@ -35,6 +35,26 @@ struct PointerEvent {
bool down = false; ///< the button is held after this event
bool pressed = false; ///< a press transition happened on this event
bool released = false; ///< a release transition happened on this event
/// Host-supplied monotonic seconds (the host's own clock, only differences
/// are read). Two presses of the same button within 0.35 s and 4 px are a
/// double-click and re-fit every axis. A host that leaves 0 never produces
/// one -- the field is optional, not another synchronisation contract.
f64 time_s = 0.0;
};
/// A pinch/two-finger gesture, in viewport pixels. The toolkit adapters
/// translate their native pinch state into one of these per frame of the
/// gesture: `scale` is this frame's zoom factor about `(x, y)` (1.0 = no
/// change), and `(dx, dy)` the focal point's movement since the previous
/// gesture event (a two-finger pan). GLFW has no touch input, so only hosts
/// that can deliver one feed this; the controller treats it exactly like the
/// equivalent wheel + pointer pair.
struct GestureEvent {
f32 x = 0.0f;
f32 y = 0.0f;
f32 scale = 1.0f;
f32 dx = 0.0f;
f32 dy = 0.0f;
};
/// A wheel/scroll event at a pixel position. A positive `dy` (scroll up) zooms
@@ -65,4 +85,15 @@ inline constexpr int kKeyFit = 'f';
/// Hosts that report shifted keys uppercase can pass this unchanged.
inline constexpr int kKeyFitUpper = 'F';
/// Escape cancels the active drag (pan, box zoom, cursor/region drag): the
/// ranges the axes had when the drag began are restored and no box is applied.
inline constexpr int kKeyEscape = 27;
/// Arrow keys pan: Left/Right move every x axis by 10% of its span per press
/// (repeat included), Up/Down move every y axis. Hosts translate their native
/// key codes to these, exactly as they do for the ASCII set above.
inline constexpr int kKeyLeft = 256;
inline constexpr int kKeyRight = 257;
inline constexpr int kKeyUp = 258;
inline constexpr int kKeyDown = 259;
} // namespace plot::input
+7
View File
@@ -37,6 +37,7 @@
#include <plot/data/ring_buffer.hpp>
#include <plot/data/series.hpp>
#include <plot/data/stream_series.hpp>
#include <plot/data/xy_series.hpp>
#include <plot/render/embedded_mono_font.hpp>
#include <plot/render/buffer.hpp>
@@ -64,3 +65,9 @@
#include <plot/input/controller.hpp>
#include <plot/input/events.hpp>
// The library version. The one public header configure_file() generates into
// the build tree (see include/plot/version.hpp.in), so it is not under the
// source include/ root with the layers above -- last here so the layer order
// stays the story of the list.
#include <plot/version.hpp>
+32 -9
View File
@@ -52,19 +52,42 @@ struct Axis {
AxisPlace place = AxisPlace::Bottom;
bool log = false;
TickFormat format = TickFormat::Si;
/// The value the labels are written *relative to*: every value on this axis is formatted as
/// `v - origin`, and the ticks are generated on that shifted grid. A host uses it to read a
/// zoomed-in window against its own left edge -- the labels then say `0, 0.5ms, 1ms` with the
/// window's start printed once beside the axis, instead of `12.345671s, 12.345672s` (which
/// three significant digits cannot even distinguish). 0 by default, which is the plain
/// absolute axis; the *positions* of the ticks are unchanged by it in the sense that only
/// their printed value shifts.
f64 origin = 0.0;
/// When set, `origin` is re-taken from the axis' own range on every `refresh()` -- the labels
/// then count from whatever the left edge currently is, and the tick on that edge reads zero
/// exactly, without a host having to predict where the follow will place the window (which it
/// cannot: the window moves with the samples, and a value written a frame earlier is a frame
/// of scroll stale). The window-start label a host draws beside the axis is then
/// `origin` *after* the frame, which is the value these labels were built with.
bool origin_follows_range = false;
/// Whether this axis follows the newest sample. Only meaningful on an x axis --
/// a y axis has no live edge -- and `Off` on both, so nothing changes for a
/// caller who never asks for it. See XFollow.
XFollow follow = XFollow::Off;
/// How far behind the live edge the visible window's right edge sits, in data
/// units, under `XFollow::Live`. Stored rather than implied by the range,
/// because the range is recomputed from the series' newest x on every frame:
/// this is the part of the view the user chose, so it is the part that survives
/// the recompute. Three things write it, all of them through one clamp: a pan or
/// a zoom through `Plot::set_x_range()`, which derives it from the range the
/// gesture asked for, `Plot::set_x_offset()` for a caller that states it
/// directly, and `Plot::layout()`'s placement, which re-clamps it against the
/// retained window every frame. `Plot::x_offset()` reads it back.
/// How far behind the live edge the visible window's right edge sits, in **axis
/// space**, under `XFollow::Live`: data units on a linear axis, `log10` decades
/// on a logarithmic one -- the same space the controller's pan/zoom gestures run
/// in (`to_space`/`from_space`), so a log axis scrolls and holds *decades* the
/// way a linear axis scrolls and holds units. (`to_space(live.max) -
/// to_space(hi)` is the definition: the distance the axis' own transform
/// measures between the live edge and the window's right edge.) Stored rather
/// than implied by the range, because the range is recomputed from the series'
/// newest x on every frame: this is the part of the view the user chose, so it
/// is the part that survives the recompute. Three things write it, all of them
/// through one clamp: a pan or a zoom through `Plot::set_x_range()`, which
/// derives it from the range the gesture asked for, `Plot::set_x_offset()` for a
/// caller that states it directly, and `Plot::layout()`'s placement, which
/// re-clamps it against the retained window -- measured in the same space --
/// every frame. `Plot::x_offset()` reads it back.
f64 offset = 0.0;
/// Set by an *owner* that is following this axis' window on the plot's behalf: a
/// `Figure` link group, while it follows, writes its own live edge here every frame.
+87 -27
View File
@@ -30,6 +30,35 @@ struct CrosshairStyle {
///< (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
/// per-signal readout is wanted.
///
@@ -44,10 +73,10 @@ struct CrosshairStyle {
/// 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.
///
/// **The readout's values are interpolated, and deliberately so.** The y a row reports for
/// a trace is *linear interpolation between the two samples adjacent to `x`*, not a sample
/// and not an interval statistic -- see `crosshair_span()` for the rule stated in full, and
/// for why it is the opposite convention from `Series::reduce()`/`sum()`/`sumsq()`.
/// **The readout's values are samples by default** -- the y a row reports is the nearer of
/// the two samples adjacent to `x`, and its marker sits on that sample. `value_mode` is what
/// chooses otherwise; see `CrosshairValue` for the two questions it picks between, and
/// `crosshair_span()` for how both differ from `Series::reduce()`/`sum()`/`sumsq()`.
struct Crosshair {
/// 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
@@ -58,7 +87,7 @@ struct Crosshair {
/// 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.
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
/// measure and the panel is then not placed at all.
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
/// crosshair, which draws no row and anchors its readout at the top of the axes.
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
/// default-constructed `CrosshairStyle` defers every field to `render::Style` (see
/// `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
/// move, so that is a requirement rather than a nicety.
///
/// **Interpolated, not exact.** The value a caller builds from this span by
/// `crosshair_interpolate()` between the two samples is the signal *at* `x`, which is
/// generally between two samples -- the opposite convention from `Series::reduce()`,
/// `sum()` and `sumsq()`, which are **exact over a sample range**. The two conventions sit
/// next to each other in this library on purpose and they do not agree: `reduce()` answers
/// "what are the samples' extremes over this interval", this answers "what is the signal at
/// 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.
/// **`t` is a position, not yet an answer.** It says where the query sits between the two
/// samples and nothing more; what a caller makes of it is `CrosshairValue`'s to decide. In
/// `NearestSample` -- the default -- it *selects*, `t <= 0.5` taking `begin`, so the readout
/// quotes a sample the series actually holds. In `Interpolated` it *blends*, through
/// `crosshair_interpolate()`. A plot cannot tell which is right, because the question is
/// about the data and not about the drawing; `CrosshairValue` is where that is argued.
///
/// 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
/// 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
/// lands on a sample, `begin + 1` between two of them.
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;
bool valid = false;
};
/// The sample span `x` falls in, on `series`; see `CrosshairSpan` for the rule, for the
/// interpolation convention it is half of, and for the cases that yield `valid == false`.
/// The sample span `x` falls in, on `series`; see `CrosshairSpan` for what `t` means, for the
/// 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
/// fetch is the caller's -- `Plot::crosshair_value()` reads the span through
@@ -118,12 +155,14 @@ struct CrosshairSpan {
/// allocates.
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
/// `t == 1`. The one place the interpolation's arithmetic lives, so the lookup's rule and
/// its documentation cannot drift apart.
inline f64 crosshair_interpolate(f64 y_begin, f64 y_end, f64 t) noexcept {
return y_begin + (y_end - y_begin) * t;
}
/// The linear blend of a span's two end samples at `t`: `y_begin + t * (y_end - y_begin)`,
/// which is what `CrosshairValue::Interpolated` reports.
///
/// Written as that form rather than `(1 - t) * a + t * b` so `t == 0` returns `y_begin`
/// 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
/// 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
/// not a line in a panel that has no room for it.
bool listed = false;
/// The trace's interpolated y at the crosshair's x, on the trace's own y axis. Only
/// meaningful when `valid`.
/// The data x the row's value belongs to, on the trace's own x axis: in
/// `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;
/// The trace's name, or the positional placeholder of an unnamed one.
std::string label;
@@ -161,9 +206,24 @@ struct CrosshairRow {
/// `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.
std::string value;
/// The trace's intersection with the crosshair, in framebuffer pixels: the pen the
/// renderer draws the marker at. Placed through the *trace's own* axis pair, so the
/// marker is where that trace is actually drawn. Meaningless when `!valid`.
/// Where the renderer draws the marker, in framebuffer pixels: the pen for `(x, y)`, placed
/// through the *trace's own* axis pair, so a trace on a secondary y axis is marked on the
/// 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{};
/// The swatch's box, `Legend::swatch_w` x `Legend::swatch_h` centred in the row. Empty
/// when `!listed`.
+20
View File
@@ -6,6 +6,7 @@
#include <plot/core/color.hpp>
#include <plot/core/types.hpp>
#include <plot/math/vec2.hpp>
namespace plot {
@@ -86,6 +87,25 @@ struct Cursor {
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`.
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
/// place the orientation becomes a branch, so every consumer takes the same decision and
+26 -1
View File
@@ -126,6 +126,28 @@ class Figure {
void set_row_gap(f32 device_px) noexcept { row_gap_ = device_px > 0.0f ? device_px : 0.0f; }
f32 row_gap() const noexcept { return row_gap_; }
/// A floor under the figure's common left gutter, in device pixels. The gutter
/// `layout()` imposes is the maximum over the rows' *measured* needs, so it follows
/// the tick labels: a `TickFormat::Si` axis whose range moves from `1.5` to `150` to
/// `1.5k` changes the width of its labels, and with it `axes.x` and `axes.w` of every
/// row. A caller that needs a layout which does not depend on content reserves the
/// widest label its formatter can produce once, through this, and the figure takes the
/// larger of the two. Negative values are clamped to 0 (the default: measure only).
void set_min_y_gutter(f32 device_px) noexcept {
min_y_gutter_ = device_px > 0.0f ? device_px : 0.0f;
}
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
/// 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.
@@ -143,7 +165,8 @@ class Figure {
/// It is also the one place the rows' left chrome can be made to agree (spec §16):
/// each row is laid out once to learn the gutter its own labels and title column
/// need, the figure takes the maximum and imposes it on every row through
/// `Plot::set_min_y_gutter()` before laying the rows out once more -- so every row's
/// `Plot::set_min_y_gutter()` -- never below `set_min_y_gutter()`'s own floor -- before
/// laying the rows out once more, so every row's
/// `axes.x` and `axes.w` are equal and a vertical feature lands in the same pixel
/// column in each. Two passes per row per call, not a loop: the gutter is a maximum
/// over rows and a row's own strip width does not depend on it.
@@ -312,6 +335,8 @@ class Figure {
std::vector<XLink> links_;
FigureLayout layout_{};
f32 row_gap_ = 0.0f;
f32 min_y_gutter_ = 0.0f;
f32 right_gutter_ = 0.0f;
bool labels_bottom_only_ = true;
};
+112 -11
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.
f32 y_title_width = 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
/// 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
@@ -101,8 +106,8 @@ struct PlotLayout {
/// where a pointer can grab it cannot drift apart.
std::vector<RegionLayout> regions;
/// 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
/// is over. Placed here rather than derived by the renderer, exactly as the legend and
/// readout panel and one row per visible trace with the value of the sample nearest the
/// pointer. Placed here rather than derived by the renderer, exactly as the legend and
/// 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
/// over a label strip or into a figure's neighbouring row.
@@ -152,8 +157,16 @@ class Plot {
// --- viewport, style, text metrics ------------------------------------
/// The rectangle this plot occupies on screen, in framebuffer pixels.
void set_viewport(Rect px) noexcept { viewport_ = px; }
/// The rectangle this plot occupies on screen, in framebuffer pixels. A
/// negative width or height is normalised to 0: the layout clamps against
/// the viewport's dimensions and a negative would make those clamps
/// ill-formed (`std::clamp` with `lo > hi`), so the invariant is repaired
/// at the one place a hostile rect can enter.
void set_viewport(Rect px) noexcept {
if (px.w < 0.0f) px.w = 0.0f;
if (px.h < 0.0f) px.h = 0.0f;
viewport_ = px;
}
const Rect& viewport() const noexcept { return viewport_; }
/// Set from the renderer, which owns presentation: `Renderer::draw()` copies
@@ -221,6 +234,13 @@ class Plot {
/// 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_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
/// 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
@@ -464,6 +484,46 @@ class Plot {
///
/// Ignored for a vertical cursor, whose snap is `SnapMode` -- see `set_cursor_snap()`.
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
/// measure was added, or when the series underneath it changed: a different
@@ -585,6 +645,20 @@ class Plot {
}
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.
const Grid& grid() const noexcept { return grid_; }
@@ -645,20 +719,31 @@ class Plot {
/// dash, which live on `CrosshairLayout` because they depend on `ui_scale`.
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
/// 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.
const Crosshair& crosshair() const noexcept { return crosshair_; }
/// The **interpolated** value of `trace`'s series at a data `x`: `crosshair_span()`
/// between the two adjacent samples, then `crosshair_interpolate()` -- the same lookup
/// the readout's rows are built from, exposed for a host that wants the number for
/// itself (a status line, a tooltip of its own).
/// The value this plot's readout would report for `trace` at a data `x`: the nearer
/// bracketing sample's y, or the blend of the two, according to
/// `crosshair_value_mode()` -- the same lookup the readout's rows are built from, exposed
/// 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
/// 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
/// `crosshair_span()` for why an out-of-window x is not a value.
/// documents, plus the trace itself. Never a clamped answer: see `crosshair_span()` for why
/// 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
/// index arithmetic and the samples are read through one `Series::gather()` of at most
@@ -666,6 +751,17 @@ class Plot {
/// 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;
/// 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:
/// 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
@@ -773,7 +869,7 @@ class Plot {
/// never names a trace.
void build_legend();
/// 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
/// 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.
@@ -783,11 +879,16 @@ class Plot {
render::Style style_{};
const render::TextBackend* text_ = nullptr;
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().
XStrip x_strip_ = XStrip::Both;
/// The floor a figure imposes on the left gutter; see set_min_y_gutter(). 0.0 means
/// none, which is what a plot standing on its own keeps.
f32 min_y_gutter_ = 0.0f;
/// 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).
HotHandles hot_{};
+18 -4
View File
@@ -48,7 +48,10 @@ void format_si(f64 v, char* buf, usize n, const char* unit = nullptr);
/// field is derived from that one number, so no field can ever reach its radix:
/// 119.5 s is `2:00`, not `1:60`, and 3599.5 s is `1:00:00`.
///
/// Shapes, by magnitude: below 1 s `500ms`; below 60 s `1.5s`; below 1 h `1:30`;
/// Shapes, by magnitude: below 1 s the prefixed unit that keeps the mantissa in
/// `[1, 1000)` -- `500ms`, `5ms`, `500us`, `5us`, `5ns`, `5ps` (the micro sign is
/// UTF-8 U+00B5, as in `format_si`) -- with an exact zero printed as `0` rather
/// than as a zero of some unit; below 60 s `1.5s`; below 1 h `1:30`;
/// at or above 24 h, as a decimal day count `2.5d`. Between 1 h and 24 h the
/// shape is `1:01:01` -- and because a value that rounds up to 24 h takes the day
/// shape, the hour field of that shape is always below 24. The 60 s and 1 h
@@ -68,14 +71,25 @@ inline constexpr usize kMaxTicks = 256;
/// `include_minors` adds, for grid geometry, the minor ticks subdividing each
/// major step by 5 (by 4 when the step's leading digit is 2). Without it `out`
/// holds exactly the labelled major ticks -- the labelled set a caller draws
/// numbers for, with no unlabelled entries to filter. Either way the total is
/// capped at kMaxTicks.
/// numbers for, with no unlabelled entries to filter. The cap counts **minor**
/// ticks only: a range dense enough to exceed it loses its subdivisions, never
/// its labelled majors -- every major at or below `hi` is emitted, so the far
/// end of the axis keeps its numbers.
///
/// A range that straddles the origin gets a tick of **exactly** `0.0` there, labelled
/// `0`. The walk cannot land on the origin by itself -- it starts at
/// `ceil(min / stride) * stride`, which is not representable whenever the stride is
/// not a power of two -- so the tick that means zero arrives a few ULP off and would
/// otherwise be labelled from the far end of the SI table (`-139a` between `-500m` and
/// `500m`). Every tick is an integer multiple of the stride, so anything within a
/// millionth of one *is* the origin and nothing else; a range whose ticks are
/// legitimately that small keeps them.
///
/// Hostile input clears `out` and returns: a non-finite endpoint, `max <= min`
/// (a zero or negative span, which includes the collapsed range of a
/// single-sample series), a non-positive `px` or `min_px`, and a span whose step
/// overflows to infinity.
void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out,
bool include_minors = false);
bool include_minors = false, f64 origin = 0.0);
} // namespace plot
+35 -2
View File
@@ -9,6 +9,16 @@
namespace plot {
/// How a trace's geometry is gathered from its LOD pyramid. The level choice
/// is unchanged; this picks what a bucket contributes (see
/// `data::GatherMode`).
using TraceMode = data::GatherMode;
/// A marker shape for per-sample points. Drawn with the trace's own colour
/// through the shared line pipeline; `marker_every == 0` strides
/// automatically (one marker per pixel column at most).
enum class Marker { None, Cross, Circle, Square };
/// How a caller asks for a trace. The series pointer is borrowed and must outlive
/// the plot's use of it; `axis_x`/`axis_y` index the plot's x and y axis lists.
struct TraceDesc {
@@ -16,7 +26,8 @@ struct TraceDesc {
Color color = Color::hex(0x4FA3FF);
/// The stroke's width in **logical** pixels, like every stroke in `Style` (see
/// that struct's unit rule): the renderer converts it once, so a trace has the
/// same weight against the axis lines whatever the display's DPI.
/// same weight against the axis lines whatever the display's DPI. `0` draws
/// the markers only -- a scatter trace.
f32 width = 1.5f;
u32 axis_x = 0, axis_y = 0;
/// What the trace is called, for the legend. Empty means "do not list it", which
@@ -26,6 +37,20 @@ struct TraceDesc {
/// unchanged, exactly as `AxisDesc`'s trailing format does (and, in a build with
/// `-Wextra`, warning-clean).
std::string name{};
/// Downsample policy for the LOD buckets (Envelope by default).
TraceMode mode = TraceMode::Envelope;
/// Dash pattern in logical pixels: ink, then gap. `dash_off <= 0` (and the
/// default) is a solid line -- no geometry is added, the dash is a
/// fragment-shader discard.
f32 dash_on = 0.0f;
f32 dash_off = 0.0f;
/// Per-sample markers (see `Marker`). `marker_every == 0` strides
/// automatically -- at most about one marker per pixel column -- so a dense
/// trace never stages more markers than it has columns; a positive value
/// draws every n-th gathered sample literally.
Marker marker = Marker::None;
f32 marker_size_px = 3.0f;
u32 marker_every = 0;
};
/// A series bound to a colour, width and axis pair. The series pointer may be
@@ -35,13 +60,21 @@ struct Trace {
const data::Series* series = nullptr;
Color color = Color::hex(0x4FA3FF);
/// Logical pixels, as in `TraceDesc::width`: converted to device pixels by the
/// renderer's one `Style::device()` call per stroke.
/// renderer's one `Style::device()` call per stroke. `0` = scatter (markers
/// only, if any).
f32 width = 1.5f;
u32 axis_x = 0, axis_y = 0;
/// The legend's entry for this trace, empty when it has none. A name does not
/// make a trace drawable: the legend lists a trace only when it is visible, so a
/// null series is skipped there exactly as it is by every other consumer.
std::string name{};
/// Same fields as `TraceDesc`, applied by the renderer.
TraceMode mode = TraceMode::Envelope;
f32 dash_on = 0.0f;
f32 dash_off = 0.0f;
Marker marker = Marker::None;
f32 marker_size_px = 3.0f;
u32 marker_every = 0;
/// True when the trace has a series with at least one sample to draw.
bool visible() const noexcept { return series != nullptr && series->size() > 0; }
+15 -8
View File
@@ -35,10 +35,11 @@ struct LineInstance {
/// rect onto the two the same way round, so a cell is never mirrored. `(1, 0)`
/// is unrotated text, and `(0, -1)` is a run advancing up the screen, i.e. a
/// 90 degree counter-clockwise label such as a y-axis title.
/// - `(r, g, b, a)` is the run's tint, straight alpha. Per instance rather than
/// a uniform, so one draw can carry a readout whose values are coloured per
/// row (each row's own trace) while its labels stay the axis text colour.
///
/// Six floats, so the whole text pipeline still fits one instanced draw call per
/// frame with rotation free: a matrix per glyph would be three times the bytes
/// for the same pixels.
/// Ten floats, still one instanced draw call per frame.
struct TextInstance {
f32 x;
f32 y;
@@ -46,10 +47,14 @@ struct TextInstance {
f32 v;
f32 ax;
f32 ay;
f32 r;
f32 g;
f32 b;
f32 a;
};
static_assert(sizeof(LineInstance) == 4 * sizeof(f32), "line instance must stay 4 floats");
static_assert(sizeof(TextInstance) == 6 * sizeof(f32), "text instance must stay 6 floats");
static_assert(sizeof(TextInstance) == 10 * sizeof(f32), "text instance must stay 10 floats");
/// How a store's staged instances reach the GPU. Semantics are identical either
/// way -- only the transfer differs, and `Renderer::init()` picks one from
@@ -137,7 +142,9 @@ class InstanceStore {
/// buffer cannot leave the vertex array bound to a deleted one), and `slot_epoch_`
/// advances on every upload that lands in a new slot. Both counters only ever
/// increase, so their sum changes whenever either does, and never repeats.
u32 epoch() const noexcept { return buffer_.generation() + slot_epoch_; }
/// 64-bit: the sum of two counters that advance every upload of a streaming
/// frame loop must not be able to wrap into a stale value.
u64 epoch() const noexcept { return buffer_.generation() + slot_epoch_; }
void bind() const noexcept { buffer_.bind(); }
bool persistent() const noexcept { return mode_ == UploadMode::PersistentMapped; }
@@ -165,7 +172,7 @@ class InstanceStore {
u32 frame_uploads_ = 0;
/// Times an upload landed in a new slot. Kept apart from the buffer's own
/// identity (see `epoch()`) so neither has to know about the other.
u32 slot_epoch_ = 0;
u64 slot_epoch_ = 0;
u8* mapped_ = nullptr; ///< base of the mapped ring, null in `Orphan` mode
/// Guards every slot the frame just ended wrote. Recorded once, after its last
/// draw; waited on once, at the start of the next frame.
@@ -186,7 +193,7 @@ class LineBuffer {
}
bool mark_drawn() noexcept { return store_.mark_drawn(); }
usize slot_offset() const noexcept { return store_.slot_offset(); }
u32 epoch() const noexcept { return store_.epoch(); }
u64 epoch() const noexcept { return store_.epoch(); }
bool persistent() const noexcept { return store_.persistent(); }
void bind() const noexcept { store_.bind(); }
@@ -206,7 +213,7 @@ class TextBuffer {
}
bool mark_drawn() noexcept { return store_.mark_drawn(); }
usize slot_offset() const noexcept { return store_.slot_offset(); }
u32 epoch() const noexcept { return store_.epoch(); }
u64 epoch() const noexcept { return store_.epoch(); }
bool persistent() const noexcept { return store_.persistent(); }
void bind() const noexcept { store_.bind(); }
+10 -8
View File
@@ -2,6 +2,7 @@
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <plot/core/color.hpp>
#include <plot/core/types.hpp>
#include <plot/math/vec2.hpp>
#include <plot/render/text.hpp>
@@ -60,7 +61,7 @@ class LineBatch {
/// Like `LineBatch` it keeps its capacity across frames. Each staged glyph is
/// one instance: the glyph cell's top-left corner in pixels, the glyph rect's
/// normalised top-left in the atlas and the cell's rotation basis, interleaved as
/// `x,y,u,v,ax,ay` — the layout the text shader declares (see `TextInstance` in
/// `x,y,u,v,ax,ay,r,g,b,a` — the layout the text shader declares (see `TextInstance` in
/// buffer.hpp).
///
/// Every glyph a batch stages is read through one atlas rect of one size — its
@@ -92,8 +93,9 @@ class TextBatch {
/// rasterised them. `axis` is the direction the run advances in, in pixel
/// space; it rotates the cell (see `TextInstance`), and is normalised here, so
/// any non-zero direction works and a zero or non-finite one means "unrotated".
void add_glyph(Vec2 pen_px, const Glyph& glyph, f32 px = 1.0f,
Vec2 axis = {1.0f, 0.0f});
/// `color` tints this run's glyphs (per-vertex `a_colour`, see `TextInstance`).
void add_glyph(Vec2 pen_px, const Glyph& glyph, f32 px = 1.0f, Vec2 axis = {1.0f, 0.0f},
Color color = {1.0f, 1.0f, 1.0f, 1.0f});
/// Stages every codepoint of `utf8` from `pen_px` and returns the pen past the
/// last glyph, so the run is exactly `font.measure(utf8) * px` long measured
@@ -104,14 +106,14 @@ class TextBatch {
/// `next_codepoint()` and `glyph()`, so a run stages exactly what `measure()`
/// summed. A codepoint the backend has no glyph for stages nothing and moves
/// the pen nowhere — a run is never wider than the glyphs it can draw. A null
/// or empty string stages nothing and returns `pen_px`.
/// or empty string stages nothing and returns `pen_px`. `color` tints the run.
Vec2 add_text(const char* utf8, Vec2 pen_px, f32 px, const TextBackend& font,
Vec2 axis = {1.0f, 0.0f});
Vec2 axis = {1.0f, 0.0f}, Color color = {1.0f, 1.0f, 1.0f, 1.0f});
/// Glyphs staged so far, one instance each.
u32 glyph_count() const noexcept { return static_cast<u32>(v_.size() / 6u); }
u32 glyph_count() const noexcept { return static_cast<u32>(v_.size() / 10u); }
/// Interleaved `x,y,u,v,ax,ay`, six floats per staged glyph.
/// Interleaved `x,y,u,v,ax,ay,r,g,b,a`, ten floats per staged glyph.
const std::vector<f32>& data() const noexcept { return v_; }
/// The cell the staged glyphs are drawn in: what `set_cell()` declared, or --
@@ -124,7 +126,7 @@ class TextBatch {
private:
/// Stages one cell whose basis the caller has already normalised, so a run and
/// the pen it advances share one rounding of the axis.
void add_cell(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 unit_ux);
void add_cell(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 unit_ux, Color color);
std::vector<f32> v_;
Vec2 cell_uv_{};
+13 -2
View File
@@ -35,8 +35,19 @@ class LineProgram {
/// discard on the interpolated `v_along`, so it costs no geometry, and it is the one uniform
/// that distinguishes the crosshair's stroke from every other line in the library.
void set_dash(Vec2 on_off) const noexcept;
/// `u_aa`: the AA fringe half-width in pixels. The style's `line_aa_px` in
/// device pixels, clamped at 0.25; 1.0 reproduces the historical fringe.
void set_aa(f32 px) const noexcept;
/// `u_color`: stroke colour, alpha included.
void set_color(Color color) const noexcept;
/// `u_snap`: with it on, an **axis-aligned** segment's ink is rounded onto whole
/// pixels along the axis it is constant on, so a hairline lights one column (or
/// row) fully instead of two at half coverage. It is for the chrome that frames a
/// plot -- grid lines, axis lines, panel and legend borders. Anything that reports
/// a data position -- a trace, a cursor, a region edge, the crosshair -- leaves it
/// off, because the rounding moves a line by up to half a pixel. A diagonal segment
/// is untouched whatever this says.
void set_snap(bool on) const noexcept;
private:
gl::Program program_;
@@ -66,8 +77,8 @@ class TextProgram {
void set_glyph_uv(Vec2 uv) const noexcept;
/// `u_tex_size`: atlas size in texels.
void set_texture_size(Vec2 texels) const noexcept;
/// `u_color`: tint colour, alpha included.
void set_color(Color color) const noexcept;
/// The tint is per instance (`a_colour`), not a uniform, so a draw can carry
/// runs of several colours -- there is deliberately no colour setter here.
private:
gl::Program program_;
+89 -18
View File
@@ -31,6 +31,7 @@ struct FrameStats {
u32 lod_traces = 0; ///< traces drawn from a min/max pyramid level
u64 visible_samples = 0; ///< samples the gathered windows covered, summed
u32 staged_segments = 0; ///< line segments staged, all colour groups
u32 staged_markers = 0; ///< marker shapes staged over the traces
u32 draw_calls = 0; ///< draw calls issued, line and text
};
@@ -70,10 +71,26 @@ struct FrameStats {
class Renderer {
public:
Renderer() noexcept = default;
~Renderer() = default;
/// Releases every GL handle first (`release_gl()`), so the destructor is safe
/// whenever the context that owns them is still current -- the normal case for
/// a member renderer destroyed by its host's widget teardown.
~Renderer();
Renderer(const Renderer&) = delete;
Renderer& operator=(const Renderer&) = delete;
/// Destroys every GL object this renderer created: both programs, both vertex
/// arrays and their instance buffers, and the glyph atlas texture. The
/// renderer is left uninitialised (`ready() == false`) and `init()` can be
/// called again on the same object.
///
/// **Teardown contract:** call this while the context that owns the names is
/// still current, before the host destroys that context -- a GL name deleted
/// after its context is gone is leaked, not deleted. The destructor calls this
/// first for the common case of a member renderer whose context outlives it;
/// a host that tears the context down before its renderer must call
/// `release_gl()` explicitly.
void release_gl() noexcept;
/// Compiles both programs, uploads the font's atlas and allocates the vertex
/// buffers. The calling thread must have the context current and
/// `plot::gl::load()` must have succeeded, otherwise the returned `Status` is
@@ -215,7 +232,7 @@ class Renderer {
///
/// The GL state it leaves behind, so a host can embed the plot without guessing:
/// vertex array 0, program 0, `GL_ARRAY_BUFFER` 0, texture unit 0 active and
/// nothing bound to `GL_TEXTURE_2D` on it, blending off, scissoring off, and the
/// nothing bound to `GL_TEXTURE_2D` on it, scissoring off, and the
/// viewport left at the frame's rect (the caller sets its own for the next pass).
///
/// The scissor is the other piece of state a frame moves, and it ends the frame *off*:
@@ -346,12 +363,19 @@ class Renderer {
/// One axis pair's data -> pixel mapping, already shifted so the viewport's
/// top-left corner is the origin (the shaders emit NDC relative to the GL
/// viewport, not the framebuffer).
///
/// `origin_x` is the data-space reference the caller staged its geometry
/// relative to: every data-space x reaches this layer as the f32
/// `to_space(x) - to_space(origin_x)` (B1), so the mapping folds the origin
/// back into the offset uniform -- `offset_x = sx*to_space(origin_x) + tx -
/// viewport.x`, computed in f64 and narrowed once -- and the staged values
/// stay O(visible pixels) however large the absolute x is (an epoch axis).
struct Mapping {
Vec2 scale{1.0f, 1.0f};
Vec2 offset{0.0f, 0.0f};
};
Mapping mapping(const Plot& plot, u32 axis_x, u32 axis_y) const noexcept;
Mapping mapping(const Plot& plot, u32 axis_x, u32 axis_y, f64 origin_x) const noexcept;
/// The identity mapping for geometry already staged in viewport pixels.
Mapping pixel_mapping() const noexcept;
/// Shifts one framebuffer-space pixel position into viewport space.
@@ -370,12 +394,20 @@ class Renderer {
///
/// `dash` is the pattern in pixels for the fragment shader's discard: `x` the ink
/// length, `y` the gap, and a non-positive `y` -- the default every caller but the
/// crosshair passes -- means **solid**. It is set on every flush, so one draw's dash
/// can never leak into the next.
void flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash = Vec2{}) noexcept;
/// Draws every staged glyph in `style().axis_text` and empties the batch. Text
/// is texture-bound, so this runs once per frame, after all line geometry.
void flush_text() noexcept;
/// crosshair passes and a dashed trace -- means **solid**. It is set on every flush,
/// so one draw's dash can never leak into the next. The AA fringe (`u_aa`) is
/// likewise set on every flush, from `style().line_aa_px` in device pixels.
///
/// `snap` is `LineProgram::set_snap()`'s pixel-grid rounding: on for the chrome
/// that frames a plot -- the grid, the axis lines, a panel or legend border --
/// and off for everything drawn at a data position, since it moves an
/// axis-aligned line by up to half a pixel. Set on every flush, like `dash`.
void flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash = Vec2{},
bool snap = false) noexcept;
/// Draws every staged glyph in `batch` -- each instance carries its own tint
/// (`a_colour`) -- and empties the batch. Text is texture-bound, so this runs
/// once per text batch, after the line geometry it belongs over.
void flush_text(render::TextBatch& batch) noexcept;
/// Narrows the frame's clip to `rect` -- framebuffer pixels, top-left origin, like every
/// `Rect` here -- and *intersects* it with the frame's viewport, so the clip can only
@@ -408,13 +440,21 @@ class Renderer {
/// measurement band fills, is the wrong primitive here). A rect with no width or
/// no height stages nothing.
void fill_pixels(const Rect& rect, Color color);
/// Stages one data-space segment, taking the two axes' log flags into account.
void stage_segment(f64 x0, f64 y0, f64 x1, f64 y1, bool log_x, bool log_y);
/// Stages `n` interleaved `x,y` data-space points (a gathered window).
void stage_xy(const f32* xy, u32 n, bool log_x, bool log_y);
/// Lays one run of text out at a framebuffer position and stages it, shifting
/// it into viewport space. Returns the pen past the run.
Vec2 stage_text(Vec2 pos_px, const char* utf8);
/// Stages one data-space segment as f32 values **relative to `origin_x`** in
/// the axis' pre-affine space (`log10` of it on a log axis): the mapping folds
/// `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.
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 `Series::gather`): x
/// **relative to `origin_x`**, which the caller asked the gather for, y absolute. On linear
/// 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).
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
/// `batch`, shifting it into viewport space and tinting it `color` (the
/// per-instance `a_colour`). Returns the pen past the run.
Vec2 stage_text(render::TextBatch& batch, Vec2 pos_px, const char* utf8, Color color);
/// Width of the longest prefix of `utf8` that fits `budget_px` along `axis`, at
/// the size the text pipeline draws with: exactly what `stage_text_clipped()`
/// stages.
@@ -430,7 +470,8 @@ class Renderer {
/// horizontal x title, which spends the strip's width, and (0,-1) for the y
/// title, which reads bottom-to-top and spends the strip's height. `pos_px` is
/// the pen -- for the rotated run, the bottom-left corner of its first cell.
f32 stage_text_clipped(Vec2 pos_px, const char* utf8, f32 budget_px, Vec2 axis);
f32 stage_text_clipped(render::TextBatch& batch, Vec2 pos_px, const char* utf8, f32 budget_px,
Vec2 axis, Color color);
/// Pins the text pipeline for a frame: the plot layer's backend at the size the
/// style asks for, the cell every staged glyph is drawn in, and the atlas the
@@ -494,6 +535,12 @@ class Renderer {
/// them again, so the panel a test asserts and the panel this draws are the same
/// numbers.
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
/// `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
@@ -531,6 +578,12 @@ class Renderer {
/// pointer is on: a readout the legend covered would be the feature failing at its one job.
void draw_crosshair_readout(const Plot& plot, const PlotLayout& layout);
/// One marker shape (`Marker`) at a viewport-pixel centre: `Cross` two
/// segments, `Circle` an 8-gon, `Square` four. Staged into the line batch in
/// pixel space, at `size_px` half-extent, so a marker's size is device pixels
/// whatever the data scale is.
void stage_marker(plot::Marker marker, Vec2 centre_px, f32 size_px);
/// The built-in backend the renderer owns, and the face the frames draw with:
/// `set_text_backend()` writes `font_`, which starts here and comes back here
/// when a host passes null. Every use of the face goes through the
@@ -549,8 +602,16 @@ class Renderer {
render::TextVertexArray text_vao_;
render::LineBatch lines_;
render::TextBatch glyphs_;
/// The frame's two text batches (B6): `glyphs_under_` collects the text the
/// crosshair readout's panel may legitimately occlude (legend names, cursor
/// labels, measure labels, annotation captions) and is flushed immediately
/// before `draw_crosshair_readout()`; `glyphs_over_` collects what must sit
/// over everything (readout text, tick labels, titles) and is flushed at
/// `end_frame()`. One batch per z-order, one draw call each.
render::TextBatch glyphs_under_;
render::TextBatch glyphs_over_;
std::vector<f32> gather_; ///< scratch for one gathered window, re-used
std::vector<f32> staged_; ///< scratch for one staged window, re-used
mutable std::vector<u8> readback_; ///< dump_frame_ppm's row buffer
/// The host's `GL_COLOR_CLEAR_VALUE`, queried on the first `begin_frame()` (GL
@@ -559,6 +620,16 @@ class Renderer {
Color host_clear_{};
bool host_clear_saved_ = false;
/// The host's blend state, saved by `begin_frame()` and handed back by
/// `end_frame()` (R6): a frame sets blending for its straight-alpha pipelines
/// and must not leave its function -- or the enable bit -- behind for the
/// host's own draws to inherit.
gl::GLint blend_src_rgb_ = gl::GL_ONE;
gl::GLint blend_src_a_ = gl::GL_ONE;
gl::GLint blend_dst_rgb_ = gl::GL_ZERO;
gl::GLint blend_dst_a_ = gl::GL_ZERO;
bool blend_was_on_ = false;
Rect viewport_; ///< the frame's region, top-left origin, framebuffer px
Rect gl_viewport_; ///< the same rect flipped for GL, and for readback
u32 fb_w_ = 0;
+20 -6
View File
@@ -16,7 +16,9 @@ namespace plot::render {
/// `v_along`, the distance travelled from `a_p0` in pixels.
///
/// Uniforms: `u_scale` (vec2, data space to pixels), `u_offset` (vec2, pixels),
/// `u_viewport` (vec2, pixels), `u_width` (float, pixels), `u_color` (vec4).
/// `u_viewport` (vec2, pixels), `u_width` (float, pixels), `u_aa` (float, the AA
/// fringe half-width in pixels -- the style's `line_aa_px` in device pixels,
/// clamped at 0.25), `u_color` (vec4).
/// `plot::Rect` is top-left origin while GL is bottom-left, so the y axis is
/// flipped exactly once, here.
///
@@ -42,15 +44,27 @@ extern const char* const kLineFS;
/// the cell's own +v direction -- so `a_axis` of `(0, -1)` draws a run that
/// advances up the screen, and it maps `a_uv` over the cell with the same
/// handedness, so a glyph's top row stays its top row however the cell is
/// turned. The fragment shader converts back to texels and samples the
/// single-channel `GL_R8` atlas at the nearest texel centre, so `GL_NEAREST`
/// never lands on a cell boundary. `u_color` tints the straight alpha.
/// turned. It also passes the cell's origin through `flat`, which is the bound
/// the fragment shader filters against.
///
/// The fragment shader converts back to texels and blends the four texels the
/// fragment sits between, **clamped to the instance's own cell**. Filtering is
/// the shader's, not the sampler's: every fetch is an explicit texel centre, so
/// `GL_NEAREST` stays correct, and the clamp keeps a footprint reaching past a
/// cell edge from blending in the neighbouring glyph of what is a tight,
/// unpadded strip of cells. A point sample would have been enough only at an
/// integer scale: at the library's own default of a 12 px style on the 13 px
/// embedded bake it *deleted* one of every 8 columns and one of every 17 rows of
/// every glyph. At scale 1 with a whole pen the two taps coincide and the result
/// is exactly as crisp as a point sample. The instance's own `a_colour` tints the
/// straight alpha -- per vertex, so one draw can carry runs of several colours.
///
/// Uniforms: `u_viewport` (vec2, pixels), `u_px_range` (vec2, the glyph cell's
/// size on screen in pixels), `u_glyph_uv` (vec2, the glyph cell's size in
/// normalised atlas coordinates -- one size for the whole draw, since every
/// glyph of a frame comes from one atlas), `u_tex_size` (vec2, atlas size in
/// texels), `u_atlas` (sampler2D, unit 0), `u_color` (vec4).
/// glyph of a frame comes from one atlas; read in *both* stages, the fragment
/// one needing it for the clamp), `u_tex_size` (vec2, atlas size in texels),
/// `u_atlas` (sampler2D, unit 0).
extern const char* const kTextVS;
extern const char* const kTextFS;
+56 -3
View File
@@ -38,8 +38,19 @@ struct Style {
// --- colours: neither logical nor device, so nothing to scale ------------
Color background = Color::hex(0x101418);
Color grid_major = Color::hex(0x2A3038);
Color grid_minor = Color::hex(0x1C2126);
/// The labelled grid. Brighter than the subdivisions and still well under `axis_line`
/// (0x8A94A6): the grid is structure the eye can read, the axis is the frame, and the
/// hierarchy between the three is what keeps a dense plot legible. It is already fully
/// opaque, so "more prominent" means a brighter colour and nothing else.
Color grid_major = Color::hex(0x39414C);
/// The minor grid's colour, **with an alpha** so it blends into the background rather than
/// sitting on it: a subdivision is a reading aid, and at full opacity a dense minor grid
/// reads as a second set of ticks competing with the labelled one. The line shader applies
/// `u_color.a` (see `src/render/shaders.cpp`), so this one blends and the major -- opaque at
/// 0x39414C -- stays the structure the eye picks out. Together with `grid_minor_width` and
/// the dash below, the defaults put a minor line at an order of magnitude less ink than a
/// major one.
Color grid_minor = Color::hex(0x1C2126, 0.5f);
Color axis_line = Color::hex(0x8A94A6);
Color axis_text = Color::hex(0xC8D0DC);
Color cursor = Color::hex(0xFFB020);
@@ -75,6 +86,21 @@ struct Style {
f32 crosshair_width = 0.75f;
f32 crosshair_dash_on = 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 ----------------------------------------------------------
@@ -96,12 +122,39 @@ struct Style {
/// skips it before the first segment is staged, so a dense plot's minor grid
/// costs nothing rather than being drawn transparent (spec §11). The colours
/// above are already per class.
/// When a trace's window holds at most this many raw samples, each one is also drawn as a dot
/// `marker_px` logical pixels across, in the trace's own colour. `0` draws none. Zoomed in far
/// enough to see individual samples -- a handful of them across a whole panel -- the polyline
/// *between* them stops being the shape, and the samples themselves are what a reader is looking
/// at; a marker makes each one a place rather than a bend in a line.
f32 marker_below_samples = 50.0f;
f32 marker_px = 3.0f;
f32 grid_major_width = 1.0f; ///< logical width of a major (labelled-tick) line.
f32 grid_minor_width = 1.0f; ///< logical width of a minor (subdivision) line.
/// Logical width of a minor (subdivision) line: **thinner than the major hairline**, and
/// sub-pixel on purpose. At 1x that is expressed as *coverage* -- the anti-aliased ramp
/// (`line_aa_px`) lights one column at roughly three quarters of the ink -- and on a HiDPI
/// display as a line that is literally narrower. The point is hierarchy: the labelled grid is
/// structure, the subdivisions are texture.
f32 grid_minor_width = 0.75f;
bool grid_major_visible = true; ///< stage the major grid at all.
bool grid_minor_visible = true; ///< stage the minor grid at all.
/// The minor grid's dash, in logical pixels: ink length and gap. **`off == 0` draws it
/// solid** -- the same convention as the crosshair's and a trace's dash -- which is what a
/// caller that wants the old solid subdivision states. Dashed is the default because an
/// unlabelled line at an unlabelled position should never be mistaken for a labelled one, and
/// because a broken line carries less weight over data than a continuous one. The dash is a
/// fragment discard, never extra geometry, so a dense minor grid costs no more vertices for
/// being dashed.
f32 grid_minor_dash_on = 3.0f;
f32 grid_minor_dash_off = 3.0f;
f32 line_width_px = 1.5f; ///< logical width of the strokes framing the data
///< (axis lines, cursor markers).
/// Logical half-width of the anti-aliased fringe the line shader blends at
/// every stroke edge (the smoothstep ramp runs over `|dist| in [w - aa,
/// w + aa]`). Clamped to at least 0.25 device px at use: below that the
/// ramp is thinner than a fragment and edges alias again. 1.0 is the value
/// every default was chosen on.
f32 line_aa_px = 1.0f;
/// Logical width of a *draggable handle* drawn highlighted: the tab a hovered or
/// grabbed cursor marker is widened to, and the tab a region's hot bound is drawn
/// as -- the latter sized by `Plot::layout()` with this same metric, so the two
+6 -5
View File
@@ -54,20 +54,21 @@ class LineVertexArray {
gl::VertexArray vao_;
u32 instances_ = 0;
/// Instance-buffer generation the attribute pointers currently point into.
u32 epoch_ = 0;
u64 epoch_ = 0;
};
/// Vertex array plus instance buffer of the text pipeline: `a_pos` at location 0
/// (the cell's top-left corner in pixels), the glyph's atlas rect `a_uv` at
/// location 1 and its rotation basis `a_axis` at location 2, two floats each,
/// every one advancing once per instance. The layout is the one `TextInstance`
/// location 1, its rotation basis `a_axis` at location 2 and the run's tint
/// `a_colour` at location 3 (`vec2`, `vec2`, `vec2`, `vec4` floats), each
/// advancing once per instance. The layout is the one `TextInstance`
/// declares and shaders.hpp consumes; a caller never writes a location, stride or
/// offset, it stages a `TextBatch` and uploads its `data()`.
class TextVertexArray {
public:
bool create(Status* st = nullptr, UploadMode mode = UploadMode::Orphan) noexcept;
/// Uploads `glyphs` instances from interleaved `x,y,u,v,ax,ay` floats.
/// Uploads `glyphs` instances from interleaved `x,y,u,v,ax,ay,r,g,b,a` floats.
void upload(const f32* interleaved, u32 glyphs) noexcept;
void bind() const noexcept { vao_.bind(); }
@@ -84,7 +85,7 @@ class TextVertexArray {
TextBuffer buffer_;
gl::VertexArray vao_;
u32 instances_ = 0;
u32 epoch_ = 0;
u64 epoch_ = 0;
};
} // namespace plot::render
+28
View File
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
// Template for <plot/version.hpp>: configure_file() expands it into
// <build>/include/plot/version.hpp at configure time, and that generated file
// -- not this one -- is a public header. The umbrella names it, and the
// install rules place it under include/plot/ with the rest of the surface, so
// a consumer can ask what it compiled against: the macros for a preprocessor
// test, the plot::version constants for everything else. Both come from the
// project() version, so there is exactly one place a release edits.
#pragma once
#define PLOT_VERSION_MAJOR @plot_VERSION_MAJOR@
#define PLOT_VERSION_MINOR @plot_VERSION_MINOR@
#define PLOT_VERSION_PATCH @plot_VERSION_PATCH@
#define PLOT_VERSION_STRING "@plot_VERSION@"
namespace plot::version {
/// The library version: one component per constant, and the whole thing as the
/// string the project() declares (e.g. "0.1.0").
inline constexpr int major = PLOT_VERSION_MAJOR;
inline constexpr int minor = PLOT_VERSION_MINOR;
inline constexpr int patch = PLOT_VERSION_PATCH;
inline constexpr const char* string = PLOT_VERSION_STRING;
} // namespace plot::version
+26
View File
@@ -0,0 +1,26 @@
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: 2026 Martino Ferrari
#
# pkg-config metadata for the plot core library: configure_file() turns this
# into <build>/plot.pc and the install rules place it in
# ${CMAKE_INSTALL_LIBDIR}/pkgconfig/. It serves consumers whose build is not
# CMake -- find_package(plot) and the package config under lib/cmake/plot/
# remain the primary interface. The prefix is anchored to the file's own
# location (`pkg-config --define-prefix` and a plain PKG_CONFIG_PATH both
# resolve it), so an install relocated after configuration -- including
# `cmake --install --prefix` -- still resolves its own headers and library.
# The ${...} references below are pkg-config's own, evaluated against this
# file's prefix; configure_file runs with @ONLY so they reach the installed
# file untouched.
prefix=${pcfiledir}/../..
exec_prefix=${prefix}
libdir=${prefix}/@PLOT_PC_LIBDIR@
includedir=${prefix}/@PLOT_PC_INCLUDEDIR@
Name: @PROJECT_NAME@
Description: @PROJECT_DESCRIPTION@
Version: @PROJECT_VERSION@
Libs: -L${libdir} @PLOT_PC_LIB@
Libs.private: @PLOT_PC_LIBS_PRIVATE@
Cflags: -I${includedir}
+30
View File
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
// The README's embed block, committed as a file: the whole of a minimal GLFW
// program (the adapter opens the window, runs the loop and feeds the
// controller, and the callback is where live data would be pushed). The
// compile command it is verified with is in README.md, next to the same code.
#include <glfw/glfw_host.hpp> // the optional GLFW adapter
#include <plot/plot.hpp>
#include <cmath>
#include <vector>
int main() {
plot::glfw::Host host;
plot::Plot& p = host.plot();
std::vector<plot::f32> y(512);
for (plot::usize i = 0; i < y.size(); ++i)
y[i] = static_cast<plot::f32>(std::sin(0.05 * static_cast<double>(i)));
plot::data::ArraySeries series(std::move(y), 0.0, 1.0);
const plot::u32 x = p.add_x_axis({"x", {0.0, 511.0}, true});
const plot::u32 sig = p.add_y_axis({"signal", {-1.0, 1.0}, true, plot::AxisPlace::Left});
p.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, x, sig, "sine"});
plot::Status st;
return host.run({800, 480, "plot"}, [](plot::Plot&, plot::f64) {}, &st) ? 0 : 1;
}
+33 -9
View File
@@ -4,6 +4,7 @@
#include <plot/data/array_series.hpp>
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <utility>
@@ -23,23 +24,45 @@ void ArraySeries::commit() {
pyramid_.clear();
pyramid_.reserve(n);
// Bounds seed on the first *finite* sample and fold the rest: a non-finite
// sample is excluded from every aggregate, and a window with none aggregates
// to a zero-span Range (the library-wide NaN policy).
f32 lo = 0.0f, hi = 0.0f;
bool first = true;
bool finite = false;
for (const f32 v : y_) {
pyramid_.append(v);
if (first) {
lo = v;
hi = v;
first = false;
} else {
if (!std::isfinite(v)) continue;
if (finite) {
lo = std::min(lo, v);
hi = std::max(hi, v);
} else {
lo = v;
hi = v;
finite = true;
}
}
y_bounds_ = first ? Range{0.0, 0.0} : Range{f64(lo), f64(hi)};
y_has_finite_ = finite;
y_bounds_ = finite ? Range{f64(lo), f64(hi)} : Range{0.0, 0.0};
x_bounds_ = n == 0 ? Range{x0_, x0_} : Range{x0_, x0_ + f64(n - 1) * dx_};
}
void ArraySeries::push(f32 v) {
y_.push_back(v);
pyramid_.append(v);
// The incremental twin of commit()'s fold: skip non-finite, seed on the first
// finite sample, extend after that.
if (std::isfinite(v)) {
if (y_has_finite_) {
y_bounds_.min = std::min(y_bounds_.min, f64(v));
y_bounds_.max = std::max(y_bounds_.max, f64(v));
} else {
y_bounds_ = Range{f64(v), f64(v)};
y_has_finite_ = true;
}
}
x_bounds_ = Range{x0_, x0_ + f64(y_.size() - 1) * dx_};
}
u64 ArraySeries::size() const { return static_cast<u64>(y_.size()); }
f64 ArraySeries::x0() const { return x0_; }
@@ -52,8 +75,9 @@ Range ArraySeries::y_bounds() const { return y_bounds_; }
const MinMaxPyramid& ArraySeries::pyramid() const { return pyramid_; }
void ArraySeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const {
gather_impl(level, begin, end, out_xy);
void ArraySeries::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode) const {
gather_impl(level, begin, end, x_origin, out_xy, mode);
}
void ArraySeries::copy_raw(u64 begin, u64 end, f32* out) const {
+39 -14
View File
@@ -12,8 +12,12 @@
namespace plot::data {
namespace {
/// Samples covered by one level-l bucket.
constexpr u64 level_span(u32 l) { return u64{kBucket} << l; }
/// Samples covered by one level-l bucket. Clamped to kMaxPyramidLevel: the
/// shift past level 57 would overflow u64 (UB), and a bucket wider than any
/// u64 sample count is the same bucket as the widest one anyway.
constexpr u64 level_span(u32 l) {
return u64{kBucket} << (l > kMaxPyramidLevel ? kMaxPyramidLevel : l);
}
/// Level-l buckets needed to hold n samples (ceiling division).
constexpr u64 buckets_for(u64 n, u32 l) {
@@ -72,11 +76,13 @@ void cover_buckets(const MinMaxPyramid& p, u64 b0, u64 b1, Fn&& fn) {
}
/// Folds the decomposition of [b0, b1) into a min/max Range. Returns a
/// zero-span Range when there is nothing to fold.
/// zero-span Range when there is nothing to fold. Buckets with no finite
/// sample (count 0) are absent, not zero-valued, and are skipped.
Range fold_range(const MinMaxPyramid& p, u64 b0, u64 b1) {
Range r{};
bool first = true;
cover_buckets(p, b0, b1, [&](u32 l, u64 i) {
if (p.bucket_count(l, i) == 0) return;
const MinMax m = p.bucket(l, i);
if (first) {
r.min = f64(m.min);
@@ -96,10 +102,13 @@ void MinMaxPyramid::add_level() { levels_.emplace_back(); }
void MinMaxPyramid::reserve(u64 n) {
// Materialise every level n samples can need -- level 0 plus one per doubling
// of the bucket -- so a run of appends never grows the level vector. See the
// header for why the bucket deques themselves cannot be pre-sized.
// of the bucket, capped at kMaxPyramidLevel -- so a run of appends never grows
// the level vector. See the header for why the bucket deques themselves cannot
// be pre-sized.
u32 want = 1;
for (u64 span = level_span(0); span <= n && want < 63; span <<= 1) ++want;
for (u64 span = level_span(0); span <= n && want <= kMaxPyramidLevel; span <<= 1) {
++want;
}
if (levels_.size() < want) levels_.resize(want);
}
@@ -115,20 +124,25 @@ void MinMaxPyramid::push(u32 l, u64 k, const Entry& e) {
}
void MinMaxPyramid::append(f32 v) {
const f64 vd = f64(v);
// The new sample always lands in the in-progress level-0 bucket, which is the
// only bucket any append ever touches: a bucket becomes an entry exactly once,
// complete, and is never rewritten.
if (count_ % kBucket == 0) {
if (count_ % kBucket == 0) pending_ = Entry{};
// Non-finite samples occupy their absolute position -- bucket boundaries are
// positional, so NaN or not the sample advances count_ and completes its
// bucket -- but they are excluded from every aggregate (see the header).
if (std::isfinite(v)) {
const f64 vd = f64(v);
if (pending_.count == 0) {
pending_.mm.min = v;
pending_.mm.max = v;
pending_.sum = vd;
pending_.sumsq = vd * vd;
} else {
pending_.mm.min = std::min(pending_.mm.min, v);
pending_.mm.max = std::max(pending_.mm.max, v);
}
pending_.sum += vd;
pending_.sumsq += vd * vd;
++pending_.count;
}
++count_;
if (count_ % kBucket != 0) return; // the bucket is still partial: nothing to push
@@ -141,7 +155,8 @@ void MinMaxPyramid::append(f32 v) {
// Cascade: level l gains an entry exactly when kBucket << l divides the sample
// count, so the walk stops at the first level that gained nothing -- amortised
// O(1) per sample (1/kBucket + 1/(2*kBucket) + ... = 1/(kBucket/2) merges).
for (u32 l = 1; l < 63; ++l) {
// Capped at kMaxPyramidLevel: past it the span shift would overflow.
for (u32 l = 1; l <= kMaxPyramidLevel; ++l) {
const u64 span = level_span(l);
if (count_ % span != 0) break;
const u64 k = count_ / span - 1; // the level-l bucket that just completed
@@ -152,10 +167,14 @@ void MinMaxPyramid::append(f32 v) {
const Entry& a = at(l - 1, child);
const Entry& b = at(l - 1, child + 1);
Entry m;
m.mm.min = std::min(a.mm.min, b.mm.min);
m.mm.max = std::max(a.mm.max, b.mm.max);
// An empty child (count 0) has zero sums and a meaningless mm: it must not
// vote 0 into the merge, so a merge with it is just the other child.
m.mm = a.count == 0 ? b.mm : (b.count == 0 ? a.mm
: MinMax{std::min(a.mm.min, b.mm.min),
std::max(a.mm.max, b.mm.max)});
m.sum = a.sum + b.sum;
m.sumsq = a.sumsq + b.sumsq;
m.count = a.count + b.count;
push(l, k, m);
}
}
@@ -225,6 +244,12 @@ u64 MinMaxPyramid::level_size(u32 l) const noexcept {
MinMax MinMaxPyramid::bucket(u32 l, u64 i) const { return at(l, i).mm; }
f64 MinMaxPyramid::bucket_sum(u32 l, u64 i) const { return at(l, i).sum; }
f64 MinMaxPyramid::bucket_sumsq(u32 l, u64 i) const { return at(l, i).sumsq; }
u32 MinMaxPyramid::bucket_count(u32 l, u64 i) const { return at(l, i).count; }
Range MinMaxPyramid::reduce_buckets(u64 begin, u64 end) const {
const u64 e = std::min(end, size());
if (begin >= e) return Range{0.0, 0.0};
@@ -265,7 +290,7 @@ u32 MinMaxPyramid::level_for(u64 n, f64 samples_per_pixel) const {
const f64 ratio = samples_per_pixel / f64(kBucket);
if (n == 0 || !(ratio > 1.0)) return 0;
const f64 lg = std::floor(std::log2(ratio));
u32 l = lg >= f64(62) ? 62u : static_cast<u32>(lg);
u32 l = lg >= f64(kMaxPyramidLevel) ? kMaxPyramidLevel : static_cast<u32>(lg);
while (l > 0) {
const bool exists = l < level_count() && level_size(l) >= 2;
if (exists && buckets_for(n, l) >= 2) break;
+137 -27
View File
@@ -4,6 +4,7 @@
#include <plot/data/series.hpp>
#include <algorithm>
#include <cmath>
#include <cstddef>
namespace plot::data {
@@ -13,6 +14,9 @@ namespace {
/// gather path never allocates, and large enough to amortise the call.
constexpr u64 kGatherChunk = 256;
/// Ceiling division: first multiple of `d` at or after `v`, in `d` units.
constexpr u64 ceil_div(u64 v, u64 d) { return v / d + (v % d == 0 ? u64{0} : u64{1}); }
} // namespace
Series::QueryParts Series::split_query(u64 begin, u64 end) const {
@@ -75,6 +79,7 @@ Range Series::reduce(u64 begin, u64 end) const {
const u64 n = copy_edges(p, edge);
for (u64 i = 0; i < n; ++i) {
const f64 v = f64(edge[i]);
if (!std::isfinite(v)) continue; // non-finite samples are excluded (B7)
if (first) {
r.min = v;
r.max = v;
@@ -84,6 +89,8 @@ Range Series::reduce(u64 begin, u64 end) const {
r.max = std::max(r.max, v);
}
}
// No finite sample at all (an all-NaN window): a zero-span Range, per the
// library-wide NaN policy.
return first ? Range{0.0, 0.0} : r;
}
@@ -98,7 +105,10 @@ f64 Series::sum(u64 begin, u64 end) const {
}
f32 edge[2 * kBucket];
const u64 n = copy_edges(p, edge);
for (u64 i = 0; i < n; ++i) total += f64(edge[i]);
for (u64 i = 0; i < n; ++i) {
const f64 v = f64(edge[i]);
if (std::isfinite(v)) total += v; // non-finite samples are excluded (B7)
}
return total;
}
@@ -115,77 +125,177 @@ f64 Series::sumsq(u64 begin, u64 end) const {
const u64 n = copy_edges(p, edge);
for (u64 i = 0; i < n; ++i) {
const f64 v = f64(edge[i]);
total += v * v;
if (std::isfinite(v)) total += v * v; // non-finite samples are excluded (B7)
}
return total;
}
void Series::gather_raw(u64 begin, u64 end, std::vector<f32>& out_xy) const {
const f64 x_0 = x0();
f64 Series::x_at(u64 i) const { return x0() + f64(i) * dx(); }
bool Series::neighbours(f64 x, u64& i0, u64& i1) const {
const u64 n = size();
if (n == 0) return false;
const f64 lo = x_at(0);
const f64 hi = x_at(n - 1);
if (!(x >= lo && x <= hi)) return false;
const f64 step = dx();
if (!(step > 0.0)) {
i0 = 0;
i1 = 0;
return true;
}
const f64 t = (x - lo) / step;
const u64 k = x >= hi ? n - 1 : static_cast<u64>(t); // floor, in range
// An exact hit collapses to one index, whatever the division above rounded
// to: compare against the samples themselves, not against t's fraction.
if (x_at(k) == x) {
i0 = k;
i1 = k;
return true;
}
if (k + 1 < n && x_at(k + 1) == x) {
i0 = k + 1;
i1 = k + 1;
return true;
}
i0 = k;
i1 = k + 1 < n ? k + 1 : k;
return true;
}
u64 Series::snap_index(f64 x) const {
const u64 n = size();
if (n == 0) return 0;
const f64 step = dx();
if (!(step > 0.0)) return 0;
const f64 k = std::round((x - x0()) / step);
// The first test also rejects NaN (every comparison with it is false), which
// would otherwise reach the u64 cast undefined.
if (!(k > 0.0)) return 0;
if (k >= f64(n)) return n - 1;
return static_cast<u64>(k);
}
void Series::gather_raw(u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy) const {
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];
for (u64 i = begin; i < end;) {
const u64 take = std::min<u64>(kGatherChunk, end - i);
copy_raw(i, i + take, raw);
for (u64 k = 0; k < take; ++k) {
out_xy.push_back(f32(x_0 + f64(i + k) * step));
out_xy.push_back(f32(shift + f64(i + k) * step)); // x relative to x_origin: see gather()
out_xy.push_back(raw[k]);
}
i += take;
}
}
void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const {
void Series::gather_impl(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode) const {
const u64 n = size();
const u64 b = std::min(begin, n);
const u64 e = std::min(std::max(end, b), n);
if (b >= e) return;
if (level == 0) {
gather_raw(b, e, out_xy);
gather_raw(b, e, x_origin, out_xy);
return;
}
// Public levels clamp to the deepest representable one (R3): past
// kMaxPyramidLevel the bucket-span shift would overflow, and the widest
// bucket there is is already wider than any window.
level = std::min(level, kMaxPyramidLevel);
// Level >= 1: one envelope pair per *retained* bucket the range overlaps, x at
// the bucket's first sample (min) and at its last sample (max), which keeps x
// monotonic while covering the bucket's span. The pyramid's buckets are
// absolute, so map the window range onto its grid and clamp to the buckets that
// are both complete and wholly inside the window: a bucket that is only partly
// retained aggregates samples the window no longer has, and a partial trailing
// bucket is not stored at all.
// Level >= 1: one geometry element per *retained* bucket the range overlaps.
// The pyramid's buckets are absolute, so map the window range onto its grid
// and clamp to the buckets that are both complete and wholly inside the
// requested range: `first` is the first bucket whose span starts at or after
// `begin` (ceiling, so the bucket cannot reach left of the range) and `last`
// is one past the last bucket whose span ends at or before `end` (floor, so
// it cannot reach right of it). This is the B3 fix: a floor on `first` (or a
// bucket containing `end - 1`) emitted aggregates that covered samples
// outside [begin, end), so the envelope was wrong for any unaligned range.
// Buckets that are only partly *retained* are also excluded by construction:
// stored entries are complete aggregates of their own span, and a partial
// trailing bucket is never stored.
//
// Those two excluded buckets are still part of the range, and one of them holds
// the newest samples -- for a live trace, the part being watched. They are drawn
// from the raw samples instead: at most one bucket at each end, about a pixel
// each at the level level_for() picks, so the envelope path stays O(buckets)
// and the geometry still spans the requested range exactly.
const f64 x_0 = x0();
// The two excluded spans at the edges -- [b, lead) and [trail, e) -- are part
// of the range, and one of them holds the newest samples -- for a live trace,
// the part being watched. They are drawn from the raw samples instead: at
// most one bucket at each end, about a pixel each at the level level_for()
// picks, so the envelope path stays O(buckets) and the geometry still spans
// the requested range exactly, for every mode.
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 origin = pyramid().window_base();
const u64 lbase = pyramid().level_base(level);
const u64 lend = lbase + pyramid().level_size(level);
const u64 first = std::max((origin + b) / span, lbase);
const u64 last = std::min((origin + e - 1) / span + 1, lend);
const u64 first = std::max(ceil_div(origin + b, span), lbase);
const u64 last = std::min((origin + e) / span, lend);
if (first >= last) {
// 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.
gather_raw(b, e, out_xy);
gather_raw(b, e, x_origin, out_xy);
return;
}
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) {
case GatherMode::Envelope: {
// One (min, max) pair per bucket, x at the bucket's first sample (min)
// and at its last sample (max), which keeps x monotonic while covering
// the bucket's span. A bucket with no finite sample is absent, not
// zero-valued, and emits nothing.
for (u64 bi = first; bi < last; ++bi) {
if (pyramid().bucket_count(level, bi) == 0) continue;
const MinMax m = pyramid().bucket(level, bi);
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;
out_xy.push_back(f32(x_0 + f64(first_sample) * step));
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(f32(x_0 + f64(last_sample) * step));
out_xy.push_back(f32(shift + f64(last_sample) * step));
out_xy.push_back(m.max);
}
break;
}
case GatherMode::Nearest: {
// One raw sample per bucket, picked the way pyramid().snap() would pick
// it: the sample nearest the bucket's centre, which on the fixed grid is
// the centre sample itself. x is at the sample; the value is raw, so a
// bucket whose centre sample is non-finite contributes that NaN, exactly
// as level 0 would have drawn it.
for (u64 bi = first; bi < last; ++bi) {
const u64 centre = bi * span + span / 2 - origin; // inside [b, e): the bucket is wholly inside
f32 v = 0.0f;
copy_raw(centre, centre + 1, &v);
out_xy.push_back(f32(shift + f64(centre) * step)); // x relative to x_origin
out_xy.push_back(v);
}
break;
}
case GatherMode::Mean: {
// One point per bucket, y = the bucket's finite samples' mean (the exact
// sum the bucket stores, divided by its finite count) and x at the
// bucket's centre. A bucket with no finite sample has no mean and emits
// nothing.
for (u64 bi = first; bi < last; ++bi) {
const u32 count = pyramid().bucket_count(level, bi);
if (count == 0) continue;
const u64 centre = bi * span + span / 2 - origin;
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)));
}
break;
}
}
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
+17 -4
View File
@@ -18,8 +18,11 @@ constexpr u64 kReadChunk = 256;
StreamSeries::StreamSeries(u64 capacity, f64 x0, f64 dx)
: ring_(capacity), x0_(x0), dx_(dx) {
// Size the pyramid's level table for a full window up front: the ring never
// holds more than capacity() samples, so appending never grows it.
pyramid_.reserve(capacity);
// holds more than capacity() samples, so appending never grows it. The ring
// rounded the request up to a power of two, and the window it will hold is
// the *rounded* capacity -- reserving the raw argument would under-size a
// non-power-of-two request exactly when the stream saturates (R4).
pyramid_.reserve(ring_.capacity());
}
void StreamSeries::push(f32 y) { ring_.push(y); }
@@ -60,6 +63,15 @@ void StreamSeries::acquire() {
y_bounds_ = reduce(0, size());
}
ArraySeries StreamSeries::freeze() const {
// The window of the last acquire(), copied out once: the frozen series owns
// its samples, so nothing the producer does afterwards can move it.
const u64 n = size();
std::vector<f32> y(static_cast<usize>(n));
if (n > 0) copy_raw(0, n, y.data());
return ArraySeries(std::move(y), x0(), dx());
}
u64 StreamSeries::size() const { return pyramid_.size(); }
f64 StreamSeries::x0() const { return x0_ + f64(pyramid_.window_base()) * dx_; }
@@ -75,8 +87,9 @@ Range StreamSeries::y_bounds() const { return y_bounds_; }
const MinMaxPyramid& StreamSeries::pyramid() const { return pyramid_; }
void StreamSeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const {
gather_impl(level, begin, end, out_xy);
void StreamSeries::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode) const {
gather_impl(level, begin, end, x_origin, out_xy, mode);
}
void StreamSeries::copy_raw(u64 begin, u64 end, f32* out) const {
+201
View File
@@ -0,0 +1,201 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/data/xy_series.hpp>
#include <algorithm>
#include <cassert>
#include <cmath>
#include <cstddef>
#include <utility>
namespace plot::data {
namespace {
/// Ceiling division: the first level-`l` bucket whose whole span starts at or
/// after `v` samples (in bucket units).
constexpr u64 ceil_div(u64 v, u64 d) { return v / d + (v % d == 0 ? u64{0} : u64{1}); }
} // namespace
XYSeries::XYSeries(std::vector<f64> x, std::vector<f32> y) { reset(std::move(x), std::move(y)); }
void XYSeries::reset(std::vector<f64> x, std::vector<f32> y) {
// The binary searches below (and every O(log n) consumer of them) are only
// correct on sorted input; see the header for the precondition.
assert(x.size() == y.size());
assert(std::is_sorted(x.begin(), x.end()) && "XYSeries requires x sorted ascending");
x_ = std::move(x);
y_ = std::move(y);
commit();
}
void XYSeries::commit() {
const u64 n = static_cast<u64>(y_.size());
pyramid_.clear();
pyramid_.reserve(n);
// Same NaN-aware fold as ArraySeries::commit(): non-finite y is excluded
// from the cached bounds, and a window with no finite sample aggregates to a
// zero-span Range. (The pyramid's append() applies the same policy per
// bucket.) The x bounds come straight from the sorted array's ends.
f32 lo = 0.0f, hi = 0.0f;
bool finite = false;
for (const f32 v : y_) {
pyramid_.append(v);
if (!std::isfinite(v)) continue;
if (finite) {
lo = std::min(lo, v);
hi = std::max(hi, v);
} else {
lo = v;
hi = v;
finite = true;
}
}
y_has_finite_ = finite;
y_bounds_ = finite ? Range{f64(lo), f64(hi)} : Range{0.0, 0.0};
x_bounds_ = n == 0 ? Range{0.0, 0.0} : Range{x_.front(), x_.back()};
}
u64 XYSeries::size() const { return static_cast<u64>(y_.size()); }
f64 XYSeries::x0() const { return x_.empty() ? 0.0 : x_.front(); }
f64 XYSeries::dx() const { return 0.0; }
Range XYSeries::x_bounds() const { return x_bounds_; }
Range XYSeries::y_bounds() const { return y_bounds_; }
const MinMaxPyramid& XYSeries::pyramid() const { return pyramid_; }
f64 XYSeries::x_at(u64 i) const { return x_[static_cast<usize>(i)]; }
bool XYSeries::neighbours(f64 x, u64& i0, u64& i1) const {
const u64 n = size();
if (n == 0) return false;
// Also rejects NaN: every comparison with it is false.
if (!(x >= x_.front() && x <= x_.back())) return false;
// Last sample at or left of x, first at or right of it. Both searches are in
// range: x >= front puts a lower_bound hit at index >= 1's predecessor at 0,
// and x <= back caps upper_bound's predecessor at n - 1.
const auto up = std::upper_bound(x_.begin(), x_.end(), x);
const auto lo = std::lower_bound(x_.begin(), x_.end(), x);
i0 = static_cast<u64>(up - x_.begin()) - 1;
i1 = static_cast<u64>(lo - x_.begin());
return true;
}
u64 XYSeries::snap_index(f64 x) const {
const u64 n = size();
if (n == 0) return 0;
if (!(x > x_.front())) return 0; // at or left of the first sample; NaN too
if (x >= x_.back()) return n - 1;
const auto it = std::lower_bound(x_.begin(), x_.end(), x);
const u64 hi = static_cast<u64>(it - x_.begin());
const u64 lo = hi - 1; // hi >= 1: x is strictly past x_.front()
return (x - x_[static_cast<usize>(lo)]) <= (x_[static_cast<usize>(hi)] - x)
? lo
: hi; // ties go to the lower index
}
void XYSeries::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode) const {
const u64 n = size();
const u64 b = std::min(begin, n);
const u64 e = std::min(std::max(end, b), n);
if (b >= e) return;
// 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.
const f64 origin = x_origin;
if (level == 0) {
for (u64 i = b; i < e; ++i) {
out_xy.push_back(f32(x_[static_cast<usize>(i)] - origin));
out_xy.push_back(y_[static_cast<usize>(i)]);
}
return;
}
level = std::min(level, kMaxPyramidLevel);
// The bucket grid is the sample index, exactly as for a uniform series, and
// an XYSeries never slides its window, so the pyramid's base is 0 and the
// window range needs no origin shift. Bucket selection is the B3-correct
// ceil/floor: `first` is the first bucket whose samples all sit at or after
// `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
// geometry comes from outside [begin, end), and the emitted x values are the
// array's own coordinates relative to the caller's origin.
const u64 span = u64{kBucket} << level;
const u64 lend = pyramid().level_size(level);
const u64 first = std::min(ceil_div(b, span), lend);
const u64 last = std::min(e / span, lend);
if (first >= last) {
for (u64 i = b; i < e; ++i) {
out_xy.push_back(f32(x_[static_cast<usize>(i)] - origin));
out_xy.push_back(y_[static_cast<usize>(i)]);
}
return;
}
const u64 lead = first * span;
for (u64 i = b; i < lead; ++i) {
out_xy.push_back(f32(x_[static_cast<usize>(i)] - origin));
out_xy.push_back(y_[static_cast<usize>(i)]);
}
switch (mode) {
case GatherMode::Envelope: {
for (u64 bi = first; bi < last; ++bi) {
if (pyramid().bucket_count(level, bi) == 0) continue;
const MinMax m = pyramid().bucket(level, bi);
const u64 lo = bi * span; // the bucket's first sample
const u64 hi = std::min((bi + 1) * span, e) - 1; // its last sample inside the range
out_xy.push_back(f32(x_[static_cast<usize>(lo)] - origin));
out_xy.push_back(m.min);
out_xy.push_back(f32(x_[static_cast<usize>(hi)] - origin));
out_xy.push_back(m.max);
}
break;
}
case GatherMode::Nearest: {
for (u64 bi = first; bi < last; ++bi) {
const u64 centre = bi * span + span / 2; // inside the bucket, hence inside [b, e)
out_xy.push_back(f32(x_[static_cast<usize>(centre)] - origin));
out_xy.push_back(y_[static_cast<usize>(centre)]);
}
break;
}
case GatherMode::Mean: {
for (u64 bi = first; bi < last; ++bi) {
const u32 count = pyramid().bucket_count(level, bi);
if (count == 0) continue;
const u64 centre = bi * span + span / 2;
out_xy.push_back(f32(x_[static_cast<usize>(centre)] - origin));
out_xy.push_back(f32(pyramid().bucket_sum(level, bi) / f64(count)));
}
break;
}
}
const u64 trail = last * span;
for (u64 i = trail; i < e; ++i) {
out_xy.push_back(f32(x_[static_cast<usize>(i)] - origin));
out_xy.push_back(y_[static_cast<usize>(i)]);
}
}
void XYSeries::copy_raw(u64 begin, u64 end, f32* out) const {
const u64 want = end > begin ? end - begin : 0;
if (want == 0) return;
const u64 n = size();
const u64 b = std::min(begin, n);
const u64 e = std::min(std::max(end, b), n);
std::copy(y_.begin() + static_cast<std::ptrdiff_t>(b),
y_.begin() + static_cast<std::ptrdiff_t>(e), out);
std::fill(out + static_cast<std::ptrdiff_t>(e - b),
out + static_cast<std::ptrdiff_t>(want), 0.0f);
}
} // namespace plot::data
+6
View File
@@ -317,9 +317,15 @@ bool Texture::create_r8(u32 width, u32 height, Status* st) noexcept {
void Texture::upload_rgba(u32 width, u32 height, const void* pixels) const noexcept {
glBindTexture(GL_TEXTURE_2D, id_);
// RGBA rows are 4-byte aligned whatever the width, but the *caller's* upload
// state is not ours to move: save what the host had and put it back, the same
// contract `upload_r8`'s callers honour for the single-channel path.
GLint host_alignment = 4;
glGetIntegerv(GL_UNPACK_ALIGNMENT, &host_alignment);
glPixelStorei(GL_UNPACK_ALIGNMENT, 4);
glTexImage2D(GL_TEXTURE_2D, 0, static_cast<GLint>(GL_RGBA8), static_cast<GLsizei>(width),
static_cast<GLsizei>(height), 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels);
glPixelStorei(GL_UNPACK_ALIGNMENT, host_alignment);
}
void Texture::upload_r8(u32 width, u32 height, const void* pixels) const noexcept {
+285 -17
View File
@@ -39,6 +39,21 @@ constexpr f32 kCursorHitRadiusLogical = 7.0f;
/// A box-zoom selection narrower than this on either side is a click. Logical for
/// the same reason: the gesture is the same hand movement on any display.
constexpr f32 kMinBoxLogical = 4.0f;
/// Share of an axis' span one horizontal wheel notch (`dx`) pans, in axis space:
/// a notch is a small, deliberate step, a twentieth of the view.
constexpr f64 kPanPerNotch = 0.05;
/// Share of an axis' span one arrow-key press pans, in axis space -- twice the
/// wheel's step, because a key press is a coarser statement than a detent. A held
/// key repeats (the adapters' shared policy), so a hold pans continuously.
constexpr f64 kPanPerPress = 0.10;
/// The double-click window: two presses of the same button within this many
/// seconds -- and within `kDoubleClickPxLogical` of each other -- are a double-click
/// reset, not two gestures. Measured on `PointerEvent::time_s`, the host's clock.
constexpr f64 kDoubleClickSeconds = 0.35;
/// How close together the two presses of a double-click must land, in **logical**
/// pixels: the same hand-distance rule as the other tolerances here, converted
/// through `Style::device()` at the plot's scale.
constexpr f32 kDoubleClickPxLogical = 4.0f;
/// The narrowest *interior* a region's body keeps, in **logical** pixels: the edge bands are
/// cut back until this much of the middle is left, so a region too narrow for the full grab
/// radius on each side is still something a pointer can take hold of and *move*. Without it
@@ -136,7 +151,13 @@ void Controller::on_pointer(const PointerEvent& raw) {
plot_->layout(); // keep the chrome geometry the gestures map through current
if (e.pressed && e.down) {
if (double_click(e)) {
// The second press of a double-click re-fits every axis and begins no drag:
// a reset is a click-shaped gesture, not the start of one.
fit_all();
} else {
begin_drag(e);
}
} else if (e.released) {
end_drag(e);
} else if (e.down && mode_ != DragMode::None) {
@@ -158,13 +179,30 @@ void Controller::on_wheel(const WheelEvent& raw) {
// like a zero-notch zoom -- a factor of exactly one -- and the zoom body would
// still run, calling the range setters with the unchanged range. Those clear
// `auto_scale` unconditionally, so a single garbage wheel would silently disarm
// auto-fit on every axis. Return before the event can mutate anything.
if (!std::isfinite(raw.dy)) return;
// auto-fit on every axis. `dx` is refused by the same rule: it pans when `dy` is
// zero, and a garbage pan is no more welcome than a garbage zoom. Return before
// the event can mutate anything.
if (!std::isfinite(raw.dy) || !std::isfinite(raw.dx)) return;
const WheelEvent e = sanitized(raw, has_pointer_, pointer_x_, pointer_y_);
plot_->layout();
// A zero-notch wheel -- `dy == 0` and `dx == 0`, which a trackpad or a driver
// happily forwards -- must change nothing at all. Letting it run would call the
// range setters with every range unchanged, and those clear `auto_scale`: one
// empty scroll would silently disarm auto-fit on every axis. There is nothing to
// apply, so there is nothing to lay out or republish either.
if (e.dy == 0.0f && e.dx == 0.0f) return;
if (e.dy != 0.0f) {
const f64 factor = zoom_factor(static_cast<f64>(e.dy));
if (!(factor > 0.0)) return;
// The zoom is refused, and the whole event with it, when its factor could not
// be applied honestly: a factor of exactly one changes no range but would
// still clear `auto_scale` on the way through the setters, a non-finite one
// (a huge downward `dy` explodes `exp`) would hand a non-finite span to the
// setters, and a non-positive one is not a zoom at all. "Leaves the plot
// exactly as it was" is the rule for an unusable amount.
if (!std::isfinite(factor) || !(factor > 0.0) || factor == 1.0) return;
const bool shift = has_mod(e.mods, Mod::Shift);
const bool ctrl = has_mod(e.mods, Mod::Ctrl);
@@ -183,6 +221,17 @@ void Controller::on_wheel(const WheelEvent& raw) {
zoom_y_axis(i, plot_->y_transform(i).to_data_y(static_cast<f64>(e.y)), factor);
}
}
}
// A horizontal wheel pans x only: `dx` notches, 5% of the x span each, in axis
// space (so a log axis scrolls multiplicatively, exactly as its transform maps).
// A positive `dx` scrolls right -- the view moves toward larger x, the same
// reading `WheelEvent`'s comment gives the field. On a live axis the range
// setter reads the shifted window as a span and an offset behind the edge, so
// scrolling right runs to the live edge and stops there, exactly as a drag pan
// does.
if (e.dx != 0.0f) shift_x_axes(static_cast<f64>(e.dx) * kPanPerNotch);
// The ranges moved under a pointer that did not, so the geometry under it changed: refresh
// the layout -- the region rects the hit test reads were measured for the range this wheel
// has just replaced -- and republish what is hot. A frame drawn right after a scroll then
@@ -197,29 +246,106 @@ void Controller::on_wheel(const WheelEvent& raw) {
publish_hot();
}
void Controller::on_gesture(const GestureEvent& raw) {
// The same refusal rule as the wheel: a frame carrying something that is not
// a number is not a gesture, and a frame that could not change anything --
// a unit scale and no movement -- must not reach the range setters, which
// clear `auto_scale` for no visible change. `scale <= 0` is not a zoom.
if (!std::isfinite(raw.x) || !std::isfinite(raw.y) || !std::isfinite(raw.scale) ||
!std::isfinite(raw.dx) || !std::isfinite(raw.dy)) {
return;
}
if (raw.scale <= 0.0f) return;
if (raw.scale == 1.0f && raw.dx == 0.0f && raw.dy == 0.0f) return;
plot_->layout();
const f64 factor = 1.0 / static_cast<f64>(raw.scale); // pinch out = zoom in
if (std::isfinite(factor) && factor > 0.0 && factor != 1.0) {
// A pinch is a two-handed instrument, so it has no modifier semantics: it
// zooms both axes about the focal point, exactly as an unmodified wheel
// notch pair would -- same setters, same live-axis behaviour.
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
zoom_x_axis(i, x_zoom_anchor(i, raw.x, raw.y), factor);
}
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
zoom_y_axis(i, plot_->y_transform(i).to_data_y(static_cast<f64>(raw.y)), factor);
}
}
// The focal point's movement is a two-finger pan: the same path a one-finger
// drag takes, deltas included, so a pinch-and-drag moves the data under the
// fingers on every axis the drag would move.
if (raw.dx != 0.0f || raw.dy != 0.0f) {
PointerEvent pan_e;
pan_e.x = raw.x;
pan_e.y = raw.y;
pan_e.dx = raw.dx;
pan_e.dy = raw.dy;
pan(pan_e);
}
plot_->layout();
if (has_pointer_) update_crosshair(pointer_x_, pointer_y_);
publish_hot();
}
void Controller::on_key(const KeyEvent& e) {
if (!e.down) return; // every action below is press-only
// Escape cancels the active drag (see cancel_drag). A key with no drag open --
// and any other key -- changes nothing and returns below without touching the
// layout, the crosshair or the published handles.
if (e.key == kKeyEscape) {
if (mode_ != DragMode::None) cancel_drag();
return;
}
// The arrow keys pan by axis class: Left/Right every x axis, Up/Down every y
// axis, 10% of that axis' own span per press (and per repeat, which is why the
// adapters forward key repeat). An arrow says where to *look*: Left walks the
// window toward smaller x, Up toward larger y -- the screen-y inversion a drag's
// dy carries does not apply, because a drag says where the data goes and a key
// says where the viewer goes.
switch (e.key) {
case kKeyLeft: shift_x_axes(-kPanPerPress); break;
case kKeyRight: shift_x_axes(kPanPerPress); break;
case kKeyUp: shift_y_axes(kPanPerPress); break;
case kKeyDown: shift_y_axes(-kPanPerPress); break;
default:
break;
}
const bool pans = e.key >= kKeyLeft && e.key <= kKeyDown;
const bool zooms = e.key == kKeyZoomIn || e.key == kKeyZoomOut;
const bool fits = e.key == kKeyFit || e.key == kKeyFitUpper;
if (!zooms && !fits) return; // a key this controller has no gesture for
if (!pans && !zooms && !fits) return; // a key this controller has no gesture for
if (zooms) {
const f64 notches = e.key == kKeyZoomIn ? kKeyZoomNotches : -kKeyZoomNotches;
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) {
const Axis& a = plot_->x_axis(i);
const f64 lo = to_space(a.range.min, 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);
}
}
if (!only_x) {
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
const Axis& a = plot_->y_axis(i);
const f64 lo = to_space(a.range.min, 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);
}
} else {
}
} else if (fits) {
plot_->auto_fit_x();
for (u32 i = 0; i < plot_->y_axis_count(); ++i) plot_->auto_fit_y(i);
}
@@ -363,14 +489,34 @@ void Controller::begin_drag(const PointerEvent& e) {
drag_cursor_ = kInvalid;
drag_region_ = kInvalid;
drag_zone_ = RegionGrab::None;
// What Escape will restore if this gesture is cancelled: the axes' state *now*,
// before the drag moves anything (see capture_ranges).
capture_ranges();
// Shift first, and only on the left: it is a modifier gesture of its own, and a
// selection box is what it means even when it starts over a handle.
if (e.button == Button::Left && has_mod(e.mods, Mod::Shift)) {
// The modifier box zoom, on the left only: a selection box is what a modified
// left press means even when it starts over a handle. The mask is resolved once,
// here, from the modifier table -- Shift is the plain both-axis selection it has
// always been, Ctrl frames the x range only, Alt the y range only, and any other
// combo falls back to both -- and `apply_box_zoom()` reads it on release, per
// axis and for the click threshold alike.
if (e.button == Button::Left) {
const bool shift = has_mod(e.mods, Mod::Shift);
const bool ctrl = has_mod(e.mods, Mod::Ctrl);
const bool alt = has_mod(e.mods, Mod::Alt);
if (shift || ctrl || alt) {
mode_ = DragMode::BoxZoom;
// The masks: Ctrl alone frames the x range only, Alt alone the y range only,
// and every other combo (Shift included) frames both -- the field says whether
// the release *applies* that axis, so the axis named by the mask is the one
// that stays true.
const bool ctrl_only = ctrl && !shift && !alt;
const bool alt_only = alt && !shift && !ctrl;
box_zoom_x_ = !alt_only;
box_zoom_y_ = !ctrl_only;
selection_ = Rect{e.x, e.y, 0.0f, 0.0f};
return;
}
}
if (e.button == Button::Middle) {
const PlotLayout& lay = std::as_const(*plot_).layout();
if (lay.x_label_area.contains(e.x, e.y)) {
@@ -472,10 +618,113 @@ void Controller::end_drag(const PointerEvent& e) {
drag_cursor_ = kInvalid;
drag_region_ = kInvalid;
drag_zone_ = RegionGrab::None;
// The capture is the cancelled gesture's undo record, and this gesture was not
// cancelled: keeping it would let a later Escape restore a state that ended long
// ago (and `capture_ranges()` only runs at a press).
saved_x_.clear();
saved_y_.clear();
}
// --- gestures -------------------------------------------------------------
void Controller::capture_ranges() {
saved_x_.clear();
saved_y_.clear();
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
const Axis& a = plot_->x_axis(i);
saved_x_.push_back(SavedX{a.range, a.offset, a.auto_scale});
}
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
const Axis& a = plot_->y_axis(i);
saved_y_.push_back(SavedY{a.range, a.auto_scale});
}
}
void Controller::cancel_drag() {
// The ranges the axes held at `begin_drag`, through the plot's own setters: an
// `Off` axis gets its range and its auto-scale flag back exactly, and a live one
// gets its span and offset restated the way the setters read them (the offset is
// written back verbatim after the range, since the live edge of *this* moment --
// not of the press -- is what the setter would otherwise derive it from). A
// cancelled gesture is as if it never ran: nothing is applied, no auto-scale is
// disarmed that was not already, and no box is framed.
for (u32 i = 0; i < plot_->x_axis_count() && i < saved_x_.size(); ++i) {
const SavedX& saved = saved_x_[i];
plot_->set_x_range(i, saved.range);
plot_->set_x_offset(i, saved.offset);
plot_->set_x_auto_scale(i, saved.auto_scale);
}
for (u32 i = 0; i < plot_->y_axis_count() && i < saved_y_.size(); ++i) {
const SavedY& saved = saved_y_[i];
plot_->set_y_range(i, saved.range);
plot_->set_y_auto_scale(i, saved.auto_scale);
}
// The gesture itself: no mode, no selection, nothing grabbed -- and the highlight
// the renderer draws follows, through the same tail every event that changes the
// geometry under the pointer runs.
mode_ = DragMode::None;
selection_ = Rect{};
drag_cursor_ = kInvalid;
drag_region_ = kInvalid;
drag_zone_ = RegionGrab::None;
plot_->layout();
if (has_pointer_) update_crosshair(pointer_x_, pointer_y_);
publish_hot();
}
void Controller::fit_all() {
plot_->auto_fit_x();
for (u32 i = 0; i < plot_->y_axis_count(); ++i) plot_->auto_fit_y(i);
// A fit moved the geometry under a stationary pointer, exactly as a wheel notch
// does -- same tail, same reason (see on_wheel).
plot_->layout();
if (has_pointer_) update_crosshair(pointer_x_, pointer_y_);
publish_hot();
}
bool Controller::double_click(const PointerEvent& e) {
const f32 radius = plot_->style().device(kDoubleClickPxLogical);
const f32 dx = e.x - click_x_;
const f32 dy = e.y - click_y_;
// Both presses must carry a host timestamp for the window to mean anything: a
// host that leaves `time_s` at 0 never produces a double-click (the field is
// optional, and two presses "0 s apart" would otherwise fire on any host).
const bool in_time = e.time_s > 0.0 && click_time_s_ > 0.0 &&
e.time_s - click_time_s_ <= kDoubleClickSeconds;
const bool in_space = dx * dx + dy * dy <= radius * radius;
const bool is_double =
has_click_ && click_button_ == static_cast<int>(e.button) && in_time && in_space;
// This press is what a third one would be measured against -- unless the two
// just made a double-click, in which case the record clears: a third press starts
// a fresh gesture rather than re-firing the reset.
has_click_ = !is_double;
click_button_ = static_cast<int>(e.button);
click_time_s_ = e.time_s;
click_x_ = e.x;
click_y_ = e.y;
return is_double;
}
void Controller::shift_x_axes(f64 fraction) {
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
const Axis& a = plot_->x_axis(i);
const f64 lo = to_space(a.range.min, a.log);
const f64 hi = to_space(a.range.max, a.log);
const f64 d = fraction * (hi - lo); // positive scrolls toward larger x
plot_->set_x_range(i, Range{from_space(lo + d, a.log), from_space(hi + d, a.log)});
}
}
void Controller::shift_y_axes(f64 fraction) {
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
const Axis& a = plot_->y_axis(i);
const f64 lo = to_space(a.range.min, a.log);
const f64 hi = to_space(a.range.max, a.log);
const f64 d = fraction * (hi - lo); // positive scrolls toward larger y
plot_->set_y_range(i, Range{from_space(lo + d, a.log), from_space(hi + d, a.log)});
}
}
void Controller::pan(const PointerEvent& e) {
// The transforms map a range onto layout().axes, so the pixel-to-data scale is
// the range span over the axes rect -- not over the whole viewport. Using the
@@ -510,12 +759,17 @@ void Controller::box_zoom_update(const PointerEvent& e) {
void Controller::apply_box_zoom() {
const Rect sel = selection_;
// A selection under a few *logical* pixels on either side is a click: no zoom.
// The selection is measured in device pixels like everything the controller is
// handed, so the threshold is converted at the plot's own scale -- through the one
// place that conversion lives, so a change to its fallback is picked up here too.
// A selection under a few *logical* pixels on the axes the mask will zoom is a
// click: no zoom. The threshold is masked with the gesture -- a Ctrl drag frames
// the x range from a selection however short it is vertically, and vice versa --
// which is what makes a thin horizontal band a legal x-only box rather than a
// click no matter what the y says. The selection is measured in device pixels
// like everything the controller is handed, so the threshold is converted at the
// plot's own scale -- through the one place that conversion lives, so a change to
// its fallback is picked up here too.
const f32 min_box = plot_->style().device(kMinBoxLogical);
if (sel.w < min_box || sel.h < min_box) return;
if (box_zoom_x_ && sel.w < min_box) return;
if (box_zoom_y_ && sel.h < min_box) return;
// Clamped to the axes rect the transforms are built from, so a drag that left
// the plot area cannot ask for data outside what the box covers on screen.
@@ -528,19 +782,26 @@ void Controller::apply_box_zoom() {
const f64 r = std::clamp(static_cast<f64>(sel.right()), ax, ar);
const f64 t = std::clamp(static_cast<f64>(sel.y), ay, ab);
const f64 b = std::clamp(static_cast<f64>(sel.bottom()), ay, ab);
if (!(r > l) || !(b > t)) return;
if (box_zoom_x_ && !(r > l)) return;
if (box_zoom_y_ && !(b > t)) return;
// The ranges come straight from the transforms, so the box frames exactly the
// data under it; the selection's bottom edge is the range minimum on y.
// data under it; the selection's bottom edge is the range minimum on y. Masked
// axes are left exactly as they were -- the release applies only what the press
// promised.
if (box_zoom_x_) {
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
const Transform tx = plot_->x_transform(i);
plot_->set_x_range(i, Range{tx.to_data_x(l), tx.to_data_x(r)});
}
}
if (box_zoom_y_) {
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
const Transform ty = plot_->y_transform(i);
plot_->set_y_range(i, Range{ty.to_data_y(b), ty.to_data_y(t)});
}
}
}
void Controller::move_cursor(const PointerEvent& e) {
if (drag_cursor_ == kInvalid) return;
@@ -621,7 +882,12 @@ void Controller::axis_zoom(const PointerEvent& e) {
if (!(scale > 0.0) || !std::isfinite(scale)) return;
const f64 logical_dy = static_cast<f64>(e.dy) / scale;
const f64 factor = std::exp(-logical_dy * kZoomPerPixel);
if (!(factor > 0.0)) return;
// The same degenerate factors the wheel refuses: a factor of exactly one (a
// motion that reports no dy -- running it would still clear `auto_scale` on its
// way through the range setters), a non-finite one (an enormous dy explodes
// `exp` into a span the setters cannot store), and a non-positive one, which is
// not a zoom at all.
if (!std::isfinite(factor) || !(factor > 0.0) || factor == 1.0) return;
if (mode_ == DragMode::AxisZoomX) {
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
zoom_x_axis(i, x_zoom_anchor(i, e.x, e.y), factor);
@@ -669,7 +935,9 @@ u64 Controller::cursor_at(f32 px, f32 py) const {
f32 best_d = 0.0f;
for (u32 i = 0; i < plot_->cursor_count(); ++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:
// 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
+17 -3
View File
@@ -3,18 +3,32 @@
#include <plot/plot/axis.hpp>
#include <cmath>
namespace plot {
void Axis::format_value(f64 v, char* buf, usize n) const {
// The one place a value on this axis becomes text, so the origin is applied here and
// everything that goes through it -- tick labels, and the crosshair's readout -- reads in
// the same frame of reference as the axis itself.
// The subtraction leaves a residue when the value and the origin are the same distant number
// (a tick exactly on the origin then formatted as `1.78e-12ms` instead of `0ms`). Snapping it
// is a *relative* test, so a genuinely small value on a large axis is not swallowed -- what
// this removes is arithmetic dust, not data.
f64 shown = v - origin;
if (std::fabs(shown) < 1.0e-12 * std::max(1.0, std::fabs(v))) shown = 0.0;
if (format == TickFormat::Time) {
format_time(v, buf, n);
format_time(shown, buf, n);
return;
}
format_si(v, buf, n);
format_si(shown, buf, n);
}
void Axis::refresh(f32 px, f32 min_px, bool include_minors) {
generate_ticks(range, px, min_px, ticks, include_minors);
// The range first, so a following origin is this frame's edge and not the last one's: the
// zero tick then sits exactly on the window start, which is the whole point of asking.
if (origin_follows_range) origin = range.min;
generate_ticks(range, px, min_px, ticks, include_minors, origin);
if (format != TickFormat::Time) return;
// generate_ticks labels with format_si; a time axis relabels its majors here,
// because the tick generator has no business knowing about a clock -- through the one
+67 -16
View File
@@ -4,6 +4,7 @@
#include <plot/plot/figure.hpp>
#include <algorithm>
#include <cstdio>
#include <cmath>
#include <cstddef>
@@ -22,6 +23,22 @@ namespace {
/// wants a taller floor gives the figure a taller viewport, or gives the rows weights.
constexpr f32 kMinRowHeightLogical = 24.0f;
/// A coordinate in the shared axis' **axis space**: `log10` when the figure's one
/// x axis -- the bottom row's, the row the shared strip belongs to -- is
/// logarithmic, the value itself otherwise. A following group's span and offset
/// live in this space exactly as a single `Live` axis' do (see `Axis::offset`), so
/// a log group scrolls decades and holds decades; a *linear* span there would
/// rescale every row's view as the live edge moved. The bottom row rules the
/// space because it owns the shared axis; a group whose members disagree about
/// `log` still gets one data-space range pushed into every member, computed in
/// that row's space.
f64 to_link_space(f64 v, bool log) noexcept {
return log ? Transform::log10_positive(v) : v;
}
f64 from_link_space(f64 v, bool log) noexcept {
return log ? std::pow(10.0, v) : v;
}
} // namespace
Figure::Figure(u32 rows) : plots_(rows) {}
@@ -151,9 +168,11 @@ Status Figure::set_x_link_range(u32 group, Range r) {
// Under a following group the argument is not a pair of positions: it states the
// span the window should have and, through its maximum, where the window sits
// behind the live edge -- the same reinterpretation `Plot::set_x_range()` makes
// on a `Live` axis, applied to the group's own edge. A span that is not a span is
// refused rather than stored, because a following group has nothing to place a
// window from otherwise.
// on a `Live` axis, applied to the group's own edge. Both the span and the
// offset are taken in the shared axis' **axis space** (log10 decades on a log
// group), which is what makes the window hold decades instead of a decaying
// linear width. A span that is not a span is refused rather than stored,
// because a following group has nothing to place a window from otherwise.
const f64 lo = std::min(r.min, r.max);
const f64 hi = std::max(r.min, r.max);
if (!(hi > lo)) {
@@ -161,8 +180,9 @@ Status Figure::set_x_link_range(u32 group, Range r) {
"a following group's range states the window's span, so its bounds "
"must differ"};
}
g.span = hi - lo;
g.offset = live.max - hi;
const bool log = !plots_.empty() && plots_.back().x_axis(0u).log;
g.span = to_link_space(hi, log) - to_link_space(lo, log);
g.offset = to_link_space(live.max, log) - to_link_space(hi, log);
g.has_range = true;
place_live_window(g);
push_link(g);
@@ -249,18 +269,36 @@ bool Figure::place_live_window(XLink& group) {
if (!live_edge(group, live)) return false;
// The span is the group's own -- what the user zoomed to, or what the last range
// it was given stated -- and the follow window only moves it: the span is what
// makes this a scroll rather than a rescale. A span that is not a span is nothing
// makes this a scroll rather than a rescale. Both it and the offset are in the
// shared axis' **axis space** (see `to_link_space`), so a log group holds its
// visible decades whatever the edge does. A span that is not a span is nothing
// to place a window from, so the group's range is left exactly as it is.
if (!std::isfinite(group.span) || !(group.span > 0.0)) return false;
if (!std::isfinite(group.offset)) group.offset = 0.0;
// The offset can outlive the window it names: a ring that dropped samples moves the
// oldest x forward under it, so it is clamped against what the members hold *now*.
// An offset past that would put the window's right edge behind the oldest sample
// left, i.e. over data that is gone.
group.offset = std::clamp(group.offset, 0.0, std::max(0.0, live.max - live.min));
// The offset can outlive the window it names: a ring that dropped samples moves
// the oldest x forward under it, so it is clamped against what the members hold
// *now* -- measured in the same space, or a log group could hold an offset larger
// than everything it retains.
//
// The bound is about the whole *window*, not just its right edge -- but only when the window
// *can* sit on the data. `retained >= span` is that case, and there the offset may not exceed
// `retained - span`: with the loose bound a scroll could put the window's right edge exactly on
// the oldest sample, which leaves the entire span to its left over data the scope never had --
// an empty margin as wide as the view, reached by a pan or by a zoom-in near that edge. When the
// window is *wider* than what the members retain (a ring still filling, or one just trimmed)
// nothing can avoid a margin, and the honest placement is the old one: the window's right edge
// on the newest sample, the shortfall on the left, which is what a scope that just started
// should look like.
const bool log = !plots_.empty() && plots_.back().x_axis(0u).log;
const f64 newest_space = to_link_space(live.max, log);
const f64 retained =
std::max(0.0, newest_space - to_link_space(live.min, log));
const f64 reach = retained >= group.span ? retained - group.span : retained;
group.offset = std::clamp(group.offset, 0.0, std::max(0.0, reach));
group.live_newest = live.max;
group.has_live = true;
group.range = Range{live.max - group.offset - group.span, live.max - group.offset};
const f64 right = newest_space - group.offset;
group.range = Range{from_link_space(right - group.span, log), from_link_space(right, log)};
return true;
}
@@ -300,15 +338,20 @@ void Figure::sync_x_links() {
// A group that was changed from outside restates its scroll state the way a
// single `Live` axis does -- the new range's span, and the offset its maximum
// implies -- and against the group's own edge, not the edge of the row the
// change arrived on. Then the window is recomputed from the live edge, which is
// change arrived on. Both are read in the shared axis' **axis space** (the
// same space `set_x_link_range()` and `place_live_window()` state them in),
// so a member's pan or zoom on a log group states decades like everything
// else on it. Then the window is recomputed from the live edge, which is
// the whole of what following is.
if (adopted) {
Range live{};
if (live_edge(group, live)) {
const f64 span = std::max(0.0, group.range.max - group.range.min);
const bool log = !plots_.empty() && plots_.back().x_axis(0u).log;
const f64 span = std::max(0.0, to_link_space(group.range.max, log) -
to_link_space(group.range.min, log));
if (span > 0.0) {
group.span = span;
group.offset = live.max - group.range.max;
group.offset = to_link_space(live.max, log) - to_link_space(group.range.max, log);
}
}
}
@@ -408,6 +451,10 @@ const FigureLayout& Figure::layout() {
// not drawn once per row. A figure told to label every row keeps them everywhere,
// and then there is no shared axis to speak of: each row has its own.
p.set_x_strip_mode(labels_bottom_only_ && row != bottom_row ? XStrip::None : XStrip::Both);
// Before either pass below, and the same value for every row: nothing is measured on
// the right, so unlike the left gutter this needs no measurement pass -- the rows end
// on a common column because they are all told the one width.
p.set_right_gutter(right_gutter_);
}
// 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
@@ -435,7 +482,11 @@ const FigureLayout& Figure::layout() {
// per row settles the answer exactly. Measuring with a floor from the previous frame
// would be the only reason to iterate, and clearing it before the measurement pass is
// what removes it.
f32 gutter = 0.0f;
//
// `set_min_y_gutter()` seeds the maximum rather than being imposed on a row before the
// measurement, so a reserved floor cannot widen what a row reports and then be widened
// by its own reservation on the next frame.
f32 gutter = min_y_gutter_;
for (u32 row = 0; row < rows; ++row) {
plots_[row].set_min_y_gutter(0.0f);
const PlotLayout& row_layout = plots_[row].layout();
+287 -91
View File
@@ -31,41 +31,69 @@ const Axis& default_axis() noexcept {
return axis;
}
/// An axis coordinate in the space the live follow arithmetic runs in: log10 when
/// the axis is logarithmic, the value itself otherwise -- the same space the
/// controller's `to_space`/`from_space` gestures run in, and the space `Axis::offset`
/// and a `Live` window's span are stated in. A log axis therefore *scrolls* decades
/// and holds decades under follow: a linear span on a log axis would rescale the
/// view on every frame (the defect this fixes).
f64 to_axis_space(f64 v, bool log) noexcept {
return log ? Transform::log10_positive(v) : v;
}
f64 from_axis_space(f64 v, bool log) noexcept {
return log ? std::pow(10.0, v) : v;
}
/// The sample range a data interval covers: `[begin, end)` over the samples whose
/// x lies inside `[lo, hi]`, both ends included -- exactly the samples a region's
/// statistics are over. The nearest-sample snap seeds the walk and the two
/// corrections after it move by the one or two samples a rounded index can be off
/// by, so which side of a bound a sample falls on is decided by the same doubles
/// the series carries rather than by a division that is exact only on a grid that
/// is. Still O(1): the seed is never more than half a sample from the answer.
/// statistics are over. The brackets come from `Series::neighbours()`, which names
/// the samples at or either side of a bound *by the series' own x coordinates*
/// (`x_at()`), so a series whose samples are not on a uniform grid -- an XY series
/// -- is covered exactly where its samples actually are, not where a `dx()` grid
/// would have put them. Still O(1): two neighbour lookups.
///
/// False when the interval holds no sample at all, which is a real case: an
/// interval between two adjacent samples on a sparse series, one entirely before or
/// after the window, a non-finite bound, and a series with no usable `dx()`.
/// after the window, and a non-finite bound.
bool covered_samples(const data::Series& series, f64 lo, f64 hi, u64& begin, u64& end) {
const u64 n = series.size();
const f64 x0 = series.x0();
const f64 dx = series.dx();
if (n == 0u || !(dx > 0.0) || !std::isfinite(lo) || !std::isfinite(hi) || !(lo <= hi)) {
if (n == 0u || !std::isfinite(lo) || !std::isfinite(hi) || !(lo <= hi)) return false;
// The first sample at or after `lo`: the right neighbour of the bound, unless the
// bound lands on a sample -- which is then itself the first one inside. A bound
// outside the window covers everything on its side: before the oldest sample the
// coverage starts at 0, after the newest there is nothing inside.
u64 i0 = 0;
u64 i1 = 0;
if (!series.neighbours(lo, i0, i1)) {
if (series.x_at(0u) > lo) {
i1 = 0u;
} else {
return false;
}
const auto x_of = [x0, dx](u64 i) { return x0 + static_cast<f64>(i) * dx; };
} else if (series.x_at(i0) >= lo) {
i1 = i0;
}
const u64 b = i1;
// The first sample at or after `lo`: walk left while the one before is also
// inside, then right while this one is not yet.
u64 b = series.pyramid().snap(lo, x0, dx);
while (b > 0u && x_of(b - 1u) >= lo) --b;
while (b < n && x_of(b) < lo) ++b;
// One past the last sample at or before `hi`, from the same lookup: the left
// neighbour of the bound, or the sample the bound landed on.
u64 j0 = 0;
u64 j1 = 0;
if (!series.neighbours(hi, j0, j1)) {
if (series.x_at(n - 1u) < hi) {
j0 = n - 1u;
} else {
return false;
}
} else if (series.x_at(j1) <= hi) {
j0 = j1;
}
const u64 last = j0;
// One past the last sample at or before `hi`, from the same seed.
const u64 seed = series.pyramid().snap(hi, x0, dx);
u64 e = seed == n - 1u ? n : seed + 1u;
while (e < n && x_of(e) <= hi) ++e;
while (e > 0u && x_of(e - 1u) > hi) --e;
if (e <= b) return false;
if (last < b) return false; // the interval falls between two adjacent samples
begin = b;
end = e;
end = last + 1u;
return true;
}
@@ -228,6 +256,12 @@ u32 Plot::add_trace(const TraceDesc& desc) {
trace.axis_x = desc.axis_x;
trace.axis_y = desc.axis_y;
trace.name = desc.name;
trace.mode = desc.mode;
trace.dash_on = desc.dash_on;
trace.dash_off = desc.dash_off;
trace.marker = desc.marker;
trace.marker_size_px = desc.marker_size_px;
trace.marker_every = desc.marker_every;
traces_.push_back(trace);
mark_measures_dirty();
return static_cast<u32>(traces_.size() - 1);
@@ -272,6 +306,57 @@ u32 Plot::add_annotation(const Annotation& annotation) {
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) {
// 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
@@ -363,15 +448,27 @@ void Plot::set_x_range(u32 axis, Range r) {
// normalised; a span that is not a span is ignored outright, leaving the span
// and offset the axis holds. `auto_fit_x()` is how a window is framed either
// way.
//
// The offset is taken in **axis space** (log10 when the axis is logarithmic):
// "how far behind the live edge" is a distance the axis' own transform measures,
// so a log axis states it in decades and the window that follows from it holds
// decades, not a decaying linear width. The span itself is read back from the
// stored range by place_live_x_window(), in the same space.
const f64 lo = std::min(r.min, r.max);
const f64 hi = std::max(r.min, r.max);
if (!std::isfinite(lo) || !std::isfinite(hi) || !(hi > lo)) return;
a.range = Range{lo, hi};
a.offset = live.max - hi;
a.offset = to_axis_space(live.max, a.log) - to_axis_space(hi, a.log);
place_live_x_window(axis);
a.auto_scale = false; // as ever: a range a caller chose is not an auto-fit
return;
}
// A bound that is not a number is not a range: storing one would hand the
// transforms a NaN (every mapping of it is NaN) and clear `auto_scale`, the one
// way back -- so the call is refused outright and the axis keeps the range *and*
// the auto-scale state it had. The same rule `set_region()` applies to a region's
// bounds, and for the same reason.
if (!std::isfinite(r.min) || !std::isfinite(r.max)) return;
// Off, or Live with nothing streaming to follow: the range is stored verbatim,
// which is what every caller of this setter got before follow existed.
a.range = r;
@@ -380,6 +477,9 @@ void Plot::set_x_range(u32 axis, Range r) {
void Plot::set_y_range(u32 axis, Range r) {
if (axis >= y_axes_.size()) return;
// The x setter's refusal, on the y side: a non-finite bound would leave the axis
// with no range to draw and no auto-scale to recover with.
if (!std::isfinite(r.min) || !std::isfinite(r.max)) return;
y_axes_[axis].range = r;
y_axes_[axis].auto_scale = false;
}
@@ -421,7 +521,10 @@ XFollow Plot::x_follow(u32 axis) const {
void Plot::set_x_offset(u32 axis, f64 units_behind_live) {
if (axis >= x_axes_.size()) return;
// No such offset: a NaN would place a window that is not a window, and the last
// usable one is a better answer than that for a value no gesture can undo.
// usable one is a better answer than that for a value no gesture can undo. The
// units are **axis space** ones -- data units on a linear axis, decades on a log
// one -- matching how `set_x_range()` derives the offset and how
// `place_live_x_window()` spends it.
if (!std::isfinite(units_behind_live)) return;
x_axes_[axis].offset = std::max(0.0, units_behind_live);
// The upper end of the clamp is what the traces still retain, which only they
@@ -489,19 +592,30 @@ bool Plot::place_live_x_window(u32 axis) {
if (a.follow != XFollow::Live) return false;
Range live{};
if (!live_x_bounds(axis, live)) return false;
// The span is the axis' own -- what the user zoomed to, or what the last fit
// produced -- and the follow window only moves it: the span is what makes this a
// scroll rather than a rescale. A span that is not a span is nothing to place a
// window from, so the range is left exactly as it is.
const f64 span = a.range.span();
// The span and the offset are distances in **axis space** -- log10 when the axis
// is logarithmic -- the space the controller's gestures are computed in. The span
// is the axis' own -- what the user zoomed to, or what the last fit produced --
// and the follow window only moves it: the span is what makes this a scroll
// rather than a rescale. On a log axis a *linear* span would rescale the view
// once the newest sample moved: ten units of span at 1e0 is ten decades, at 1e9
// it is nothing -- while the log-space span holds the visible decades constant
// whatever the edge does. A span that is not a span is nothing to place a window
// from, so the range is left exactly as it is.
const f64 hi_space = to_axis_space(a.range.max, a.log);
const f64 lo_space = to_axis_space(a.range.min, a.log);
const f64 span = hi_space - lo_space;
if (!std::isfinite(span) || !(span > 0.0)) return false;
// 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 traces hold
// *now* -- an offset past that would put the window's right edge behind the
// oldest sample left, i.e. over data that is gone.
const f64 retained = std::max(0.0, live.max - live.min);
// *now* -- an offset past that would put the window's right edge behind the oldest
// sample left, i.e. over data that is gone. In axis space, like the span: the
// retained window's length is measured by the same ruler that measures the
// offset, or a log axis could hold an offset larger than everything it retains.
const f64 newest_space = to_axis_space(live.max, a.log);
const f64 retained = std::max(0.0, newest_space - to_axis_space(live.min, a.log));
a.offset = std::clamp(a.offset, 0.0, retained);
a.range = Range{live.max - a.offset - span, live.max - a.offset};
const f64 right = newest_space - a.offset;
a.range = Range{from_axis_space(right - span, a.log), from_axis_space(right, a.log)};
return true;
}
@@ -669,12 +783,27 @@ const Measurement& Plot::measurement(u32 id) const {
slot.result = Measurement{};
if (series != nullptr && samples > 0 && slot.cursor_a < cursors_.size() &&
slot.cursor_b < cursors_.size()) {
const u64 a = series->pyramid().snap(cursors_[slot.cursor_a].x, series->x0(), series->dx());
const u64 b = series->pyramid().snap(cursors_[slot.cursor_b].x, series->x0(), series->dx());
// snap() clamps to [0, size-1], so [a, b+1) is inside the series whenever
// the cursor pair is not inverted. An inverted pair is not an interval.
const Cursor& ca = cursors_[slot.cursor_a];
const Cursor& cb = cursors_[slot.cursor_b];
// R8: a measurement is read from two *x* coordinates, and a horizontal ruler
// has no x -- it is the dead field of its orientation (see set_cursor_x()).
// Evaluating the pair anyway would silently measure from x=0 and report
// numbers about nothing, so the result stays invalid instead; the id the
// caller holds is real, and `valid == false` is its answer.
if (!ca.is_horizontal() && !cb.is_horizontal()) {
// The cursors land on samples through the series' own lookups
// (`snap_index` clamps like the old pyramid snap did, and `x_at()` is
// where the sample really is), so an XY series measures between the
// samples its own x array names rather than between indices a uniform
// `dx()` guesses.
const u64 a = series->snap_index(ca.x);
const u64 b = series->snap_index(cb.x);
// snap_index() clamps to [0, size-1], so [a, b+1) is inside the series
// whenever the cursor pair is not inverted. An inverted pair is not an
// interval.
if (a <= b) slot.result = Measure::evaluate(*series, a, b + 1);
}
}
slot.last_series = series;
slot.last_size = samples;
slot.last_x0 = x0;
@@ -821,10 +950,22 @@ MeasureSpan Plot::measure_span(u32 id) const noexcept {
if (id >= measures_.size()) return MeasureSpan{};
const MeasureSlot& slot = measures_[id];
if (slot.cursor_a >= cursors_.size() || slot.cursor_b >= cursors_.size()) return MeasureSpan{};
// A band is drawn between two *x* coordinates, and a horizontal ruler has none
// (the same dead field `measurement()` refuses under R8): the span says invalid
// rather than naming a band one edge of which sits at x=0.
if (cursors_[slot.cursor_a].is_horizontal() || cursors_[slot.cursor_b].is_horizontal()) {
return MeasureSpan{};
}
return MeasureSpan{slot.cursor_a, slot.cursor_b, true};
}
const PlotLayout& Plot::layout() {
// Belt and braces for R1: `set_viewport()` normalises a hostile rect, but every
// `std::clamp` below needs `lo <= hi` to be defined, so the dimensions the chrome
// is clamped against are read non-negative here too -- layout() is on the path of
// every host, every frame, and must be undefined nowhere.
const f32 vw = viewport_.w > 0.0f ? viewport_.w : 0.0f;
const f32 vh = viewport_.h > 0.0f ? viewport_.h : 0.0f;
// Live x windows are placed first, so everything below that derives from a range
// -- the ticks, the grid, the transforms the controller hit-tests through, and the
// range a caller reads back -- describes the live edge of *this* frame rather than
@@ -883,10 +1024,10 @@ const PlotLayout& Plot::layout() {
// left of the allowance -- which is what keeps axes.w and axes.h positive
// however long the labels and titles are. `viewport_` is what a huge label set
// is clamped to first, so neither strip can push the rect outside it.
y_width = std::clamp(y_width, 0.0f, viewport_.w);
x_height = std::clamp(x_height, 0.0f, viewport_.h);
const f32 y_budget = (1.0f - kMinAxesShare) * viewport_.w;
const f32 x_budget = (1.0f - kMinAxesShare) * viewport_.h;
y_width = std::clamp(y_width, 0.0f, vw);
x_height = std::clamp(x_height, 0.0f, vh);
const f32 y_budget = (1.0f - kMinAxesShare) * vw;
const f32 x_budget = (1.0f - kMinAxesShare) * vh;
// The figure's common gutter, when one was imposed: a floor on `y_width + y_title_w`
// so every row's axes starts at the same x. It is served by the *label* strip -- the
// title column is one text line per titled axis and cannot be widened to fill a gutter
@@ -898,13 +1039,22 @@ const PlotLayout& Plot::layout() {
x_height = std::min(x_height, x_budget);
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);
// 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_.x_axis_height = x_height;
layout_.y_title_width = y_title_width;
layout_.x_title_height = x_title_height;
layout_.right_gutter = right_gutter;
layout_.axes = Rect{viewport_.x + y_width + y_title_width, viewport_.y,
viewport_.w - (y_width + y_title_width),
viewport_.h - (x_height + x_title_height)};
vw - (y_width + y_title_width + right_gutter),
vh - (x_height + x_title_height)};
layout_.y_title_area = Rect{viewport_.x, layout_.axes.y, y_title_width, layout_.axes.h};
layout_.y_label_area =
Rect{layout_.y_title_area.right(), layout_.axes.y, y_width, layout_.axes.h};
@@ -917,11 +1067,11 @@ const PlotLayout& Plot::layout() {
if (text_ == nullptr || pass + 1 >= kMaxLayoutPasses) break;
// The same caps as above, so this compares like with like and the loop still
// reaches its fixed point on the second pass.
const f32 need_y = std::min(std::clamp(y_label_width(), 0.0f, viewport_.w), y_budget);
const f32 need_y = std::min(std::clamp(y_label_width(), 0.0f, vw), y_budget);
// Overridden, not merely unmeasured: the strip is not reserved, so the fixed
// point has to be zero for a row that gave its strip away.
const f32 need_x =
x_labels ? std::min(std::clamp(text_->height() + 2.0f * pad, 0.0f, viewport_.h), x_budget)
x_labels ? std::min(std::clamp(text_->height() + 2.0f * pad, 0.0f, vh), x_budget)
: 0.0f;
// What the strip *wants* is the label's own width or the figure's floor, whichever is
// greater -- the floor is a maximum over sources, settled by one `max` and not by
@@ -943,7 +1093,7 @@ const PlotLayout& Plot::layout() {
build_regions();
build_legend();
// 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.
build_crosshair();
return layout_;
@@ -987,8 +1137,10 @@ const data::Series* Plot::snap_series(u32 axis_x) const {
f64 Plot::snapped_x(f64 x, u32 axis_x) const {
const data::Series* s = snap_series(axis_x);
if (s == nullptr) return x;
const u64 i = s->pyramid().snap(x, s->x0(), s->dx());
return s->x0() + static_cast<f64>(i) * s->dx();
// Through the series' own index arithmetic (`snap_index`/`x_at`), so a series
// whose samples are not on a uniform grid snaps to the sample that is really
// nearest, not to where a `dx()` grid would have put one.
return s->x_at(s->snap_index(x));
}
f64 Plot::snapped_x(const Cursor& c) const {
@@ -1211,57 +1363,71 @@ void Plot::build_regions() {
CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept {
CrosshairSpan span;
const u64 n = series.size();
const f64 x0 = series.x0();
const f64 dx = series.dx();
// No samples, no grid, no finite origin: nothing to interpolate between, and `index`
// below would be a division by zero or a NaN -- whose floor is undefined -- rather than a
// sample index. `!(dx > 0.0)` is also the NaN test for `dx`.
if (n == 0u || !(dx > 0.0) || !std::isfinite(x0) || !std::isfinite(x)) return span;
// No samples and no finite query: nothing to bracket.
if (n == 0u || !std::isfinite(x)) return span;
// 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 between. Answering 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 pointer when the series does not reach that far.
const f64 newest = x0 + static_cast<f64>(n - 1u) * dx;
if (x < x0 || x > newest) return span;
// The bracketing samples, straight from the series (`neighbours()`/`x_at()`):
// the window is closed at both ends -- the oldest and the newest sample are
// samples too -- and a query outside it has no neighbours at all. Answering
// 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
// 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
// the old uniform-grid index arithmetic would have read samples that do not
// exist at the positions it assumed.
u64 i0 = 0;
u64 i1 = 0;
if (!series.neighbours(x, i0, i1)) return span;
// Index arithmetic, not a search: the grid is uniform, so the sample at or before `x` is
// `floor((x - x0) / dx)` and there is nothing to look up. The two bounds are checked
// *before* the cast, which is also what keeps it defined: `index` is a quotient of two
// caller-supplied doubles and can overflow to infinity for an extreme origin and spacing,
// and casting that to `u64` is undefined. `!(index > 0.0)` is the NaN test as well.
const f64 index = (x - x0) / dx;
if (!(index > 0.0)) {
span.begin = span.end = 0u; // the oldest sample, or a rounding step below it
// 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
// ends of the window are samples too -- a bracket of (0, 1) at a query sitting
// on sample 0 would read as a span with a width it does not have. Decided on
// the samples' own coordinates, so it holds for any `neighbours()` impl.
if (series.x_at(i0) == x) {
span.begin = span.end = i0;
span.t = 0.0;
span.valid = true;
return span;
}
if (index >= static_cast<f64>(n - 1u)) {
span.begin = span.end = n - 1u; // the newest sample, or a step past it
if (series.x_at(i1) == x) {
span.begin = span.end = i1;
span.t = 0.0;
span.valid = true;
return span;
}
// `index` is finite and inside `(0, n-1)`, so truncating it is a floor on a value a `u64`
// holds. Two rounding cases keep the span honest at a sample: a `t` that lands on 0 is the
// query sitting *on* sample `i`, and one that rounds up to 1.0 is it sitting on the sample
// after it -- neither is an interpolation with no width.
const u64 i = static_cast<u64>(index);
const f64 t = index - static_cast<f64>(i);
if (!(t > 0.0)) {
span.begin = span.end = i;
} else if (t >= 1.0) {
span.begin = span.end = i + 1u;
if (i0 == i1) {
// The series bracketed a single sample for a query that is not on it: there
// is no second sample to choose between, and this one is the honest answer.
span.begin = span.end = i0;
span.t = 0.0;
} else {
span.begin = i;
span.end = i + 1u;
span.t = t;
span.begin = i0;
span.end = i1;
// `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)
// reads the earlier sample.
const f64 xa = series.x_at(i0);
const f64 xb = series.x_at(i1);
span.t = xb > xa ? (x - xa) / (xb - xa) : 0.0;
if (!(span.t > 0.0)) {
span.t = 0.0; // a rounding step below the earlier sample is the sample
} else if (span.t > 1.0) {
span.t = 1.0;
}
}
span.valid = true;
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) {
char buf[16];
std::snprintf(buf, sizeof buf, "#%u", static_cast<unsigned>(trace));
@@ -1291,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 {
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;
const data::Series* series = traces_[trace].series;
if (series == nullptr) return false;
@@ -1302,14 +1473,33 @@ 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,
// and sized for two pairs from construction, so the pointer path allocates nothing.
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);
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_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);
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() {
CrosshairLayout& crosshair = layout_.crosshair;
@@ -1376,17 +1566,23 @@ void Plot::build_crosshair() {
row.trace = i;
row.valid = false;
row.listed = false;
row.x = 0.0;
row.y = 0.0;
row.marker = Vec2{};
row.swatch = Rect{};
row.label_box = Rect{};
row.value_box = Rect{};
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
// where it does not. The row is listed either way, so the absence has to be *said* -- a blank
// value cell reads as a value that has not arrived -- and the dash is measured into the panel's
// width like any other value, so the row's boxes are placed by one rule.
if (crosshair_value(i, crosshair_.x, row.y)) {
// The value where the trace has one at this x, and the em dash where it does not. The row is
// listed either way, so the absence has to be *said* -- a blank value cell reads as a value
// that has not arrived -- and the dash is measured into the panel's width like any other
// value, so the row's boxes are placed by one rule.
//
// 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;
char value[32];
// The trace's *own* y axis formats the number, so a readout reads exactly like the
@@ -1395,7 +1591,7 @@ void Plot::build_crosshair() {
row.value.assign(value);
// Placed through the trace's own axis pair, so the marker sits where that trace is
// 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))};
} else {
row.value.assign(kCrosshairNoValue);
+102 -16
View File
@@ -98,15 +98,42 @@ void format_time(f64 seconds, char* buf, usize n) {
const char* const sign = seconds < 0.0 ? "-" : "";
const f64 t = std::fabs(seconds);
if (t < 1.0) {
// Sub-second: milliseconds. The 999.5 cut keeps %.3g out of scientific
// notation (it would print "1e+03ms") right below the one-second boundary.
const f64 ms = t * 1000.0;
if (ms < 999.5) {
std::snprintf(buf, n, "%s%.3gms", sign, ms);
} else {
std::snprintf(buf, n, "%s%.3gs", sign, t);
// `0.9995` and not `1.0`: three significant figures of 0.9999 s are `1e+03ms`, so a value
// that *rounds* to a second belongs to the whole-second branch below, which writes it `1s`.
// The same spill one rung down is handled inside the ladder.
if (t < 0.9995) {
// Sub-second: the prefix that keeps the mantissa in [1, 1000) -- the same rule the SI
// formatter uses on a y axis, and the reading a zoomed-in window needs. A fixed
// millisecond unit printed `0.005ms` where `5us` is what the axis is showing, and
// `1e-05ms` where `10ns` is. The micro sign is the UTF-8 sequence the embedded font has a
// glyph for (see `format_si`), not a Latin-1 byte.
// Exactly zero carries no unit, the same convention `format_si` states: every prefixed
// form of it is "0" with a unit the value does not have.
if (t == 0.0) {
std::snprintf(buf, n, "0");
return;
}
static const struct {
f64 scale;
const char* unit;
} kUnits[] = {
{1.0e-3, "ms"},
{1.0e-6, "\u00b5s"},
{1.0e-9, "ns"},
{1.0e-12, "ps"},
};
// 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;
}
@@ -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();
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);
if (!std::isfinite(step) || !(step > 0.0)) return; // span so large the step overflowed
const f64 first_major = std::ceil(lo / step) * step;
// The major walk starts on the *origin's* grid: with an origin the ticks a host reads are
// round numbers relative to it (0, 0.5ms, 1ms of the window it zoomed into), which is the
// whole point of having one. `origin = 0` is the plain absolute grid.
const f64 first_major = origin + std::ceil((lo - origin) / step) * step;
if (!std::isfinite(first_major)) return;
// Minor ticks subdivide the major step: by 4 when the step's leading digit is
@@ -167,22 +198,77 @@ void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out, boo
const f64 minor_step = step / minor_ratio;
if (!std::isfinite(minor_step) || !(minor_step > 0.0)) return;
const f64 stride = include_minors ? minor_step : step;
// Minors subdivide the major step an exact integer number of times, so the
// major/minor split is an *integer-index* test: a tick is a major when its
// minor index, counted from `first_major`, is a multiple of the ratio. Both
// operands of the index are exact for the windows axes actually show --
// `v - first_major` is the difference of two near doubles (Sterbenz-exact), and
// rounding the small quotient lands on the index the grid defines -- so no float
// tolerance is needed and none is applied. The old `|v - round(v/step)*step| <=
// step*1e-9` test fought a ULP-sized residue that an epoch origin produces
// whenever the step is not a power of two (v/step carries a rounding error of a
// few ULP of 1.75e9, i.e. far more than step*1e-9) and misclassified its majors;
// the index here has no residue to fight.
const auto minor_index_is_major = [&](f64 v) {
const f64 index = std::round((v - first_major) / minor_step);
return std::fmod(index, minor_ratio) == 0.0;
};
// Mutable: once the minors are capped the walk steps the major stride (see the
// cap branch below).
f64 stride = include_minors ? minor_step : step;
const f64 first = include_minors ? std::ceil(lo / minor_step) * minor_step : first_major;
if (!std::isfinite(first)) return;
// The walk starts at `ceil(lo / stride) * stride`, which is not representable
// whenever the stride is not a power of two -- 0.1 and 0.2 are the common ones --
// so a range that straddles the origin never lands *on* it: [-1, 1] stepped by 0.1
// reaches -1.39e-16 where it means zero, and that renders as `-139a`, a label from
// the far end of the SI table sitting between `-500m` and `500m`. Deriving `v` by
// multiplication instead of accumulation does not help, because the error is
// already in `first`.
//
// Every tick is an integer multiple of the stride, so the smallest magnitude a
// *non-zero* tick can have is the stride itself, while the residue is a few ULP of
// the range -- a relative gap of some ten orders of magnitude. Anything inside a
// millionth of the stride is therefore the zero tick and nothing else, and it is
// stored as exactly `0.0` rather than merely labelled as such: `Axis::refresh()`
// re-formats a time axis from `tick.value`, where the same residue reads
// `3.19e-13ms`.
const f64 zero_tol = (include_minors ? minor_step : step) * 1e-6;
char label[kLabelBuf];
usize minors = 0;
for (f64 v = first; v <= hi + stride * 1e-9;) {
if (out.size() >= kMaxTicks) break;
const bool major = !include_minors ||
std::fabs(v - std::round(v / step) * step) <= step * 1e-9;
const bool major = !include_minors || minor_index_is_major(v);
if (!major && minors >= kMaxTicks) {
// The cap counts minors only, and the majors are never truncated: an axis
// dense enough to want more subdivisions than fit loses its *subdivisions*,
// not its labels, so the far end of the axis keeps its numbers. From here on
// the walk steps the major stride; the next major at or after `v` is
// re-seated onto the major grid explicitly, because continuing from an
// off-grid minor with the new stride would visit only off-grid points and
// silently drop every remaining label. `minors >= kMaxTicks` is also the
// guard that keeps a hostile request (px towering over min_px) from stepping
// a minor stride across the whole range after the cap; the majors-only walk
// is as long as the caller's own pixel count asked it to be.
stride = step;
const f64 major_index = std::ceil((v - first_major) / step - 1e-9);
f64 reseat = first_major + major_index * step;
if (reseat < v) reseat = first_major + (major_index + 1.0) * step;
v = reseat;
continue;
}
if (!major) ++minors;
Tick tick;
tick.value = v;
// The walk variable itself is left alone: `minor_index_is_major` counts indices
// from `first_major` and both operands have to stay on the walk's own grid.
tick.value = std::fabs(v) < zero_tol ? 0.0 : v;
tick.major = major;
if (major) {
// format_si owns both label guards: it never writes past the buffer, and it
// falls back to %.3g for a non-finite value or a label over 12 characters.
format_si(v, label, sizeof label);
format_si(tick.value, label, sizeof label);
tick.label = label;
}
out.push_back(std::move(tick));
+14 -5
View File
@@ -9,12 +9,17 @@
namespace plot::render {
namespace {
#ifndef NDEBUG
/// 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.
///
/// 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 {
const auto near = [](f32 p, f32 q) { return std::fabs(p - q) <= kAtlasCellTolerance; };
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
/// the shader both take (see `TextInstance`). A zero, infinite or NaN direction
@@ -93,7 +98,7 @@ void TextBatch::set_cell(Vec2 cell_uv) noexcept {
cell_declared_ = true;
}
void TextBatch::add_cell(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 unit_ux) {
void TextBatch::add_cell(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 unit_ux, Color color) {
if (!cell_declared_) {
// Nobody declared a cell: a caller staging raw glyphs, where the glyph's own
// rect is the only authority there is. `add_text` and the renderer both
@@ -120,14 +125,18 @@ void TextBatch::add_cell(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 unit_ux)
v_.push_back(glyph.v0);
v_.push_back(unit_ux.x);
v_.push_back(unit_ux.y);
v_.push_back(color.r);
v_.push_back(color.g);
v_.push_back(color.b);
v_.push_back(color.a);
}
void TextBatch::add_glyph(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 axis) {
add_cell(pen_px, glyph, px, unit_axis(axis));
void TextBatch::add_glyph(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 axis, Color color) {
add_cell(pen_px, glyph, px, unit_axis(axis), color);
}
Vec2 TextBatch::add_text(const char* utf8, Vec2 pen_px, f32 px, const TextBackend& font,
Vec2 axis) {
Vec2 axis, Color color) {
if (utf8 == nullptr) {
return pen_px;
}
@@ -145,7 +154,7 @@ Vec2 TextBatch::add_text(const char* utf8, Vec2 pen_px, f32 px, const TextBacken
if (glyph == nullptr) {
continue; // no artwork for this codepoint: nothing to draw, nothing to advance
}
add_cell(pen_px, *glyph, px, ux);
add_cell(pen_px, *glyph, px, ux, color);
pen_px = pen_px + ux * (glyph->advance * px);
}
return pen_px;
+6 -2
View File
@@ -32,6 +32,12 @@ void LineProgram::set_dash(Vec2 on_off) const noexcept {
void LineProgram::set_color(Color color) const noexcept { program_.set_uniform("u_color", color); }
void LineProgram::set_aa(f32 px) const noexcept { program_.set_uniform("u_aa", px); }
void LineProgram::set_snap(bool on) const noexcept {
program_.set_uniform("u_snap", on ? 1.0f : 0.0f);
}
bool TextProgram::create(Status* st) noexcept {
program_ = gl::Program::from_source(kTextVS, kTextFS, st);
if (program_.id() == 0) {
@@ -54,6 +60,4 @@ void TextProgram::set_texture_size(Vec2 texels) const noexcept {
program_.set_uniform("u_tex_size", texels);
}
void TextProgram::set_color(Color color) const noexcept { program_.set_uniform("u_color", color); }
} // namespace plot::render
+437 -119
View File
@@ -9,6 +9,7 @@
#include <plot/render/detail/text_scale.hpp>
#include <algorithm>
#include <cstring>
#include <cmath>
#include <cstdio>
#include <cstdlib>
@@ -36,19 +37,11 @@ bool fail(Status* st, ErrorCode code, const char* message) noexcept {
/// `math::Transform::to_screen_*` applies before scaling. A non-positive value on
/// a log axis takes the transform's own clamp, so it lands off-scale rather than
/// as NaN.
Vec2 pre_affine(f64 x, f64 y, bool log_x, bool log_y) noexcept {
return Vec2{static_cast<f32>(log_x ? Transform::log10_positive(x) : x),
static_cast<f32>(log_y ? Transform::log10_positive(y) : y)};
}
/// 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 axis' pre-affine coordinate: the value itself on a linear axis, `log10`
/// of it on a log one -- exactly what `math::Transform::to_screen_*` applies
/// before scaling. A non-positive value on a log axis takes the transform's own
/// clamp, so it lands off-scale rather than as NaN.
f64 space(f64 v, bool log) noexcept { return log ? Transform::log10_positive(v) : v; }
/// One ASCII/UTF-8 line of the measurement readout. `format_si` leaves room for
/// three significant digits plus a prefix and sign.
@@ -190,6 +183,31 @@ u32 Renderer::AxisFont::revision() const { return font_ != nullptr ? font_->revi
// ------------------------------------------------------------------ init
Renderer::~Renderer() {
// The teardown contract (see renderer.hpp): the handles go while the context
// that owns their names is still current -- which it is, for a renderer
// destroyed before its context.
release_gl();
}
void Renderer::release_gl() noexcept {
ready_ = false;
// Assigning fresh objects releases every GL name through the handles'
// destructors: both programs, both vertex arrays and their instance buffers,
// and the atlas texture. Safe to call more than once; every assignment is a
// no-op while the renderer is uninitialised.
atlas_ = render::TextAtlas{};
line_program_ = render::LineProgram{};
text_program_ = render::TextProgram{};
line_vao_ = render::LineVertexArray{};
text_vao_ = render::TextVertexArray{};
lines_.clear();
glyphs_under_.clear();
glyphs_over_.clear();
}
// ------------------------------------------------------------------ init
void Renderer::set_text_backend(const render::TextBackend* font) noexcept {
const render::TextBackend* next = font != nullptr ? font : &default_font_;
if (next == font_) {
@@ -251,7 +269,8 @@ void Renderer::begin_frame(u32 fb_w, u32 fb_h, Rect viewport_px) noexcept {
fb_h_ = fb_h;
viewport_ = viewport_px;
lines_.clear();
glyphs_.clear();
glyphs_under_.clear();
glyphs_over_.clear();
stats_ = FrameStats{};
gl_error_ = false;
@@ -307,7 +326,14 @@ void Renderer::begin_frame(u32 fb_w, u32 fb_h, Rect viewport_px) noexcept {
// The pipelines write straight alpha and rely on blending being on; setting it
// per frame costs nothing and makes the renderer independent of what the host
// left behind.
// left behind. What the host had is saved first (R6) so `end_frame()` can hand
// the function and the enable bit back instead of leaving the frame's choice
// behind for the host's own draws to inherit.
gl::glGetIntegerv(gl::GL_BLEND_SRC_RGB, &blend_src_rgb_);
gl::glGetIntegerv(gl::GL_BLEND_SRC_ALPHA, &blend_src_a_);
gl::glGetIntegerv(gl::GL_BLEND_DST_RGB, &blend_dst_rgb_);
gl::glGetIntegerv(gl::GL_BLEND_DST_ALPHA, &blend_dst_a_);
blend_was_on_ = gl::glIsEnabled(gl::GL_BLEND) != gl::GL_FALSE;
gl::glEnable(gl::GL_BLEND);
gl::glBlendFunc(gl::GL_SRC_ALPHA, gl::GL_ONE_MINUS_SRC_ALPHA);
}
@@ -366,8 +392,10 @@ bool Renderer::begin_frame_text() noexcept {
font_backend_.reset(*font_, style_.device(style_.axis_font_px));
// The cell every glyph staged this frame is drawn in, declared from the backend
// rather than left to whichever glyph is staged first: the instances carry rect
// origins, and the draw carries this size (see TextBackend::cell_uv).
glyphs_.set_cell(font_backend_.cell_uv());
// origins, and the draw carries this size (see TextBackend::cell_uv). Both
// batches draw from the same atlas at the same size, so both declare it.
glyphs_under_.set_cell(font_backend_.cell_uv());
glyphs_over_.set_cell(font_backend_.cell_uv());
// A backend whose revision moved -- a font swapped, or re-baked at another size
// -- is re-uploaded here, before anything is staged from it, so the frame draws
// its glyphs from the atlas they were baked into. The first sync happened in
@@ -417,9 +445,10 @@ void Renderer::draw(Plot& plot) {
// its tick rebuilds, and the grid build all run in here, and every helper below
// reads the strips, the axes rect and the transforms it pins.
draw_plot(plot, plot.layout());
// The frame's single text flush, after every line batch of the frame: text is
// texture-bound, so it is one draw call whatever it holds (see draw_plot).
flush_text();
// The frame's over-text flush, after every line batch and after the under batch
// has already gone out inside draw_plot: readout text, tick labels and titles --
// what must sit over everything (see draw_plot).
flush_text(glyphs_over_);
}
void Renderer::draw(Figure& figure) {
@@ -453,7 +482,7 @@ void Renderer::draw(Figure& figure) {
// overload -- the one layout of this frame, not a second derivation of it.
draw_plot(plot, static_cast<const Plot&>(plot).layout());
}
flush_text();
flush_text(glyphs_over_);
}
void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) {
@@ -504,9 +533,14 @@ void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) {
draw_measures(plot, layout);
draw_annotations(plot);
draw_legend(plot, layout);
draw_x_note(plot, layout);
// 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
// annotation captions are all chrome the panel is allowed to sit on.
flush_text(glyphs_under_);
// The crosshair's readout last of the interior passes: it is what the pointer is on, so its
// panel is drawn over the legend and every other overlay rather than under them, and its text
// joins the frame's single flush with everything else.
// panel is drawn over the legend and every other overlay rather than under them, and its
// text joins the frame's over flush with the tick labels and titles.
draw_crosshair_readout(plot, layout);
clear_clip();
draw_tick_labels(plot, layout);
@@ -540,7 +574,15 @@ void Renderer::end_frame() noexcept {
gl::glBindBuffer(gl::GL_ARRAY_BUFFER, 0);
gl::glActiveTexture(gl::GL_TEXTURE0);
gl::glBindTexture(gl::GL_TEXTURE_2D, 0);
// The blend state is the host's, not the frame's: what `begin_frame()` saved
// comes back here, function and enable bit both (R6).
gl::glBlendFuncSeparate(static_cast<gl::GLenum>(blend_src_rgb_),
static_cast<gl::GLenum>(blend_dst_rgb_),
static_cast<gl::GLenum>(blend_src_a_),
static_cast<gl::GLenum>(blend_dst_a_));
if (!blend_was_on_) {
gl::glDisable(gl::GL_BLEND);
}
// The scissor, too: the frame engaged it (its baseline clip) and each plot narrowed and
// released it, so what is left behind is the test *off* -- the state the residue list
// promises -- rather than whichever rect the last pass was clipped to. The box stays
@@ -567,12 +609,13 @@ void Renderer::upload_vertices(std::span<const f32> interleaved) noexcept {
}
void Renderer::upload_text_vertices(std::span<const f32> interleaved) noexcept {
// Six floats per instance is the text pipeline's layout (see TextInstance):
// the cell's corner, its atlas rect and the basis that rotates it.
text_vao_.upload(interleaved.data(), static_cast<u32>(interleaved.size() / 6u));
// Ten floats per instance is the text pipeline's layout (see TextInstance):
// the cell's corner, its atlas rect, the basis that rotates it and the tint.
text_vao_.upload(interleaved.data(), static_cast<u32>(interleaved.size() / 10u));
}
void Renderer::flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash) noexcept {
void Renderer::flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash,
bool snap) noexcept {
const u32 segments = lines_.segment_count();
if (segments != 0 && ready_) {
upload_vertices(lines_.data());
@@ -584,7 +627,15 @@ void Renderer::flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash
// Set on *every* flush, solid included, so the crosshair's dash cannot leak into the
// grid, a trace or a cursor drawn after it (see draw_crosshair_lines()).
line_program_.set_dash(dash);
// Likewise set on every flush: the grid's snap must not follow the trace drawn
// after it onto the pixel grid.
line_program_.set_snap(snap);
line_program_.set_color(color);
// The AA fringe, too: the style's logical knob in device pixels, clamped so
// a 0 style still keeps a quarter pixel of ramp. 1.0 -- the default -- is
// the fringe the shaders used to hardcode, so the default frame is
// unchanged.
line_program_.set_aa(std::max(0.25f, style_.device(style_.line_aa_px)));
line_vao_.bind();
// One triangle strip per instance, four corners from gl_VertexID.
gl::glDrawArraysInstanced(gl::GL_TRIANGLE_STRIP, 0, 4,
@@ -595,12 +646,12 @@ void Renderer::flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash
lines_.clear(); // keeps the capacity, so the next group re-uses the store
}
void Renderer::flush_text() noexcept {
const u32 glyphs = glyphs_.glyph_count();
void Renderer::flush_text(render::TextBatch& batch) noexcept {
const u32 glyphs = batch.glyph_count();
// No atlas is no text: a backend that publishes none has nothing to sample, and
// drawing its instances would read whatever texture happened to be bound.
if (glyphs != 0 && ready_ && !atlas_.empty()) {
upload_text_vertices(glyphs_.data());
upload_text_vertices(batch.data());
text_program_.bind();
text_program_.set_viewport({viewport_.w, viewport_.h});
// The cell comes from the backend and the atlas it uploaded -- never from a
@@ -613,26 +664,35 @@ void Renderer::flush_text() noexcept {
text_program_.set_px_range({cell_uv.x * atlas_texels.x * font_backend_.scale(),
cell_uv.y * atlas_texels.y * font_backend_.scale()});
text_program_.set_texture_size(atlas_texels);
text_program_.set_color(style_.axis_text);
// The tint rides on the instance (`a_colour`), so one draw call carries runs
// of several colours -- there is no colour uniform to set.
atlas_.bind();
text_vao_.bind();
gl::glDrawArraysInstanced(gl::GL_TRIANGLE_STRIP, 0, 4,
static_cast<gl::GLsizei>(text_vao_.instance_count()));
++stats_.draw_calls;
}
glyphs_.clear();
batch.clear();
}
// ----------------------------------------------------------- coordinates
Renderer::Mapping Renderer::mapping(const Plot& plot, u32 axis_x, u32 axis_y) const noexcept {
Renderer::Mapping Renderer::mapping(const Plot& plot, u32 axis_x, u32 axis_y,
f64 origin_x) const noexcept {
const Transform tx = plot.x_transform(axis_x);
const Transform ty = plot.y_transform(axis_y);
// The plot's transforms already map data to framebuffer pixels; the shaders
// emit NDC relative to the GL viewport, so the viewport origin comes off here
// -- once per mapping, never per primitive.
//
// The staged x is f32(to_space(x) - to_space(origin_x)) (B1), so the origin is
// folded back into the offset here -- in f64, once, before the single
// narrowing -- and the pair reproduces `to_screen_x` exactly however large the
// absolute x is. y is staged absolute (in pre-affine space), so its offset is
// the transform's own.
const f64 origin_s = space(origin_x, tx.log_x);
return Mapping{Vec2{static_cast<f32>(tx.sx), static_cast<f32>(ty.sy)},
Vec2{static_cast<f32>(tx.tx) - viewport_.x,
Vec2{static_cast<f32>(tx.sx * origin_s + tx.tx - viewport_.x),
static_cast<f32>(ty.ty) - viewport_.y}};
}
@@ -666,25 +726,34 @@ void Renderer::fill_pixels(const Rect& rect, Color color) {
flush_lines(pixel_mapping(), color, rect.h);
}
void Renderer::stage_segment(f64 x0, f64 y0, f64 x1, f64 y1, bool log_x, bool log_y) {
lines_.add_segment(pre_affine(x0, y0, log_x, log_y), pre_affine(x1, y1, log_x, log_y));
void Renderer::stage_segment(f64 origin_x, f64 x0, f64 y0, f64 x1, f64 y1, bool log_x,
bool log_y) {
// Staged relative to `origin_x` in the pre-affine space (B1): the mapping folds
// the origin back in, in f64, so this f32 is O(visible pixels).
lines_.add_segment(Vec2{static_cast<f32>(space(x0, log_x) - space(origin_x, log_x)),
static_cast<f32>(space(y0, log_y))},
Vec2{static_cast<f32>(space(x1, log_x) - space(origin_x, log_x)),
static_cast<f32>(space(y1, log_y))});
}
void Renderer::stage_xy(const f32* xy, u32 n, bool log_x, bool log_y) {
void Renderer::stage_xy(const f32* xy, u32 n, f64 origin_x, bool log_x, bool log_y) {
if (!log_x && !log_y) {
// The common case: gathered points are already interleaved `x,y`, which is
// the batch's own payload, so this is a straight copy.
// The common case costs nothing at all: the gather emitted x relative to this very origin
// (`draw_traces` asks it for `x_range.min`, which is the draw's origin too) and a linear y
// needs no mapping, so the gathered buffer *is* the staged one.
lines_.add_polyline_xy(xy, n);
return;
}
for (u32 i = 0; i + 1u < n; ++i) {
stage_segment(xy[2u * i], xy[2u * i + 1u], xy[2u * i + 2u], xy[2u * i + 3u], log_x, log_y);
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);
}
}
Vec2 Renderer::stage_text(Vec2 pos_px, const char* utf8) {
Vec2 Renderer::stage_text(render::TextBatch& batch, Vec2 pos_px, const char* utf8, Color color) {
// Text is staged in viewport pixels: the text program has no offset uniform.
return glyphs_.add_text(utf8, to_viewport(pos_px), font_backend_.scale(), *font_);
return batch.add_text(utf8, to_viewport(pos_px), font_backend_.scale(), *font_, {1.0f, 0.0f},
color);
}
f32 Renderer::clipped_text_width(const char* utf8, f32 budget_px, Vec2 axis) const {
@@ -692,11 +761,12 @@ f32 Renderer::clipped_text_width(const char* utf8, f32 budget_px, Vec2 axis) con
[](Vec2, const render::Glyph&) {});
}
f32 Renderer::stage_text_clipped(Vec2 pos_px, const char* utf8, f32 budget_px, Vec2 axis) {
f32 Renderer::stage_text_clipped(render::TextBatch& batch, Vec2 pos_px, const char* utf8,
f32 budget_px, Vec2 axis, Color color) {
const f32 scale = font_backend_.scale();
return walk_text_clipped(to_viewport(pos_px), utf8, budget_px, *font_, scale, axis,
[this, scale, axis](Vec2 pen, const render::Glyph& glyph) {
glyphs_.add_glyph(pen, glyph, scale, axis);
[this, &batch, scale, axis, color](Vec2 pen, const render::Glyph& glyph) {
batch.add_glyph(pen, glyph, scale, axis, color);
});
}
@@ -704,9 +774,12 @@ f32 Renderer::stage_text_clipped(Vec2 pos_px, const char* utf8, f32 budget_px, V
void Renderer::draw_grid(const Plot& plot) {
const Grid& grid = plot.grid();
const Mapping map = mapping(plot, 0, 0);
// The grid is built from the primary axis pair, so it is mapped with their
// transforms even when a trace uses another pair.
// transforms even when a trace uses another pair. Its lines are staged
// relative to the x range's own minimum -- the natural origin for a whole-axis
// draw -- and the mapping folds it back in (B1).
const Range x_range = plot.x_range(0);
const Mapping map = mapping(plot, 0, 0, x_range.min);
const bool log_x = plot.x_axis(0).log;
const bool log_y = plot.y_axis(0).log;
@@ -719,20 +792,31 @@ void Renderer::draw_grid(const Plot& plot) {
// to draw rather than being drawn transparent. Majors first, minors over them, as
// the single group order always did. `LineBatch::clear()` keeps its capacity, so
// the second pass re-uses the first one's storage.
const auto draw_class = [&](bool major, bool visible, Color color, f32 width) {
const auto draw_class = [&](bool major, bool visible, Color color, f32 width, Vec2 dash) {
if (!visible) {
return;
}
for (const GridLine& line : grid.lines()) {
if (line.major != major) continue; // grid.build() already dropped non-finite ones
stage_segment(line.x0, line.y0, line.x1, line.y1, log_x, log_y);
stage_segment(x_range.min, line.x0, line.y0, line.x1, line.y1, log_x, log_y);
}
flush_lines(map, color, width);
// Snapped: a grid line is chrome at a round *data* value, and a hairline that
// straddles two columns reads as a grey smear and shimmers as the view pans.
flush_lines(map, color, width, dash, true);
};
draw_class(true, style_.grid_major_visible, style_.grid_major,
style_.device(style_.grid_major_width));
style_.device(style_.grid_major_width), Vec2{});
// The minor grid's dash rides the same per-draw uniform the crosshair and a dashed trace use,
// and `off == 0` is the solid default. Each line starts its own pattern (the dash distance is
// per-segment), and every segment here is built from the range's minimum, so the dashes of the
// vertical lines line up with each other -- the grid reads as one texture rather than as a
// jumble of unrelated dashes.
const Vec2 minor_dash = style_.grid_minor_dash_off > 0.0f
? Vec2{style_.device(style_.grid_minor_dash_on),
style_.device(style_.grid_minor_dash_off)}
: Vec2{};
draw_class(false, style_.grid_minor_visible, style_.grid_minor,
style_.device(style_.grid_minor_width));
style_.device(style_.grid_minor_width), minor_dash);
}
// ------------------------------------------------------------- axis chrome
@@ -745,18 +829,24 @@ void Renderer::draw_axis_lines(const Plot& plot) {
// An x axis draws along its edge of the axes rect, spanning its own range; the
// perpendicular extent is the primary y axis' range, which is exactly what
// maps to the rect's top and bottom.
// maps to the rect's top and bottom. Staged relative to each range's own
// minimum and folded back in by the mapping (B1).
for (u32 i = 0; i < plot.x_axis_count(); ++i) {
const Axis& axis = plot.x_axis(i);
const f64 y = axis.place == AxisPlace::Top ? y_range.max : y_range.min;
stage_segment(axis.range.min, y, axis.range.max, y, axis.log, log_y0);
flush_lines(mapping(plot, i, 0), style_.axis_line, style_.device(style_.line_width_px));
stage_segment(axis.range.min, axis.range.min, y, axis.range.max, y, axis.log, log_y0);
// Snapped, like the grid: an axis line is the frame of the plot, and it is the
// one line a reader compares every other against, so a smeared one is the worst
// place for the half-coverage pair.
flush_lines(mapping(plot, i, 0, axis.range.min), style_.axis_line,
style_.device(style_.line_width_px), Vec2{}, true);
}
for (u32 i = 0; i < plot.y_axis_count(); ++i) {
const Axis& axis = plot.y_axis(i);
const f64 x = axis.place == AxisPlace::Right ? x_range.max : x_range.min;
stage_segment(x, axis.range.min, x, axis.range.max, log_x0, axis.log);
flush_lines(mapping(plot, 0, i), style_.axis_line, style_.device(style_.line_width_px));
stage_segment(x, x, axis.range.min, x, axis.range.max, log_x0, axis.log);
flush_lines(mapping(plot, 0, i, x), style_.axis_line,
style_.device(style_.line_width_px), Vec2{}, true);
}
}
@@ -777,9 +867,10 @@ void Renderer::draw_tick_labels(const Plot& plot, const PlotLayout& layout) {
for (const Tick& tick : plot.x_axis(i).ticks) {
if (!tick.major || tick.label.empty()) continue;
const f32 width = font_backend_.measure(tick.label.c_str());
stage_text({static_cast<f32>(transform.to_screen_x(tick.value)) - 0.5f * width,
stage_text(glyphs_over_,
{static_cast<f32>(transform.to_screen_x(tick.value)) - 0.5f * width,
layout.x_label_area.y + 0.5f * (layout.x_label_area.h - line_h)},
tick.label.c_str());
tick.label.c_str(), style_.axis_text);
}
}
}
@@ -796,8 +887,9 @@ void Renderer::draw_tick_labels(const Plot& plot, const PlotLayout& layout) {
const f32 width = font_backend_.measure(tick.label.c_str());
const f32 centre = static_cast<f32>(transform.to_screen_y(tick.value));
const f32 top = std::clamp(centre - 0.5f * line_h, layout.y_label_area.y, top_limit);
stage_text({layout.y_label_area.right() - style_.device(style_.grid_label_pad) - width, top},
tick.label.c_str());
stage_text(glyphs_over_,
{layout.y_label_area.right() - style_.device(style_.grid_label_pad) - width, top},
tick.label.c_str(), style_.axis_text);
}
}
}
@@ -841,8 +933,8 @@ void Renderer::draw_titles(const Plot& plot, const PlotLayout& layout) {
const f32 width = clipped_text_width(axis.label.c_str(), budget, kTextRight);
// Centred on the strip: the tick labels have their own rows above it, so the
// title needs no alignment of its own.
stage_text_clipped({strip.x + 0.5f * (strip.w - width), y}, axis.label.c_str(), budget,
kTextRight);
stage_text_clipped(glyphs_over_, {strip.x + 0.5f * (strip.w - width), y},
axis.label.c_str(), budget, kTextRight, style_.axis_text);
++row;
}
@@ -872,8 +964,8 @@ void Renderer::draw_titles(const Plot& plot, const PlotLayout& layout) {
if (x + line_h > strip.right()) break;
const f32 budget = strip.h - 2.0f * pad;
const f32 length = clipped_text_width(axis.label.c_str(), budget, kTextUp);
stage_text_clipped({x, strip.bottom() - 0.5f * (strip.h - length)}, axis.label.c_str(), budget,
kTextUp);
stage_text_clipped(glyphs_over_, {x, strip.bottom() - 0.5f * (strip.h - length)},
axis.label.c_str(), budget, kTextUp, style_.axis_text);
++column;
}
}
@@ -896,15 +988,26 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
const u64 n = series.size();
if (!(dx > 0.0) || n == 0) continue;
// Map the visible x range onto sample indices. One sample of slack on each
// side covers a partially visible end bucket; the range is clamped to the
// series, so a zoomed-out view never asks for more than exists.
// The visible x range onto sample indices, through the series' *own* x.
//
// Not the `(x - x0()) / dx()` grid: `dx()` is documented as a nominal spacing (the LOD
// level, a coarse snap) and for a series whose x is a streamed timestamp it is only the
// mean of the retained window. Walking that grid lands the window inside the visible range
// -- and the first and last *segments*, the ones that enter and leave the frame, are then
// not drawn at all, which is the empty margin at both edges of a zoomed-in view. A series
// with a uniform x answers the same question through the same call, so this is the one
// path. One sample of slack on each side keeps a partially visible end bucket whole, and
// the result is clamped to the series: a zoomed-out view never asks for more than exists,
// and a window that reaches past the data leaves the margin genuinely -- correctly -- empty.
const Range x_range = plot.x_range(trace.axis_x);
f64 lo = (x_range.min - series.x0()) / dx;
f64 hi = (x_range.max - series.x0()) / dx;
if (hi < lo) std::swap(lo, hi);
const u64 first = clamp_index(std::floor(lo) - 1.0, n);
const u64 last = clamp_index(std::ceil(hi) + 1.0, n);
// `dx` is still read above for the samples-per-pixel estimate -- a density, where a nominal
// spacing is exactly right -- and never for the window itself.
u64 first = 0;
u64 last = n;
u64 i0 = 0;
u64 i1 = 0;
if (series.neighbours(x_range.min, i0, i1)) first = i0 > 0 ? i0 - 1 : 0;
if (series.neighbours(x_range.max, i0, i1)) last = std::min(n, i1 + 1);
if (last <= first) continue;
// This is the whole performance contract: the window, never the series.
@@ -915,24 +1018,129 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
: 0u;
gather_.clear();
series.gather(level, first, last, gather_);
// 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
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) {
++stats_.raw_traces;
} else {
++stats_.lod_traces;
}
stage_xy(gather_.data(), static_cast<u32>(gather_.size() / 2u),
plot.x_axis(trace.axis_x).log, plot.y_axis(trace.axis_y).log);
// Gathered x is relative to `x_range.min` (the gather contract, with the origin this
// function asked for); the staging reconstructs the absolute data x in f64 and emits
// `to_space(x_abs) - to_space(origin)` about that same origin, so the f32 is O(visible
// pixels), not O(epoch) (B1). The mapping folds the origin back into the offset uniform,
// in f64.
const bool log_x = plot.x_axis(trace.axis_x).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);
// 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);
// A trace's dash rides on the same per-draw uniform the crosshair uses, in
// device pixels; `dash_off <= 0` is the solid default.
const Vec2 dash = trace.dash_off > 0.0f
? Vec2{style_.device(trace.dash_on), style_.device(trace.dash_off)}
: Vec2{};
// A trace's width is a logical size like every other stroke here, so it is
// converted too: left raw, a 1.5 px trace would be drawn 1.5 device px against a
// 3 device px axis line at scale 2 -- half the logical weight, which inverts the
// trace/axis hierarchy on a high-DPI panel.
flush_lines(mapping(plot, trace.axis_x, trace.axis_y), trace.color,
style_.device(trace.width));
// trace/axis hierarchy on a high-DPI panel. `width == 0` is scatter: the
// markers below are the whole trace.
if (trace.width > 0.0f) {
stage_xy(gather_.data(), points, x_range.min, log_x, log_y);
flush_lines(map, trace.color, style_.device(trace.width), dash);
}
// Markers, staged as line segments through this same batch in *pixel* space
// (so their size is device pixels whatever the data scale is). The stride is
// the trace's own `marker_every` or -- when it is 0, the auto mode -- about
// one marker per pixel column: ceil(samples per pixel), so markers only
// appear where the samples are sparse enough to need them.
if (trace.marker != Marker::None) {
const u32 stride =
trace.marker_every != 0
? trace.marker_every
: static_cast<u32>(std::ceil(samples_per_pixel > 1.0 ? samples_per_pixel : 1.0));
const f32 half = style_.device(trace.marker_size_px);
const f64 origin_s = space(x_range.min, log_x);
u32 staged = 0;
for (u32 p = 0; p < points; p += stride) {
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 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};
stage_marker(trace.marker, centre, half);
++staged;
}
if (staged != 0) {
flush_lines(pixel_mapping(), trace.color, style_.device(1.0f));
stats_.staged_markers += staged;
}
}
}
}
@@ -964,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
// a floating-point ulp, which a bare `<` would mistake for "outside". Only the ones
// beyond that margin are skipped.
const f32 half = 0.5f * style_.device(on ? style_.handle_width_px : style_.line_width_px);
// The cursor's own width when it has one, else the style's; a hover or a grab can only make
// it thicker (the cue that the pointer has hold of something).
const f32 own = cursor.width > 0.0f ? style_.device(cursor.width)
: style_.device(style_.line_width_px);
const f32 width = on ? std::max(own, style_.device(style_.handle_width_px)) : own;
const f32 half = 0.5f * width;
if (horizontal ? (at < layout.axes.y - half || at > layout.axes.bottom() + half)
: (at < layout.axes.x - half || at > layout.axes.right() + half)) {
continue;
@@ -985,16 +1198,17 @@ void Renderer::draw_cursors(const Plot& plot, const PlotLayout& layout) {
if (horizontal) {
const f64 y_drawn = plot.y_transform(cursor.axis_y).to_data_y(static_cast<f64>(drawn));
stage_segment(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);
flush_lines(mapping(plot, 0, cursor.axis_y, x_range.min),
on ? style_.cursor : cursor.color, width, cursor.dash);
} else {
const f64 x_drawn = plot.x_transform(cursor.axis_x).to_data_x(static_cast<f64>(drawn));
stage_segment(x_drawn, y_range.min, x_drawn, y_range.max, plot.x_axis(cursor.axis_x).log,
log_y);
stage_segment(x_drawn, x_drawn, y_range.min, x_drawn, y_range.max,
plot.x_axis(cursor.axis_x).log, log_y);
flush_lines(mapping(plot, cursor.axis_x, 0, x_drawn),
on ? style_.cursor : cursor.color, width, cursor.dash);
}
flush_lines(horizontal ? mapping(plot, 0, cursor.axis_y) : mapping(plot, cursor.axis_x, 0),
on ? style_.cursor : cursor.color,
style_.device(on ? style_.handle_width_px : style_.line_width_px));
draw_cursor_label(plot, layout, cursor, drawn);
}
}
@@ -1012,15 +1226,17 @@ void Renderer::draw_cursor_label(const Plot& plot, const PlotLayout& layout, con
char value[32];
plot.y_axis(cursor.axis_y).format_value(cursor.y, value, sizeof value);
if (cursor.label.empty()) {
stage_text(pen, value);
stage_text(glyphs_under_, pen, value, style_.axis_text);
return;
}
stage_text(pen, cursor.label.c_str());
stage_text({pen.x + font_backend_.measure(cursor.label.c_str()) + gap, pen.y}, value);
stage_text(glyphs_under_, pen, cursor.label.c_str(), style_.axis_text);
stage_text(glyphs_under_, {pen.x + font_backend_.measure(cursor.label.c_str()) + gap, pen.y},
value, style_.axis_text);
return;
}
if (!cursor.label.empty()) {
stage_text({at + gap, layout.axes.y + style_.device(2.0f)}, cursor.label.c_str());
stage_text(glyphs_under_, {at + gap, layout.axes.y + style_.device(2.0f)},
cursor.label.c_str(), style_.axis_text);
}
}
@@ -1051,7 +1267,8 @@ void Renderer::draw_regions(const Plot& plot, const PlotLayout& layout) {
hot_right ? region.right_handle.w : edge_w);
if (!config.label.empty() && region.label.w > 0.0f) {
stage_text({region.label.x, region.label.y}, config.label.c_str());
stage_text(glyphs_under_, {region.label.x, region.label.y}, config.label.c_str(),
style_.axis_text);
}
}
}
@@ -1087,17 +1304,19 @@ void Renderer::draw_measures(const Plot& plot, const PlotLayout& layout) {
// The shaded band: one vertical stroke per pixel column of the interval.
// The line pipeline has no filled primitive, and the band is bounded by the
// plot's width, so a per-column fill stays O(pixels) and needs no second
// pipeline. Strokes are staged in data space like everything else, so the
// band follows a pan or a zoom through the uniform alone.
// pipeline. Strokes are staged in data space relative to the axis' visible
// minimum and folded back by the mapping (B1), so the band follows a pan or a
// zoom through the uniform alone.
const f64 origin = plot.x_range(a.axis_x).min;
for (f32 px = x0; px < x1; px += 1.0f) {
const f64 x = transform.to_data_x(static_cast<f64>(px) + 0.5);
stage_segment(x, y_range.min, x, y_range.max, plot.x_axis(a.axis_x).log, log_y);
stage_segment(origin, x, y_range.min, x, y_range.max, plot.x_axis(a.axis_x).log, log_y);
}
// One device pixel, not a logical one: the band is a *fill* made of one
// stroke per framebuffer column (`px += 1.0f` above), so its width is the
// column pitch itself. Scaling it would overlap the columns it does not
// match and leave gaps in the ones it does.
flush_lines(mapping(plot, a.axis_x, 0), style_.measure_fill, 1.0f);
flush_lines(mapping(plot, a.axis_x, 0, origin), style_.measure_fill, 1.0f);
}
char dx[kNumberChars];
@@ -1118,14 +1337,15 @@ void Renderer::draw_measures(const Plot& plot, const PlotLayout& layout) {
// cursor labels, and a wide readout clamped to the left edge would overprint
// them. The y axis title is not a consideration here -- it lives in its own
// strip outside the axes.
stage_text({left, axes.y + line_h + style_.device(2.0f)}, readout);
stage_text(glyphs_under_, {left, axes.y + line_h + style_.device(2.0f)}, readout,
style_.axis_text);
}
}
void Renderer::draw_annotations(const Plot& plot) {
const f32 line_h = font_backend_.height();
// The clip every other overlay is drawn under, read here because the caption is staged
// into the frame's single text batch -- flushed after `clear_clip()` -- so the scissor that
// into the under-text batch -- flushed after `clear_clip()` -- so the scissor that
// keeps the rest of this pass inside the axes does not reach it.
const Rect& axes = plot.layout().axes;
for (u32 i = 0; i < plot.annotation_count(); ++i) {
@@ -1141,13 +1361,19 @@ void Renderer::draw_annotations(const Plot& plot) {
flush_lines(pixel_mapping(), annotation.color, style_.device(1.0f));
// A caption whose anchor is outside the axes is skipped with its cross, which the clip
// has already removed: a caption with no cross is a label for a point the frame does not
// show, and -- because the text batch is drawn *after* the clip is released -- it would
// otherwise paint outside the axes it is measured in, into the neighbouring row of a
// `Figure` whose rows tile the figure. Splitting the frame's one text draw call to clip
// it instead would cost a draw call per row and mis-clip the tick labels, which are
// staged outside `axes` on purpose and share the batch after this one.
// show, and -- because the under-batch text is drawn *after* the clip is released -- it
// would otherwise paint outside the axes it is measured in, into the neighbouring row of a
// `Figure` whose rows tile the figure. The caption box is also clamped inside `axes`
// (R6), and skipped entirely when it cannot fit the rect at all -- an annotation pinned
// inside a sliver of axes keeps its cross but not a caption that would overhang it.
if (!annotation.text.empty() && axes.contains(px, py)) {
stage_text({px + style_.device(5.0f), py - 0.5f * line_h}, annotation.text.c_str());
const f32 width = font_backend_.measure(annotation.text.c_str());
const f32 pad = style_.device(5.0f);
if (width <= axes.w && line_h <= axes.h) {
const f32 x = std::clamp(px + pad, axes.x, axes.right() - width);
const f32 y = std::clamp(py - 0.5f * line_h, axes.y, axes.bottom() - line_h);
stage_text(glyphs_under_, {x, y}, annotation.text.c_str(), annotation.color);
}
}
}
}
@@ -1166,11 +1392,74 @@ void Renderer::draw_legend(const Plot& plot, const PlotLayout& layout) {
// the marker itself, so both branches read their string from one place -- the
// layout measured it, this stages it.
if (row.ellipsis) {
stage_text({row.name.x, row.name.y}, kLegendEllipsis);
stage_text(glyphs_under_, {row.name.x, row.name.y}, kLegendEllipsis, style_.axis_text);
continue;
}
draw_swatch(plot, row.trace, row.swatch);
stage_text({row.name.x, row.name.y}, plot.trace(row.trace).name.c_str());
stage_text(glyphs_under_, {row.name.x, row.name.y}, plot.trace(row.trace).name.c_str(),
style_.axis_text);
}
}
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) {
// 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
// scale is. Cross: the two diagonals. Circle: an 8-gon. Square: the four sides.
switch (marker) {
case plot::Marker::Cross:
lines_.add_segment({centre_px.x - size_px, centre_px.y - size_px},
{centre_px.x + size_px, centre_px.y + size_px});
lines_.add_segment({centre_px.x - size_px, centre_px.y + size_px},
{centre_px.x + size_px, centre_px.y - size_px});
break;
case plot::Marker::Circle: {
// An octagon inscribed in the size box: 8 vertices, 8 segments.
Vec2 prev{};
for (u32 k = 0; k <= 8u; ++k) {
const f32 angle = static_cast<f32>(k) * (3.14159265f / 4.0f);
const Vec2 v{centre_px.x + size_px * std::cos(angle),
centre_px.y + size_px * std::sin(angle)};
if (k != 0) {
lines_.add_segment(prev, v);
}
prev = v;
}
break;
}
case plot::Marker::Square:
lines_.add_segment({centre_px.x - size_px, centre_px.y - size_px},
{centre_px.x + size_px, centre_px.y - size_px});
lines_.add_segment({centre_px.x + size_px, centre_px.y - size_px},
{centre_px.x + size_px, centre_px.y + size_px});
lines_.add_segment({centre_px.x + size_px, centre_px.y + size_px},
{centre_px.x - size_px, centre_px.y + size_px});
lines_.add_segment({centre_px.x - size_px, centre_px.y + size_px},
{centre_px.x - size_px, centre_px.y - size_px});
break;
case plot::Marker::None:
break;
}
}
@@ -1201,8 +1490,19 @@ void Renderer::draw_swatch(const Plot& plot, u32 trace, const Rect& box) {
// Opaque whatever the trace's own alpha is: a swatch drawn at a trace's 15% would be the least
// legible thing in the frame, and the swatch is the one element that says which colour an entry or
// a readout row *is*. One rule, used by the legend and by the crosshair's readout alike.
Color swatch = plot.trace(trace).color;
const Trace& source = plot.trace(trace);
Color swatch = source.color;
swatch.a = 1.0f;
if (source.dash_off > 0.0f && box.w > 0.0f && box.h > 0.0f) {
// A dashed trace's swatch *samples the dash*: a solid fill would describe the
// one thing the trace is not. One dashed stroke across the box's midline, in
// the trace's own pattern (converted to device pixels, as the trace itself is).
const f32 mid = box.y + 0.5f * box.h;
lines_.add_segment({box.x, mid}, {box.right(), mid});
flush_lines(pixel_mapping(), swatch, std::min(box.h, style_.device(source.width)),
Vec2{style_.device(source.dash_on), style_.device(source.dash_off)});
return;
}
fill_pixels(box, swatch);
}
@@ -1221,35 +1521,49 @@ void Renderer::draw_crosshair_lines(const Plot& plot, const PlotLayout& layout)
//
// The dash rides on the same segment: `v_along` is measured from the axes edge the line is
// staged from, so no geometry is added and the phase does not depend on the transform.
const Mapping data = mapping(plot, 0u, 0u);
const Mapping data = mapping(plot, 0u, 0u, plot.x_range(0u).min);
if (crosshair.vertical.w > 0.0f) {
const f64 x = plot.x_transform(0u).to_data_x(
static_cast<f64>(crosshair.vertical.x + 0.5f * crosshair.vertical.w));
const Range y_range = plot.y_range(0u);
stage_segment(x, y_range.min, x, y_range.max, plot.x_axis(0u).log, plot.y_axis(0u).log);
// Both lines share the horizontal's origin (the visible x minimum), so the
// staged values and the one mapping agree (B1): the vertical is staged as
// to_space(x) - to_space(min), not relative to itself.
stage_segment(plot.x_range(0u).min, x, y_range.min, x, y_range.max, plot.x_axis(0u).log,
plot.y_axis(0u).log);
flush_lines(data, style_.crosshair, stroke, crosshair.dash);
}
if (crosshair.horizontal.h > 0.0f) {
const f64 y = plot.y_transform(0u).to_data_y(
static_cast<f64>(crosshair.horizontal.y + 0.5f * crosshair.horizontal.h));
const Range x_range = plot.x_range(0u);
stage_segment(x_range.min, y, x_range.max, y, plot.x_axis(0u).log, plot.y_axis(0u).log);
stage_segment(x_range.min, x_range.min, y, x_range.max, y, plot.x_axis(0u).log,
plot.y_axis(0u).log);
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
// legend's swatch, so a trace drawn at 15% still has a marker that can be seen. The arm
// length is a logical size like every other marker's, and the position is the layout's: a
// marker for a sample far outside the visible y range is staged where the trace is and the
// axes clip keeps it inside, rather than being skipped by a range test of this layer's own.
const f32 arm = style_.device(3.0f);
// A cross at each trace's readout point, in the trace's own colour -- opaque, like a legend's
// swatch, so a trace drawn at 15% still has a mark that can be seen. The position is the
// layout's, and in the default `CrosshairValue::NearestSample` that is the *quoted sample*
// rather than the pointer's column: a mark for a sample outside the visible range is staged
// where that sample is and the axes clip keeps it inside, rather than being skipped by a range
// 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) {
if (!row.valid) continue;
Color color = plot.trace(row.trace).color;
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, 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);
}
}
}
@@ -1265,10 +1579,14 @@ void Renderer::draw_crosshair_readout(const Plot& plot, const PlotLayout& layout
for (const CrosshairRow& row : crosshair.rows) {
if (!row.listed) continue;
draw_swatch(plot, row.trace, row.swatch);
stage_text({row.label_box.x, row.label_box.y}, row.label.c_str());
stage_text(glyphs_over_, {row.label_box.x, row.label_box.y}, row.label.c_str(),
style_.axis_text);
// The value is a number, or `kCrosshairNoValue` for a trace with no sample at this x (the plot
// put it there): either way there is a run to stage, so a row is never a blank cell.
stage_text({row.value_box.x, row.value_box.y}, row.value.c_str());
// put it there): either way there is a run to stage, so a row is never a blank cell. It is
// drawn in the row trace's own colour, resolved here from the plot -- the one tint the text
// pipeline carries per instance, so labels and values of one draw call can differ.
stage_text(glyphs_over_, {row.value_box.x, row.value_box.y}, row.value.c_str(),
plot.trace(row.trace).color);
}
}
+102 -12
View File
@@ -19,6 +19,8 @@ uniform vec2 u_scale; // data -> pixels
uniform vec2 u_offset; // pixels
uniform vec2 u_viewport; // render target size, pixels
uniform float u_width; // stroke width, pixels
uniform float u_aa; // AA fringe half-width, pixels
uniform float u_snap; // non-zero: put an axis-aligned segment's ink on whole pixels
out float v_dist; // signed distance to the segment axis, pixels
// Distance travelled from a_p0, pixels -- and the dash's phase anchor. Both of the
@@ -31,9 +33,43 @@ out float v_along;
void main() {
vec2 p0 = a_p0 * u_scale + u_offset;
vec2 p1 = a_p1 * u_scale + u_offset;
// The pixel-grid snap, for the chrome that *frames* the plot: a grid line, an
// axis line, a panel or legend border. Those are staged in data space like
// everything else, so a 1 px stroke's centre lands wherever the mapping puts it
// -- at x = 100.0 the ink spans [99.5, 100.5] and lights two columns at half
// coverage each (a grey smear), at x = 100.5 it spans [100, 101] and lights one
// crisply. Which of the two a given line got depended on the view, so a frame's
// gridlines were a mix of both and panning made each shimmer between them.
//
// Snapping the ink's *leading edge* to a pixel boundary -- rather than the
// centre to a half pixel -- is the form that holds for any width: it reduces to
// integer + 0.5 for an odd stroke and to an integer for an even one, with no
// case analysis. `u_aa` is deliberately not in `ink`: the fringe is symmetric
// about the ink and snapping with it folded in would bias the line by half a
// fringe.
//
// Only for an axis-aligned segment, and only on the axis it is constant along:
// a diagonal has no single perpendicular to round, and moving either end of one
// by a part of a pixel would tilt it. That restriction is also why this is safe
// to leave on for a whole draw -- the grid and the axis lines are nothing *but*
// axis-aligned segments. Traces, cursors, regions and the crosshair pass 0: a
// line that reports a data position may not be moved off it, however little.
if (u_snap != 0.0) {
float ink = u_width * 0.5;
vec2 snapped = floor(p0 - vec2(ink) + vec2(0.5)) + vec2(ink);
if (abs(p1.x - p0.x) < 1e-4) { // vertical: round x, leave y
p0.x = snapped.x;
p1.x = snapped.x;
} else if (abs(p1.y - p0.y) < 1e-4) { // horizontal: round y, leave x
p0.y = snapped.y;
p1.y = snapped.y;
}
}
vec2 d = p1 - p0;
float len = length(d);
float half_w = u_width * 0.5 + 1.0; // + one pixel of AA fringe
float half_w = u_width * 0.5 + u_aa; // + the AA fringe
// A degenerate segment has no direction: pin the normal, and give the quad
// area by stretching its two ends apart along x. Collapsing all four corners
// onto p0 would rasterise nothing, and a flat trace staged as p == p (the
@@ -64,6 +100,7 @@ in float v_dist; // signed distance to the segment axis, pixels
in float v_along; // distance travelled from a_p0, pixels
uniform float u_width; // stroke width, pixels
uniform float u_aa; // AA fringe half-width, pixels
uniform vec4 u_color;
uniform vec2 u_dash; // dash pattern in pixels: x = ink, y = gap; y <= 0 is solid
@@ -85,12 +122,15 @@ void main() {
}
float half_w = u_width * 0.5;
float d = abs(v_dist);
if (d > half_w + 1.0) {
if (d > half_w + u_aa) {
discard; // outside the fringe; nothing to blend
}
// Ramp over the last pixel of the half width so a thin stroke keeps its
// weight instead of aliasing into a dashed line.
float alpha = 1.0 - smoothstep(half_w - 0.5, half_w + 0.5, d);
// Ramp over the fringe so a thin stroke keeps its weight instead of aliasing
// into a dashed line: the ramp is centred on `half_w` and spans `u_aa`, so
// `u_aa == 1.0` -- the style default -- reproduces the historical single-pixel
// ramp exactly. `u_aa` is the style's `line_aa_px` in device pixels, clamped
// at 0.25 by the renderer.
float alpha = 1.0 - smoothstep(half_w - 0.5 * u_aa, half_w + 0.5 * u_aa, d);
o_color = vec4(u_color.rgb, u_color.a * alpha);
}
)";
@@ -99,12 +139,18 @@ const char* const kTextVS = R"(#version 330 core
layout(location = 0) in vec2 a_pos; // per-instance glyph cell top-left, pixels
layout(location = 1) in vec2 a_uv; // per-instance glyph rect top-left, normalised
layout(location = 2) in vec2 a_axis; // per-instance cell +x axis, unit, pixels
layout(location = 3) in vec4 a_colour; // per-instance tint, straight alpha
uniform vec2 u_viewport; // render target size, pixels
uniform vec2 u_px_range; // glyph cell on screen, pixels (width, height)
uniform vec2 u_glyph_uv; // glyph cell in the atlas, normalised (width, height)
out vec2 v_uv; // normalised atlas coordinate of this fragment
out vec4 v_colour; // the instance's tint
// This glyph's cell origin, flat: the fragment shader filters inside the cell and
// has to know where it starts. Interpolating it would make the bound it clamps
// against drift across the quad, which is the one thing the clamp is there to stop.
flat out vec2 v_cell0;
void main() {
// The cell's two axes: the run direction the instance carries and its
@@ -123,25 +169,69 @@ void main() {
// The same handedness as the cell, so the glyph's rect lands on it the right
// way round: u runs along the basis, v down the glyph.
v_uv = a_uv + corner * u_glyph_uv;
v_colour = a_colour;
v_cell0 = a_uv;
}
)";
const char* const kTextFS = R"(#version 330 core
in vec2 v_uv; // normalised atlas coordinate of this fragment
in vec4 v_colour; // the instance's tint
flat in vec2 v_cell0; // this glyph's cell origin, normalised
uniform sampler2D u_atlas; // GL_R8 glyph atlas, GL_NEAREST
uniform vec2 u_tex_size; // atlas size in texels
uniform vec4 u_color;
uniform vec2 u_glyph_uv; // the cell's size in normalised atlas coordinates
out vec4 o_color;
/// Coverage of one texel, addressed by its integer index. Every fetch in here is
/// an explicit texel centre, which is why the sampler can stay `GL_NEAREST`: the
/// filtering below is this shader's, not the sampler's, so no fetch can tie at a
/// texel boundary and no fetch can be steered outside the cell clamped for it.
float tap(vec2 texel) {
return texture(u_atlas, (texel + vec2(0.5)) / u_tex_size).r;
}
void main() {
// Sample the centre of the texel the fragment lands in. GL_NEAREST alone
// would tie at a texel boundary, where the driver may pick the neighbouring
// glyph cell; rounding to a centre removes the tie by construction.
vec2 texel = clamp(floor(v_uv * u_tex_size), vec2(0.0), u_tex_size - vec2(1.0));
float coverage = texture(u_atlas, (texel + vec2(0.5)) / u_tex_size).r;
o_color = vec4(u_color.rgb, u_color.a * coverage);
// Bilinear over the four texels the fragment sits between, **clamped to this
// glyph's own cell**.
//
// It used to be a single point sample of the texel the fragment landed in, and
// the two halves of that -- a point sample, and `AxisFont` drawing the atlas at
// `requested_px / em_px` -- are each reasonable and jointly wrong: a point
// sample at a non-integer scale *deletes* texels rather than blending them. The
// library's own default is a 12 px style on the 13 px embedded bake, i.e. 12/13,
// which dropped one of every 8 columns and one of every 17 rows of every glyph
// -- whole strokes, not softened edges, and which stroke went depended on where
// the unsnapped pen happened to fall. Filtering costs four fetches of a texture
// that is one row of cells and is in cache for the whole pass.
//
// The clamp is what keeps the old guarantee: the atlas is a tight strip of cells
// with no padding between them, so a footprint reaching past a cell edge would
// blend in the neighbouring glyph. Clamping to the cell's own texel range
// replicates its edge instead, and a cell's border texels are blank (the cell is
// the face's widest advance by its full ascent-to-descent extent), so the
// replication is of nothing.
//
// `floor(x + 0.5)` on both bounds, not a raw product: they come back from a
// normalised coordinate times a size, so an exact texel index arrives a few ULP
// either side of itself, and a `floor` of 191.99999 is off by a whole texel.
vec2 lo = floor(v_cell0 * u_tex_size + vec2(0.5));
vec2 hi = lo + floor(u_glyph_uv * u_tex_size + vec2(0.5)) - vec2(1.0);
// Texel *centres* are at integer + 0.5 in normalised-times-size space, so
// subtracting the half puts the sample on a grid whose integers are the centres:
// `f` is then the distance between the two taps, and a fragment that lands on a
// centre -- every fragment, when the scale is 1 and the pen is whole -- gets
// `f == 0` and exactly one texel, as crisp as the point sample ever was.
vec2 t = v_uv * u_tex_size - vec2(0.5);
vec2 t0 = floor(t);
vec2 f = t - t0;
vec2 a = clamp(t0, lo, hi);
vec2 b = clamp(t0 + vec2(1.0), lo, hi);
float coverage = mix(mix(tap(vec2(a.x, a.y)), tap(vec2(b.x, a.y)), f.x),
mix(tap(vec2(a.x, b.y)), tap(vec2(b.x, b.y)), f.x), f.y);
o_color = vec4(v_colour.rgb, v_colour.a * coverage);
}
)";
+6
View File
@@ -71,6 +71,12 @@ bool TextAtlas::sync(const TextBackend& backend, Status* st) noexcept {
// no second create -- and it keeps the filter, which is why the filter is set
// on every upload rather than once at creation.
texture_.upload_r8(width, height, pixels);
// `GL_NEAREST` on purpose, and it is not the absence of filtering: `kTextFS`
// filters, with four fetches it aims at explicit texel centres and clamps to
// the instance's own cell. `GL_LINEAR` here would do the blending with the
// *sampler's* footprint instead, which knows nothing of cell bounds -- and the
// atlas is a tight strip of cells with no padding, so a glyph at a cell edge
// would pick up its neighbour.
texture_.set_filter(gl::GL_NEAREST, gl::GL_NEAREST);
}
gl::glPixelStorei(gl::GL_UNPACK_ALIGNMENT, host_alignment);
+7 -3
View File
@@ -72,15 +72,18 @@ bool TextVertexArray::create(Status* st, UploadMode mode) noexcept {
}
vao_.bind();
buffer_.bind();
// Three attributes now: the cell's corner, its atlas rect and the basis that
// rotates it (see TextInstance). All of them advance once per instance, like
// the line pipeline's, because the corners come from gl_VertexID.
// Four attributes now: the cell's corner, its atlas rect, the basis that
// rotates it and the run's tint (see TextInstance). All of them advance once
// per instance, like the line pipeline's, because the corners come from
// gl_VertexID.
vao_.attrib(0u, 2, sizeof(TextInstance), offsetof(TextInstance, x));
vao_.attrib(1u, 2, sizeof(TextInstance), offsetof(TextInstance, u));
vao_.attrib(2u, 2, sizeof(TextInstance), offsetof(TextInstance, ax));
vao_.attrib(3u, 4, sizeof(TextInstance), offsetof(TextInstance, r));
gl::glVertexAttribDivisor(0u, kPerInstance);
gl::glVertexAttribDivisor(1u, kPerInstance);
gl::glVertexAttribDivisor(2u, kPerInstance);
gl::glVertexAttribDivisor(3u, kPerInstance);
gl::glBindVertexArray(0);
epoch_ = buffer_.epoch();
return true;
@@ -99,6 +102,7 @@ void TextVertexArray::upload(const f32* interleaved, u32 glyphs) noexcept {
vao_.attrib(0u, 2, sizeof(TextInstance), base + offsetof(TextInstance, x));
vao_.attrib(1u, 2, sizeof(TextInstance), base + offsetof(TextInstance, u));
vao_.attrib(2u, 2, sizeof(TextInstance), base + offsetof(TextInstance, ax));
vao_.attrib(3u, 4, sizeof(TextInstance), base + offsetof(TextInstance, r));
epoch_ = buffer_.epoch();
}
instances_ = glyphs;
+22 -4
View File
@@ -32,6 +32,7 @@ add_executable(plot_tests
test_ring_buffer.cpp
test_minmax_pyramid.cpp
test_series.cpp
test_xy_series.cpp
test_ticks.cpp
test_measure.cpp
test_plot.cpp
@@ -77,8 +78,8 @@ target_include_directories(plot_tests PRIVATE "${CMAKE_SOURCE_DIR}/adapters")
# The optional runtime-font backend brings its own cases, and they exist only when
# its target does: the header, the class and the archive are all absent otherwise,
# so there would be nothing to compile them against (see PLOT_BUILD_TTF_FONT in the
# top-level CMakeLists.txt). Guarded on the target rather than on the option, like
# so there would be nothing to compile them against (see PLOT_BUILD_TTF_FONT in
# cmake/plot_options.cmake). Guarded on the target rather than on the option, like
# the adapter cases above, so a target that exists for any reason gets its cases.
#
# Two paths are handed over as definitions because a case needs them and should
@@ -157,6 +158,18 @@ foreach(plot_header IN LISTS plot_public_headers)
list(APPEND plot_header_check_sources "${plot_header_check_source}")
endforeach()
# The generated version header is public (the umbrella names it) but is not
# under the source include root -- the top-level CMakeLists.txt configures it
# into <build>/include/plot/version.hpp -- so the glob above cannot see it. It
# gets the same standalone-compile TU by hand, and reaches the header through
# plot::core's build-tree include interface.
file(GENERATE
OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/header_checks/plot_version.cpp"
CONTENT
"// Generated by tests/CMakeLists.txt: <plot/version.hpp> must compile on its own.\n#include <plot/version.hpp>\n")
list(APPEND plot_header_check_sources
"${CMAKE_CURRENT_BINARY_DIR}/header_checks/plot_version.cpp")
add_library(plot_header_self_containment OBJECT ${plot_header_check_sources})
target_link_libraries(plot_header_self_containment PRIVATE plot::core)
plot_apply_warnings(plot_header_self_containment)
@@ -189,8 +202,12 @@ plot_apply_warnings(plot_umbrella_check)
# Coverage check on the umbrella. Included here so a public header missing from
# plot.hpp stops the configure; the ctest entry below runs the same script again
# so the check is also a visible test.
# so the check is also a visible test. The generated version header is passed in
# both times: the script must require it of the umbrella, and must look for the
# file in the build tree where configure_file() put it.
set(PLOT_INCLUDE_DIR "${CMAKE_SOURCE_DIR}/include")
set(PLOT_GENERATED_INCLUDE_DIR "${CMAKE_BINARY_DIR}/include")
set(PLOT_EXTRA_PUBLIC_HEADERS "plot/version.hpp")
set(PLOT_UMBRELLA "${CMAKE_SOURCE_DIR}/include/plot/plot.hpp")
include("${CMAKE_SOURCE_DIR}/cmake/plot_umbrella_check.cmake")
@@ -199,6 +216,7 @@ add_test(NAME plot_headers_self_contained
add_test(NAME plot_umbrella_covers_public_headers
COMMAND ${CMAKE_COMMAND}
-DPLOT_INCLUDE_DIR=${CMAKE_SOURCE_DIR}/include
-DPLOT_UMBRELLA=${CMAKE_SOURCE_DIR}/include/plot/plot.hpp
-DPLOT_GENERATED_INCLUDE_DIR=${CMAKE_BINARY_DIR}/include
-DPLOT_EXTRA_PUBLIC_HEADERS=plot/version.hpp
-P "${CMAKE_SOURCE_DIR}/cmake/plot_umbrella_check.cmake")
add_test(NAME plot_umbrella_check COMMAND plot_umbrella_check)
+446 -24
View File
@@ -187,21 +187,30 @@ PLOT_TEST(hovered_cursor_only_inside_plot_area) {
PLOT_CHECK(c.hovered_cursor() == cid);
}
PLOT_TEST(ctrl_drag_moves_the_nearest_cursor) {
PLOT_TEST(ctrl_drag_box_zooms_x_only_even_over_a_cursor) {
// The modifier table: Ctrl+Left is the x-only box zoom now -- it wins over the
// cursor grab a plain press would open, and the release frames the x range from
// the selection while the y range (and the cursor under the press) stay put.
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 cid = p.add_cursor(20.0, plot::Color::hex(0xFF0000));
const u32 ax = p.add_x_axis({"t", {0.0, 800.0}, false, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
const u32 cid = p.add_cursor(200.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, 20.0);
const f32 sx = screen_x_of(p, ax, 200.0);
const f64 want_lo = p.x_transform(ax).to_data_x(sx);
const f64 want_hi = p.x_transform(ax).to_data_x(sx + 100.0f);
c.on_pointer({sx, 200, 0, 0, plot::input::Button::Left, kCtrl, true, true, false});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::MoveCursor);
c.on_pointer({sx + 100, 200, 100, 0, plot::input::Button::Left, kCtrl, true, false, false});
c.on_pointer({sx + 100, 200, 0, 0, plot::input::Button::Left, kCtrl, false, false, true});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::BoxZoom);
c.on_pointer({sx + 100, 260, 100, 60, plot::input::Button::Left, kCtrl, true, false, false});
c.on_pointer({sx + 100, 260, 0, 0, plot::input::Button::Left, kCtrl, false, false, true});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None);
PLOT_CHECK(p.cursor(cid).x > 20.0);
PLOT_CHECK_NEAR(p.cursor(cid).x, p.x_transform(ax).to_data_x(sx + 100), 1e-9);
PLOT_CHECK_NEAR(p.x_range(ax).min, want_lo, 1e-6);
PLOT_CHECK_NEAR(p.x_range(ax).max, want_hi, 1e-6);
PLOT_CHECK_NEAR(p.y_range(ay).min, -1.0, 1e-9); // the masked axis is untouched
PLOT_CHECK_NEAR(p.y_range(ay).max, 1.0, 1e-9);
PLOT_CHECK_NEAR(p.cursor(cid).x, 200.0, 1e-9); // and no cursor was grabbed
}
PLOT_TEST(middle_drag_on_x_strip_zooms_x_only) {
@@ -254,6 +263,27 @@ PLOT_TEST(key_minus_zooms_out) {
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
/// (-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
@@ -391,9 +421,10 @@ PLOT_TEST(grab_radius_scales_with_dpi) {
c.on_pointer({sx + outside, 200.0f * scale, 0, 0, plot::input::Button::Left, 0, false, false,
false});
PLOT_CHECK(c.hovered_cursor() == plot::kInvalid);
// ...and a ctrl-drag at that same logical distance opens a cursor move, which is
// the gesture the radius actually gates.
c.on_pointer({sx + inside, 200.0f * scale, 0, 0, plot::input::Button::Left, kCtrl, true, true,
// ...and a press at that same logical distance opens a cursor move, which is
// the gesture the radius actually gates. (A plain press: Ctrl is the x-only
// box zoom's mask now, not a cursor modifier.)
c.on_pointer({sx + inside, 200.0f * scale, 0, 0, plot::input::Button::Left, 0, true, true,
false});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::MoveCursor);
}
@@ -878,17 +909,9 @@ PLOT_TEST(cursor_is_grabbed_without_a_modifier) {
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::None);
PLOT_CHECK(f.controller.grabbed_cursor() == plot::kInvalid);
PLOT_CHECK(f.plot.hot_handles().grabbed_cursor == plot::kInvalid);
// The same press with ctrl is the same gesture, not a different mode.
const f32 moved_to = column_of(f.plot, f.ax, f.plot.cursor(cid).x);
const f64 min_before = f.plot.x_range(f.ax).min;
press(f.controller, moved_to, 200.0f, kCtrl);
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor);
PLOT_CHECK(f.controller.grabbed_cursor() == cid);
move_to(f.controller, moved_to - 40.0f, 200.0f, kCtrl);
PLOT_CHECK_NEAR(f.plot.cursor(cid).x, data_at(f.plot, f.ax, moved_to - 40.0f), 1e-9);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, min_before, 1e-9); // still no pan
release(f.controller, moved_to - 40.0f, 200.0f, kCtrl);
// The grab is the *plain* press's gesture: Ctrl+Left is the x-only box zoom now
// (see ctrl_drag_box_zooms_x_only_even_over_a_cursor), so there is no modifier
// variant of a cursor grab to pin here.
}
/// A snapping cursor follows the pointer for the whole drag: the drag writes the free
@@ -1216,7 +1239,7 @@ PLOT_TEST(pressing_away_from_a_handle_still_pans) {
const u32 cid = f.plot.add_cursor(20.0, plot::Color::hex(0x00FF00));
const u32 rid = f.plot.add_region(free_region(40.0, 60.0));
for (const u32 mods : {0u, kCtrl}) {
for (const u32 mods : {0u}) { // the plain press: Ctrl+Left is the box zoom now
const f32 px = screen_x_of(f.plot, f.ax, 85.0);
const f64 grabbed = data_at(f.plot, f.ax, px);
const f64 min_before = f.plot.x_range(f.ax).min;
@@ -1786,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
// by a fifth of its width. These two cases pin the fix from the two ends: the gesture keeps
// the edge, and the hint that makes it possible exists exactly while a group follows.
/// A following group's window stays **on the data**: the offset bound is `retained - span`, not
/// `retained`. With the loose bound, scrolling back put the window's *right* edge on the oldest
/// retained sample, which leaves the window's whole span to its left over data the scope never
/// had -- an empty margin as wide as the view, reached by a pan or by a zoom-in near that edge.
/// That is the "sometimes zooming in, the left or right margin becomes empty" report, and it is
/// why the bound has to be about the window and not about one edge of it.
PLOT_TEST(a_following_group_keeps_its_whole_window_on_the_data) {
plot::data::StreamSeries series(1u << 12, 0.0, 1.0 / 1000.0);
plot::f32 samples[512];
for (u32 i = 0; i < 512u; ++i) samples[i] = static_cast<plot::f32>(i % 7u);
series.push(samples, 512u);
series.acquire();
plot::Figure figure(1u);
figure.set_viewport({0.0f, 0.0f, 800.0f, 600.0f});
figure.plot(0u).add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
figure.plot(0u).add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
figure.plot(0u).add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, 0u, 0u, "s"});
const u32 group = figure.add_x_link();
PLOT_CHECK(figure.link_x(0u, group));
PLOT_CHECK(figure.set_x_link_range(group, {0.0, 0.25}));
PLOT_CHECK(figure.set_x_link_follow(group, plot::XFollow::Live));
figure.layout();
const plot::Range data = series.x_bounds();
const f64 span = figure.x_link_range(group).span();
PLOT_CHECK(span > 0.0);
// Ask to scroll far past the oldest sample, the way a drag or a zoom-in near the left edge does.
PLOT_CHECK(figure.set_x_link_range(group, {data.min - 10.0 * span, data.min - 9.0 * span}));
figure.layout();
const plot::Range after = figure.x_link_range(group);
std::printf("[follow-bound] data=[%.4f..%.4f] span=%.4f after=[%.4f..%.4f]\n", data.min,
data.max, span, after.min, after.max);
// The window is still a window, and its *left* edge is on the data: that is the assertion the
// loose bound failed, where `after.min` came out a whole span below `data.min`.
PLOT_CHECK_NEAR(after.span(), span, 1e-9 * span);
PLOT_CHECK(after.min >= data.min - 1e-9);
PLOT_CHECK(after.max <= data.max + 1e-9);
}
PLOT_TEST(a_wheel_on_a_following_figures_axis_strip_keeps_the_live_edge) {
constexpr u32 kFigureRows = 3u;
constexpr f64 kWheelDx = 1.0 / 1000.0;
@@ -1981,3 +2045,361 @@ PLOT_TEST(crosshair_tracks_motion_and_clears_on_leave) {
PLOT_CHECK(fx.plot.crosshair().active);
PLOT_CHECK_NEAR(fx.plot.crosshair().x, fx.plot.x_transform(0u).to_data_x(far_x), 1e-9);
}
// --- hardening wave: B5 zero-notch wheel and dx pan, and the new gestures ---
namespace {
/// A plot with an x and a y axis, both auto-scaled, so every "did the gesture
/// disarm the fit" question has an armed state to start from.
struct ArmedFixture {
plot::Plot plot;
u32 ax = 0;
u32 ay = 0;
plot::input::Controller controller{plot};
ArmedFixture() {
plot.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
ax = plot.add_x_axis({"t", {0.0, 100.0}, true, plot::AxisPlace::Bottom, false});
ay = plot.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
controller.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
}
};
} // namespace
/// B5: a zero-notch wheel (`dy == 0 && dx == 0`, which hosts and trackpads really
/// forward) changes nothing at all -- most importantly it does not run the range
/// setters, which clear `auto_scale`, so one empty scroll must not disarm
/// auto-fit.
PLOT_TEST(zero_notch_wheel_disarms_nothing) {
ArmedFixture f;
f.controller.on_wheel({400.0f, 200.0f, 0.0f, 0.0f, 0});
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale);
PLOT_CHECK(f.plot.y_axis(f.ay).auto_scale);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 0.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 100.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -1.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.0, 1e-9);
}
/// B5: `dy == 0` with `dx != 0` pans x only -- 5% of the x span per notch, in axis
/// space (log-aware), positive `dx` scrolling right -- and leaves the y axis,
/// including its armed auto scale, exactly as it was.
PLOT_TEST(horizontal_wheel_pans_x_only) {
ArmedFixture f;
f.controller.on_wheel({400.0f, 200.0f, 2.0f, 0.0f, 0}); // two notches right
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 10.0, 1e-9); // 2 * 5% of 100
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 110.0, 1e-9);
PLOT_CHECK(!f.plot.x_axis(f.ax).auto_scale); // a pan is a user choice
PLOT_CHECK(f.plot.y_axis(f.ay).auto_scale); // the untouched axis keeps its fit
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -1.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.0, 1e-9);
// Log-aware: the pan runs in axis space, so one notch on a log axis is a
// fraction of a *decade*, not of a linear width.
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 lg = p.add_x_axis({"f", {1.0, 100.0}, true, plot::AxisPlace::Bottom, true});
plot::input::Controller c(p);
c.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
c.on_wheel({400.0f, 200.0f, 1.0f, 0.0f, 0});
const plot::Range r = p.x_range(lg);
PLOT_CHECK_NEAR(std::log10(r.max) - std::log10(r.min), 2.0, 1e-9); // decades kept
PLOT_CHECK_NEAR(std::log10(r.max), 2.1, 1e-9); // one notch = 5% of the 2-decade span
}
/// B5: a zoom whose factor could not be applied honestly -- non-finite (a huge
/// downward `dy` explodes `exp`) -- is refused, and leaves the ranges *and* the
/// armed auto scale exactly as they were.
PLOT_TEST(wheel_zoom_refuses_a_non_finite_factor) {
ArmedFixture f;
f.controller.on_wheel({400.0f, 200.0f, 0.0f, -1e38f, 0});
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale);
PLOT_CHECK(f.plot.y_axis(f.ay).auto_scale);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 100.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.0, 1e-9);
}
/// B5: the middle-drag axis zoom carries the same guards. A motion that reports no
/// `dy` is a factor of exactly one -- refused, so it cannot clear `auto_scale` on
/// its way through the range setters -- and one that explodes the factor is
/// refused too.
PLOT_TEST(axis_zoom_refuses_degenerate_factors) {
ArmedFixture f;
const plot::PlotLayout& l = f.plot.layout();
const f32 strip_x = l.axes.x + 0.5f * l.axes.w;
const f32 strip_y = l.x_label_area.y + 0.5f * l.x_label_area.h;
f.controller.on_pointer({strip_x, strip_y, 0, 0, plot::input::Button::Middle, 0, true, true,
false});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::AxisZoomX);
// A stationary motion: no zoom, and the fits stay armed.
f.controller.on_pointer({strip_x, strip_y, 0, 0, plot::input::Button::Middle, 0, true, false,
false});
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), 100.0, 1e-9);
// An exploding motion: refused, nothing stored, nothing disarmed.
f.controller.on_pointer({strip_x, strip_y, 0, 1e38f, plot::input::Button::Middle, 0, true,
false, false});
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), 100.0, 1e-9);
f.controller.on_pointer({strip_x, strip_y, 0, 0, plot::input::Button::Middle, 0, false, false,
true});
}
/// The arrow keys pan by axis class: Left/Right every x axis, Up/Down every y
/// axis, 10% of that axis' own span per press, in axis space (log-aware). An arrow
/// says where to look: Left walks the window toward smaller x, Up toward larger y.
PLOT_TEST(arrow_keys_pan_a_tenth_of_the_span) {
ArmedFixture f;
f.plot.set_x_auto_scale(f.ax, false);
f.controller.on_key({plot::input::kKeyRight, 0, true});
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 10.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 110.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -1.0, 1e-9); // x-only
f.controller.on_key({plot::input::kKeyLeft, 0, true});
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 0.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 100.0, 1e-9);
f.controller.on_key({plot::input::kKeyUp, 0, true});
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -0.8, 1e-9); // +10% of the 2 span
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.2, 1e-9);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 0.0, 1e-9); // y-only
f.controller.on_key({plot::input::kKeyDown, 0, true});
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -1.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.0, 1e-9);
// Log-aware: an arrow on a log axis moves decades, not units.
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 lg = p.add_x_axis({"f", {1.0, 100.0}, false, plot::AxisPlace::Bottom, true});
p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
plot::input::Controller c(p);
c.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
c.on_key({plot::input::kKeyRight, 0, true});
const plot::Range r = p.x_range(lg);
PLOT_CHECK_NEAR(std::log10(r.max) - std::log10(r.min), 2.0, 1e-9);
PLOT_CHECK_NEAR(std::log10(r.max), 2.2, 1e-9); // +10% of the 2-decade span
// A key with no gesture changes nothing.
c.on_key({'q', 0, true});
PLOT_CHECK_NEAR(std::log10(p.x_range(lg).max), 2.2, 1e-9);
}
/// Escape cancels the active drag: the ranges (and auto-scale flags) the axes held
/// at `begin_drag` are restored, and the drag is closed -- the release that
/// follows applies nothing.
PLOT_TEST(escape_cancels_a_pan_and_restores_the_captured_ranges) {
ArmedFixture f;
const f64 x_min = f.plot.x_range(f.ax).min;
const f64 x_max = f.plot.x_range(f.ax).max;
const f64 y_min = f.plot.y_range(f.ay).min;
const f64 y_max = f.plot.y_range(f.ay).max;
f.controller.on_pointer({100.0f, 100.0f, 0, 0, plot::input::Button::Left, 0, true, true,
false});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::Pan);
f.controller.on_pointer({300.0f, 200.0f, 200.0f, 100.0f, plot::input::Button::Left, 0, true,
false, false});
PLOT_CHECK(f.plot.x_range(f.ax).min < x_min); // the gesture did move things
PLOT_CHECK(!f.plot.x_axis(f.ax).auto_scale);
f.controller.on_key({plot::input::kKeyEscape, 0, true});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::None);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, x_min, 1e-9);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, x_max, 1e-9);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, y_min, 1e-9);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, y_max, 1e-9);
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale); // the fit is armed again
PLOT_CHECK(f.plot.y_axis(f.ay).auto_scale);
// The release arrives after the cancel; there is no gesture left to end.
f.controller.on_pointer({300.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
true});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::None);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, x_min, 1e-9);
}
/// Escape drops a box zoom without applying it: the selection is discarded and the
/// ranges the axes held when the drag began are still there at the end.
PLOT_TEST(escape_drops_a_box_zoom_without_applying) {
ArmedFixture f;
const f64 x_max = f.plot.x_range(f.ax).max;
f.controller.on_pointer({100.0f, 50.0f, 0, 0, plot::input::Button::Left, kShift, true, true,
false});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::BoxZoom);
f.controller.on_pointer({300.0f, 150.0f, 200.0f, 100.0f, plot::input::Button::Left, kShift,
true, false, false});
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale); // nothing applies before the release
f.controller.on_key({plot::input::kKeyEscape, 0, true});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::None);
PLOT_CHECK_NEAR(f.controller.selection_rect().w, 0.0, 1e-9); // the box is gone
f.controller.on_pointer({300.0f, 150.0f, 0, 0, plot::input::Button::Left, kShift, false,
false, true});
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, x_max, 1e-9); // and nothing was framed
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale);
}
/// The box-zoom masks: Alt+Left frames the y range only -- from a selection too
/// thin horizontally to be a two-axis box, which is what makes the threshold
/// masked with the gesture.
PLOT_TEST(alt_drag_box_zooms_y_only) {
ArmedFixture f;
constexpr u32 kAlt = static_cast<u32>(plot::input::Mod::Alt);
const plot::Rect axes = f.plot.layout().axes;
const f64 want_lo = f.plot.y_transform(f.ay).to_data_y(axes.y + 150.0f);
const f64 want_hi = f.plot.y_transform(f.ay).to_data_y(axes.y + 50.0f);
// Two logical px wide: an unmasked box would call this a click.
f.controller.on_pointer({200.0f, axes.y + 50.0f, 0, 0, plot::input::Button::Left, kAlt, true,
true, false});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::BoxZoom);
f.controller.on_pointer({202.0f, axes.y + 150.0f, 2, 100, plot::input::Button::Left, kAlt,
true, false, false});
f.controller.on_pointer({202.0f, axes.y + 150.0f, 0, 0, plot::input::Button::Left, kAlt,
false, false, true});
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, want_lo, 1e-6);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, want_hi, 1e-6);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 0.0, 1e-9); // x untouched
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 100.0, 1e-9);
}
/// The Ctrl mask, applied at release from a selection that only makes sense
/// horizontally: the x range is framed from the box while the y range is left
/// alone, and a selection thin *vertically* is still a box under the mask.
PLOT_TEST(ctrl_mask_frames_x_from_a_thin_horizontal_band) {
ArmedFixture f;
const plot::Rect axes = f.plot.layout().axes;
const f64 want_lo = f.plot.x_transform(f.ax).to_data_x(axes.x + 100.0f);
const f64 want_hi = f.plot.x_transform(f.ax).to_data_x(axes.x + 300.0f);
f.controller.on_pointer({axes.x + 100.0f, 200.0f, 0, 0, plot::input::Button::Left, kCtrl,
true, true, false});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::BoxZoom);
// Two logical px tall: an unmasked box would call this a click.
f.controller.on_pointer({axes.x + 300.0f, 202.0f, 200, 2, plot::input::Button::Left, kCtrl,
true, false, false});
f.controller.on_pointer({axes.x + 300.0f, 202.0f, 0, 0, plot::input::Button::Left, kCtrl,
false, false, true});
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, want_lo, 1e-6);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, want_hi, 1e-6);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -1.0, 1e-9); // y untouched
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.0, 1e-9);
}
/// The double-click reset: two presses of the same button within 0.35 s and 4 px
/// (on `PointerEvent::time_s`) re-fit every axis -- auto scale re-armed with the
/// fit -- and the second press begins no drag.
PLOT_TEST(double_click_refits_every_axis_and_starts_no_drag) {
// A plot that has something to fit: the reset re-fits every axis to its traces,
// so this fixture carries one (the ArmedFixture above is deliberately empty --
// its tests are about auto-scale *state*, and an axis with no trace has nothing
// to fit). The axes start disarmed so the initial layout cannot pre-fit them.
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ax = p.add_x_axis({"t", {5.0, 60.0}, false, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {-0.5, 0.5}, false, plot::AxisPlace::Left, false});
std::vector<f32> ys(101);
for (u64 i = 0; i <= 100u; ++i) ys[static_cast<std::size_t>(i)] = (i % 2u == 0u) ? -1.0f : 1.0f;
plot::data::ArraySeries series(std::move(ys), 0.0, 1.0); // x = 0..100, y in [-1, 1]
p.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, ax, ay});
plot::input::Controller c(p);
c.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
// Zoom in first, so the window is somewhere a reset has to leave.
c.on_wheel({400.0f, 200.0f, 0.0f, 1.0f, 0});
PLOT_CHECK(p.x_range(ax).span() < 60.0);
PLOT_CHECK(p.y_range(ay).span() < 1.0);
c.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true, false,
/*time_s=*/1.0});
c.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false, true, 1.0});
c.on_pointer({402.0f, 201.0f, 2, 1, plot::input::Button::Left, 0, true, true, false,
1.2}); // 0.2 s and ~2.2 px later
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None); // no drag began
PLOT_CHECK(p.x_axis(ax).auto_scale); // the reset fit and re-armed
PLOT_CHECK(p.y_axis(ay).auto_scale);
// Both axes frame their traces' bounds (the 2% pad keeps the data inside them),
// which is what a fit means here.
PLOT_CHECK(p.x_range(ax).min < 0.0);
PLOT_CHECK(p.x_range(ax).max > 100.0);
PLOT_CHECK(p.y_range(ay).min < -1.0);
PLOT_CHECK(p.y_range(ay).max > 1.0);
// And the re-armed fit survives a layout: the frame the host draws next does not
// move the window the reset just chose.
const plot::Range xr = p.x_range(ax);
p.layout();
PLOT_CHECK_NEAR(p.x_range(ax).min, xr.min, 1e-9);
PLOT_CHECK_NEAR(p.x_range(ax).max, xr.max, 1e-9);
// A third press starts a fresh gesture (a pan), not a second reset.
c.on_pointer({402.0f, 201.0f, 0, 0, plot::input::Button::Left, 0, true, true, false, 1.4});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::Pan);
c.on_pointer({402.0f, 201.0f, 0, 0, plot::input::Button::Left, 0, false, false, true, 1.4});
}
/// A double-click needs the host's clock: presses that carry `time_s == 0` are
/// never one, whatever their spacing, and presses outside the window (too slow or
/// too far apart) are two ordinary gestures -- the second one a drag.
PLOT_TEST(double_click_needs_both_timestamps_and_the_window) {
ArmedFixture f;
f.plot.set_x_auto_scale(f.ax, false);
// No timestamps: two presses at the same spot do not reset.
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true,
false, 0.0});
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
true, 0.0});
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true,
false, 0.0});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::Pan);
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
true, 0.0});
// Too slow: 0.4 s apart is two gestures.
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true,
false, 10.0});
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
true, 10.0});
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true,
false, 10.4});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::Pan);
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
true, 10.4});
// Too far: 10 px apart is two gestures.
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true,
false, 20.0});
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
true, 20.0});
f.controller.on_pointer({410.0f, 200.0f, 10, 0, plot::input::Button::Left, 0, true, true,
false, 20.1});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::Pan);
f.controller.on_pointer({410.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
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);
}
+142 -8
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
/// 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.
@@ -786,14 +832,99 @@ PLOT_TEST(a_following_group_advances_all_rows_together) {
}
}
/// B4, on a following *log* group: the group's span and offset live in the shared
/// axis' axis space -- decades -- exactly as a single `Live` axis' do, so the
/// window slides with the newest sample without rescaling. The visible decades
/// stay what was asked for on every row, and a member panned mid-follow restates
/// its scroll state in the same decades rather than in a linear width that would
/// decay as the edge moved.
PLOT_TEST(a_following_log_group_holds_decades) {
plot::data::StreamSeries left(1024u, 1.0, 10.0); // samples at x = 1, 11, 21, ...
plot::data::StreamSeries right(1024u, 1.0, 10.0);
for (int i = 0; i < 30; ++i) left.push(0.5f);
for (int i = 0; i < 20; ++i) right.push(0.5f);
left.acquire();
right.acquire();
plot::Figure figure(2u);
figure.set_viewport(whole_figure());
for (plot::u32 row = 0; row < 2u; ++row) {
plot::Plot& p = figure.plot(row);
p.add_x_axis({"t", {1.0, 1000.0}, false, plot::AxisPlace::Bottom, /*log=*/true});
p.add_y_axis({"v", {0.0, 1.0}, false, plot::AxisPlace::Left, false});
}
figure.plot(0u).add_trace({&left, plot::Color::hex(0x4FA3FF), 1.5f, 0u, 0u});
figure.plot(1u).add_trace({&right, plot::Color::hex(0xFF8080), 1.5f, 0u, 0u});
const plot::u32 group = figure.add_x_link();
PLOT_CHECK(figure.link_x(0u, group));
PLOT_CHECK(figure.link_x(1u, group));
PLOT_CHECK(figure.set_x_link_follow(group, plot::XFollow::Live));
const auto decades_of = [](const plot::Plot& p) {
const plot::Range r = p.x_range(0u);
return std::log10(r.max) - std::log10(r.min);
};
// One decade wide, ending at the fastest row's edge.
const plot::f64 edge0 = left.x_bounds().max;
PLOT_CHECK(figure.set_x_link_range(group, {edge0 / 10.0, edge0}));
figure.layout();
PLOT_CHECK_NEAR(decades_of(figure.plot(0u)), 1.0, 1e-9);
PLOT_CHECK_NEAR(decades_of(figure.plot(1u)), 1.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).max, edge0, 1e-8);
// The streams advance at different rates; the window follows the group's edge --
// the faster row's -- and holds its decade on every row.
for (int step = 1; step <= 8; ++step) {
for (int i = 0; i < 3; ++i) left.push(0.5f);
for (int i = 0; i < 1; ++i) right.push(0.5f);
left.acquire();
right.acquire();
figure.layout();
PLOT_CHECK_NEAR(decades_of(figure.plot(0u)), 1.0, 1e-9);
PLOT_CHECK_NEAR(decades_of(figure.plot(1u)), 1.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).max, left.x_bounds().max, 1e-8);
PLOT_CHECK_NEAR(figure.plot(1u).x_range(0u).max, left.x_bounds().max, 1e-8);
}
// A member panned while the group follows: the adopted scroll state is the
// adopted range's span *in decades* -- two here (edge/1000 to edge/10 is a
// factor of 100) -- and the offset as far behind the edge as the retained data
// allows, both of which survive the next frames' advances.
//
// The pan *asks* for a full decade back, and the group may not take it: the offset is bounded by
// `retained - span`, so the window stops with its left edge on the oldest sample. Asserting
// `max == edge/10` here was asserting the *old* bound, which put a whole span of the window over
// data the members never held -- the empty-left-margin defect this bound exists for. The
// log-space assertions above are this test's subject and are unchanged.
const plot::f64 edge = left.x_bounds().max;
figure.plot(1u).set_x_range(0u, {edge / 1000.0, edge / 10.0});
figure.layout();
PLOT_CHECK_NEAR(decades_of(figure.plot(0u)), 2.0, 1e-9);
PLOT_CHECK_NEAR(decades_of(figure.plot(1u)), 2.0, 1e-9);
const plot::f64 oldest = std::min(left.x_bounds().min, right.x_bounds().min);
PLOT_CHECK(figure.plot(0u).x_range(0u).min >= oldest - 1e-6);
PLOT_CHECK(figure.plot(0u).x_range(0u).max <= edge + 1e-6);
for (int step = 0; step < 3; ++step) {
left.push(0.5f);
left.acquire();
figure.layout();
PLOT_CHECK_NEAR(decades_of(figure.plot(0u)), 2.0, 1e-9);
PLOT_CHECK_NEAR(decades_of(figure.plot(1u)), 2.0, 1e-9);
PLOT_CHECK(figure.plot(0u).x_range(0u).min >= oldest - 1e-6);
}
PLOT_CHECK(figure.x_link_follow(group) == plot::XFollow::Live);
}
// ------------------------------------------------- the crosshair (spec §15)
namespace {
/// 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
/// sample at different rates are the point -- one x, three different interpolations -- and the
/// last row is the case a readout must not invent a value for.
/// sample at different rates are the point -- one x, and each row reporting a sample off its own
/// grid -- and the last row is the case a readout must not invent a value for.
plot::Figure crosshair_figure() {
plot::Figure figure(4u);
figure.set_viewport(whole_figure());
@@ -821,7 +952,7 @@ plot::Figure crosshair_figure() {
/// 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
/// 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
/// reports no value at all instead of a fabricated one.
PLOT_TEST(figure_crosshair_applies_one_x_to_every_row) {
@@ -845,14 +976,15 @@ PLOT_TEST(figure_crosshair_applies_one_x_to_every_row) {
PLOT_CHECK(first.rows[0].label == "fast");
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
// (y = 200), so it reports 150 -- a different number for the same x, which is what makes this
// case about the rows rather than about one series read four times.
// Row 1 samples every *two* units: x = 3 is halfway between sample 1 (x = 2, y = 100) and
// sample 2 (x = 4, y = 200), which is the tie, so it reports sample 1's 100 -- a different
// 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;
PLOT_CHECK(second.rows.size() == 1u);
PLOT_CHECK(second.rows[0].valid);
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);
// Row 2's trace has no series yet: no row at all -- it is not a signal under the pointer.
@@ -887,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(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);
// On *every* row at once, and only the rows free it.
+9 -3
View File
@@ -494,13 +494,16 @@ PLOT_TEST(render_programs_are_inert_until_created) {
line.set_offset({0.0f, 0.0f});
line.set_viewport({256.0f, 256.0f});
line.set_width(2.0f);
// The AA fringe is a uniform now, not a hardcoded pixel: 1.0 is the fringe the
// renderer's own flush sets by default, and the same value the pixel bounds
// below were derived from.
line.set_aa(1.0f);
line.set_color(plot::Color::hex(0xFFFFFF));
text.bind();
text.set_viewport({256.0f, 256.0f});
text.set_px_range({8.0f, 8.0f});
text.set_glyph_uv({1.0f / 16.0f, 1.0f / 7.0f});
text.set_texture_size({128.0f, 56.0f});
text.set_color(plot::Color::hex(0xFFFFFF));
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
ctx.destroy();
}
@@ -541,6 +544,10 @@ PLOT_TEST(line_pipeline_draws_an_aa_strip) {
program.set_offset({64.0f, 100.0f});
program.set_viewport({static_cast<f32>(ctx.width), static_cast<f32>(ctx.height)});
program.set_width(3.0f);
// The AA fringe is a uniform now, not a hardcoded pixel: 1.0 is the fringe the
// renderer's own flush sets by default, and the value the pixel bounds below
// were derived from.
program.set_aa(1.0f);
program.set_color(plot::Color::hex(0xFF00FF));
lines.bind();
plot::gl::glDrawArraysInstanced(plot::gl::GL_TRIANGLE_STRIP, 0, 4, 3);
@@ -619,7 +626,7 @@ PLOT_TEST(text_pipeline_draws_a_glyph) {
// an identity.
batch.add_text("A", {64.0f, 100.0f}, 1.0f, font);
PLOT_CHECK(batch.glyph_count() == 1);
PLOT_CHECK(batch.data().size() == 6);
PLOT_CHECK(batch.data().size() == 10);
glyphs.upload(batch.data().data(), batch.glyph_count());
plot::gl::glViewport(0, 0, static_cast<plot::gl::GLsizei>(ctx.width),
@@ -640,7 +647,6 @@ PLOT_TEST(text_pipeline_draws_a_glyph) {
// White, so the blended pixel value *is* the coverage byte: the shader emits
// `vec4(rgb, a * coverage)`, and a white tint over the black clear lands at
// `1.0 * coverage` in every channel.
program.set_color(plot::Color::hex(0xFFFFFF));
atlas.bind();
glyphs.bind();
plot::gl::glDrawArraysInstanced(plot::gl::GL_TRIANGLE_STRIP, 0, 4, 1);
+127
View File
@@ -168,6 +168,133 @@ PLOT_TEST(a_drag_that_crosses_rows_is_ended_by_the_release_in_the_other_row) {
PLOT_CHECK(row0.drag_mode() == plot::input::DragMode::None);
}
// --- a second button does not steal or end the captured row -----------------
namespace {
/// A two-row figure handed to a host: the fixture for the capture's *button*. No
/// region this time -- what the test observes is which controller a press lands
/// on, and a pan is the easiest gesture to watch a plot's range move with.
struct TwoRows {
TwoRows() : figure(2u) {
figure.set_viewport({0.0f, 0.0f, 300.0f, 600.0f});
for (plot::u32 row = 0; row < 2u; ++row) {
plot::Plot& p = figure.plot(row);
ax[row] = p.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
}
host.set_figure(&figure);
figure.layout();
}
plot::f32 row_y(plot::u32 row) const {
const plot::Rect r = figure.layout().rows[row];
return r.y + 0.5f * r.h;
}
plot::Figure figure;
plot::glfw::Host host;
plot::u32 ax[2] = {0u, 0u};
};
} // namespace
/// The finding (R5): the capture remembered only the *row*, so a second button
/// pressed while a drag ran stole nothing but its release still ended the capture
/// -- `pointer_target()` cleared `captured_` on **any** release. A chord (or a
/// trackpad that reports a second finger as a button) therefore stranded the first
/// button's gesture in its row: the next event of the *first* button was routed by
/// the pointer's position again, to a controller that never saw the gesture begin.
/// The capture now records the button as well, and only that button's release ends
/// it.
///
/// What the test observes is purely the host's routing -- which controller a fresh
/// press reaches -- and not the controller's drag modes, which a second button's
/// press/release pair legitimately rewrites: press-and-move pairs pan whichever
/// controller they are delivered to, so "whose range moved" says who got the events.
PLOT_TEST(a_second_button_neither_steals_nor_ends_the_captured_row) {
TwoRows r;
plot::input::Controller& row1 = *r.host.row_controller(1u);
const plot::f32 x = 150.0f;
// Left press in row 0: the capture is row 0's.
r.host.feed_pointer_event({x, r.row_y(0u), 0.0f, 0.0f, plot::input::Button::Left, 0u, true,
true, false});
// Right press in row 1 while the left gesture runs: the single-gesture policy --
// the capture is not stolen, so this press reaches row 0's controller too, and
// row 1's controller sees nothing at all.
r.host.feed_pointer_event({x, r.row_y(1u), 0.0f, 0.0f, plot::input::Button::Right, 0u, true,
true, false});
PLOT_CHECK(row1.drag_mode() == plot::input::DragMode::None);
// The right release must not end the left button's capture (the finding above):
// only the owning button's release does.
r.host.feed_pointer_event({x, r.row_y(1u), 0.0f, 0.0f, plot::input::Button::Right, 0u, false,
false, true});
// The proof that the capture survived: a fresh LEFT press landing physically in
// row 1 is still routed to the captured row 0...
const plot::Range row0_before = r.figure.plot(0u).x_range(r.ax[0]);
const plot::Range row1_before = r.figure.plot(1u).x_range(r.ax[1]);
r.host.feed_pointer_event({x, r.row_y(1u), 0.0f, 0.0f, plot::input::Button::Left, 0u, true,
true, false});
r.host.feed_pointer_event({x + 40.0f, r.row_y(1u), 40.0f, 0.0f, plot::input::Button::Left, 0u,
true, false, false});
// ... so the drag it begins pans row 0 and leaves row 1 alone. (With the capture
// gone, this press-and-move belongs to row 1 and the two assertions swap.)
PLOT_CHECK(std::abs(r.figure.plot(0u).x_range(r.ax[0]).min - row0_before.min) > 1e-6);
PLOT_CHECK(std::abs(r.figure.plot(1u).x_range(r.ax[1]).min - row1_before.min) < 1e-9);
// The owning button's release ends the capture, wherever it lands...
r.host.feed_pointer_event({x + 40.0f, r.row_y(1u), 0.0f, 0.0f, plot::input::Button::Left, 0u,
false, false, true});
// ... and the next press belongs to the row it lands in again: a fresh press in
// row 1 now pans row 1, not row 0.
const plot::Range row0_after = r.figure.plot(0u).x_range(r.ax[0]);
r.host.feed_pointer_event({x, r.row_y(1u), 0.0f, 0.0f, plot::input::Button::Left, 0u, true,
true, false});
r.host.feed_pointer_event({x + 40.0f, r.row_y(1u), 40.0f, 0.0f, plot::input::Button::Left, 0u,
true, false, false});
PLOT_CHECK(std::abs(r.figure.plot(1u).x_range(r.ax[1]).min - row1_before.min) > 1e-6);
PLOT_CHECK(std::abs(r.figure.plot(0u).x_range(r.ax[0]).min - row0_after.min) < 1e-9);
r.host.feed_pointer_event({x + 40.0f, r.row_y(1u), 0.0f, 0.0f, plot::input::Button::Left, 0u,
false, false, true});
}
// --- the row-rect cache ------------------------------------------------------
/// The finding (R7): `controller_at()` and `pointer_target()` read
/// `Figure::layout()` *per event*, and a non-const `layout()` recomputes -- every
/// pointer move on a multi-row figure re-laid every row out (ticks, grid, chrome
/// and all), twice for the readers that share a handler. The host now keeps the
/// rects of the last `refresh_row_rects()` and every handler reads that cache: the
/// run loop refreshes it once per frame, `set_figure()` fills it immediately (so a
/// scripted run routes against the figure it was just handed), and in between the
/// rows the handlers answer from are the rows the last frame drew -- at most one
/// frame stale across a resize, never a second layout.
PLOT_TEST(row_routing_reads_the_cache_until_it_is_refreshed) {
Routed r(3u);
const plot::Rect row1_before = r.figure.layout().rows[1];
const plot::f32 y = row1_before.y + 0.5f * row1_before.h;
PLOT_CHECK(&r.host.controller_at(150.0f, y) == r.host.row_controller(1u));
// Re-shape the figure without telling the host: the geometry the *next* frame
// would draw. The cache still holds the 600-pixel-tall rows, and the handler
// must keep answering from them -- with the old per-event layout the same pixel
// already answered for the new geometry (row 2), which is exactly what made
// every event pay for a relayout.
r.figure.set_viewport({0.0f, 0.0f, 300.0f, 300.0f});
r.figure.layout();
PLOT_CHECK(&r.host.controller_at(150.0f, y) == r.host.row_controller(1u));
// The scripted-run equivalent of the frame: the loop's one refresh per frame.
// From here the handlers answer with the new rects, and the pixel -- the old
// row 1's centre, now the bottom edge of a 300-pixel figure -- is row 2's.
r.host.refresh_row_rects();
PLOT_CHECK(&r.host.controller_at(150.0f, y) == r.host.row_controller(2u));
}
PLOT_TEST(the_host_keeps_its_own_controller_while_no_figure_is_set) {
plot::glfw::Host host;
PLOT_CHECK(host.figure() == nullptr);
+105
View File
@@ -26,7 +26,9 @@ using plot::u32;
using plot::gtk4::DeviceMapping;
using plot::gtk4::DragAnchor;
using plot::gtk4::ScrollUnit;
using plot::gtk4::ZoomAnchor;
using plot::gtk4::button_from;
using plot::gtk4::key_from;
using plot::gtk4::kSurfacePixelsPerNotch;
using plot::gtk4::mods_from;
using plot::gtk4::scroll_notches;
@@ -212,3 +214,106 @@ PLOT_TEST(gtk_buttons_map_to_the_controllers_three_or_to_nothing) {
PLOT_CHECK(!button_from(8u).has_value()); // a navigation button
PLOT_CHECK(!button_from(9u).has_value());
}
// --- the pinch --------------------------------------------------------------
// `GtkGestureZoom` reports a cumulative scale since the gesture began, while the
// controller's `GestureEvent` wants this frame's zoom factor; `ZoomAnchor` is the
// fold, and it is the same fold the Qt adapter's `PinchTracker` does -- a
// cumulative 1.0 -> 1.25 -> 1.5 spread is a 1.25 frame then a 1.2 frame, so the
// zoom the plot applies is the zoom the fingers made.
PLOT_TEST(gtk_zoom_anchor_folds_the_cumulative_scale_into_increments) {
// A 2x widget: the focal point and its movement are reported in *device* pixels,
// like every coordinate the controller sees.
const DeviceMapping at_2 = DeviceMapping::from_sizes({400.0f, 300.0f}, {800.0f, 600.0f});
plot::gtk4::ZoomAnchor zoom;
zoom.begin(1.0f, {100.0f, 75.0f});
PLOT_CHECK(zoom.active);
// The begin itself folds to "nothing happened yet", whenever the first
// scale-changed arrives with the scale it began at.
const plot::input::GestureEvent started = zoom.update(1.0f, {100.0f, 75.0f}, at_2);
PLOT_CHECK_NEAR(started.scale, 1.0, 1e-6);
PLOT_CHECK_NEAR(started.dx, 0.0, 1e-6);
PLOT_CHECK_NEAR(started.dy, 0.0, 1e-6);
// Fingers spread to 1.25 about a steady centre: one 1.25 zoom frame, anchored
// at the centre in device pixels (100 * 2, 75 * 2).
const plot::input::GestureEvent spread = zoom.update(1.25f, {100.0f, 75.0f}, at_2);
PLOT_CHECK_NEAR(spread.scale, 1.25, 1e-6);
PLOT_CHECK_NEAR(spread.x, 200.0, 1e-6);
PLOT_CHECK_NEAR(spread.y, 150.0, 1e-6);
PLOT_CHECK_NEAR(spread.dx, 0.0, 1e-6);
PLOT_CHECK_NEAR(spread.dy, 0.0, 1e-6);
// Spread on to 1.5 while the fingers drift 10 logical pixels right: the
// increment is 1.2 and the pan is 20 device pixels.
const plot::input::GestureEvent drifted = zoom.update(1.5f, {110.0f, 75.0f}, at_2);
PLOT_CHECK_NEAR(drifted.scale, 1.2, 1e-6);
PLOT_CHECK_NEAR(drifted.dx, 20.0, 1e-6);
PLOT_CHECK_NEAR(drifted.dy, 0.0, 1e-6);
}
// The focal point is cached in logical widget pixels, so a factor change between
// two updates (the window dragged to another display mid-pinch, GTK re-emitting
// `::resize`) cannot read the ratio change as a focal jump; and an update outside
// a gesture -- before any begin, or a straggler after the end -- zooms and moves
// nothing rather than zooming from a made-up anchor.
PLOT_TEST(gtk_zoom_anchor_stays_logical_and_ignores_stragglers) {
const DeviceMapping at_1 = DeviceMapping::from_sizes({400.0f, 300.0f}, {400.0f, 300.0f});
const DeviceMapping at_125 = DeviceMapping::from_sizes({400.0f, 300.0f}, {500.0f, 375.0f});
plot::gtk4::ZoomAnchor zoom;
// The orphan update, before any begin.
const plot::input::GestureEvent orphan = zoom.update(2.0f, {10.0f, 10.0f}, at_1);
PLOT_CHECK_NEAR(orphan.scale, 1.0, 1e-9);
PLOT_CHECK_NEAR(orphan.dx, 0.0, 1e-9);
PLOT_CHECK_NEAR(orphan.dy, 0.0, 1e-9);
zoom.begin(1.0f, {100.0f, 100.0f});
// Four logical pixels of drift across a 1.0 -> 1.25 factor change: 4 * 1.25 =
// 5 device pixels, and the anchor for the next frame is the new logical point.
const plot::input::GestureEvent rescaled = zoom.update(1.0f, {104.0f, 100.0f}, at_125);
PLOT_CHECK_NEAR(rescaled.scale, 1.0, 1e-6);
PLOT_CHECK_NEAR(rescaled.dx, 5.0, 1e-6);
PLOT_CHECK_NEAR(rescaled.x, 104.0 * 1.25, 1e-6);
// The gesture ends; a straggler scale-changed does nothing.
zoom.end();
PLOT_CHECK(!zoom.active);
const plot::input::GestureEvent straggler = zoom.update(2.0f, {120.0f, 100.0f}, at_125);
PLOT_CHECK_NEAR(straggler.scale, 1.0, 1e-9);
PLOT_CHECK_NEAR(straggler.dx, 0.0, 1e-9);
}
// --- the keyboard -----------------------------------------------------------
// The whole key translation, now that it names arrows and Escape too. The
// function keys are matched on the keyval, which for them is layout- and
// level-independent; the printable keys on the Unicode the keyval decodes to,
// which is what makes `+` and shift+`=` (and the keypad's plus) one key to the
// controller. Anything else is `0` -- the caller hands the event back to GTK.
PLOT_TEST(gtk_keys_map_by_keyval_first_and_unicode_second) {
PLOT_CHECK(key_from(0xFF1Bu, 0u) == plot::input::kKeyEscape); // GDK_KEY_Escape
PLOT_CHECK(key_from(0xFF51u, 0u) == plot::input::kKeyLeft); // GDK_KEY_Left
PLOT_CHECK(key_from(0xFF53u, 0u) == plot::input::kKeyRight); // GDK_KEY_Right
PLOT_CHECK(key_from(0xFF52u, 0u) == plot::input::kKeyUp);
PLOT_CHECK(key_from(0xFF54u, 0u) == plot::input::kKeyDown);
// Printable keys, by code point -- the keyval is irrelevant to this branch.
PLOT_CHECK(key_from(0x002Bu, static_cast<u32>('+')) == plot::input::kKeyZoomIn);
PLOT_CHECK(key_from(0x003Du, static_cast<u32>('=')) == plot::input::kKeyZoomIn);
PLOT_CHECK(key_from(0x002Du, static_cast<u32>('-')) == plot::input::kKeyZoomOut);
PLOT_CHECK(key_from(0x005Fu, static_cast<u32>('_')) == plot::input::kKeyZoomOut);
PLOT_CHECK(key_from(0x0066u, static_cast<u32>('f')) == plot::input::kKeyFit);
PLOT_CHECK(key_from(0x0046u, static_cast<u32>('F')) == plot::input::kKeyFit);
// The keyval wins when both could match: the order is the seam's contract, not
// an accident of GDK's tables.
PLOT_CHECK(key_from(0xFF51u, static_cast<u32>('x')) == plot::input::kKeyLeft);
// And nothing a plot has no gesture for maps to a key: an unmapped keyval with
// an unmapped code point is refused, and so is a bare zero.
PLOT_CHECK(key_from(0x0078u, static_cast<u32>('x')) == 0);
PLOT_CHECK(key_from(0u, 0u) == 0);
}
+118
View File
@@ -352,7 +352,17 @@ PLOT_TEST(imgui_widget_invalid_position_does_not_move_the_range) {
// and not from the sentinel. This is asserted with the drag alive on purpose:
// nothing can move a range once the button is up, so the same check after the
// release could not fail and would prove nothing.
//
// The quiet frame before the second press is aged past the double-click window on
// purpose: this press lands in the same spot within the window of the first one,
// and the widget stamps presses with ImGui's clock, so without the age the two
// presses *are* a double-click -- the controller re-fits and begins no drag, which
// is the gesture the next test pins, not the sentinel behaviour this one is here
// for.
ImGuiIO& io = ImGui::GetIO();
io.DeltaTime = 0.4f;
f.frame(ImVec2(200.0f, 200.0f), false);
io.DeltaTime = 1.0f / 60.0f;
f.frame(ImVec2(200.0f, 200.0f), true);
const plot::Range second_press = f.x_range();
f.frame(ImVec2(-kMaxFloat, -kMaxFloat), true);
@@ -492,3 +502,111 @@ PLOT_TEST(imgui_widget_states_the_display_scale_to_the_renderer) {
PLOT_CHECK_NEAR(f.renderer.axis_font_px(), cell_1x, 1e-3);
PLOT_CHECK_NEAR(f.plot.layout().y_axis_width, strip_1x, 1e-3);
}
// --- keys -------------------------------------------------------------------
// A held key repeats. This is the adapter's own policy decision, not ImGui's
// default: ImGui edge-detects a press once, and `IsKeyPressed` only re-fires
// while the key is held when it is asked with repeat = true -- which the widget
// now does for every key it maps, because GLFW/Qt/GTK each deliver the OS's
// auto-repeat as extra presses and a held `+` or arrow must behave the same here
// (docs/embedding.md's shared policy). Held with `repeat = false`, the zoom
// happens exactly once; with `repeat = true`, once per repeat ImGui's timer
// fires, so the span keeps growing for as long as the key is down.
PLOT_TEST(imgui_widget_repeats_a_held_key) {
Fixture f;
PLOT_CHECK(f.init());
ImGuiIO& io = ImGui::GetIO();
// A repeat clock a test can afford: first repeat one frame after the press,
// then one per frame, each frame a twentieth of a second of ImGui time.
io.DeltaTime = 0.05f;
io.KeyRepeatDelay = 0.05f;
io.KeyRepeatRate = 0.05f;
// Hover the plot (the keys are gated on hover) and press `-` once.
f.frame(ImVec2(200.0f, 200.0f), false);
io.AddKeyEvent(ImGuiKey_Minus, true);
f.frame(ImVec2(200.0f, 200.0f), false);
const double span_after_press = f.x_range().span();
PLOT_CHECK(span_after_press > 20.0); // one zoom-out detent on the {0, 20} fixture
// Hold it for half a second of ImGui time. Each repeat zooms again, so the
// span ends up several detents past the single press -- a single-fire widget
// would leave it exactly where the press left it.
for (int i = 0; i < 10; ++i) f.frame(ImVec2(200.0f, 200.0f), false);
PLOT_CHECK(f.x_range().span() > span_after_press * 1.5);
io.AddKeyEvent(ImGuiKey_Minus, false);
f.frame(ImVec2(200.0f, 200.0f), false);
}
// The arrows pan and Escape cancels a drag, mapped to the controller's
// host-translated constants (`kKeyLeft`, `kKeyEscape`): one press of Left moves
// every x axis 10% of its span, and Escape hands the axes back the ranges they
// had when the drag began.
PLOT_TEST(imgui_widget_maps_arrows_and_escape) {
Fixture f;
PLOT_CHECK(f.init());
ImGuiIO& io = ImGui::GetIO();
f.frame(ImVec2(200.0f, 200.0f), false);
const double span = f.x_range().span();
const double min_before = f.x_range().min;
// One Left press: every x axis slides by 10% of its span, and the span itself
// is untouched (a pan, not a zoom).
io.AddKeyEvent(ImGuiKey_LeftArrow, true);
f.frame(ImVec2(200.0f, 200.0f), false);
io.AddKeyEvent(ImGuiKey_LeftArrow, false);
f.frame(ImVec2(200.0f, 200.0f), false);
PLOT_CHECK_NEAR(f.x_range().span(), span, 1e-9);
PLOT_CHECK(std::abs(f.x_range().min - min_before) > 0.05 * span);
PLOT_CHECK(std::abs(f.x_range().min - min_before) < 0.15 * span);
// A drag that has panned, then Escape while the button is still down: the
// ranges go back to where they were when the drag began, and the drag is over.
const double before_drag = f.x_range().min;
f.frame(ImVec2(200.0f, 200.0f), true);
PLOT_CHECK(f.panning());
f.frame(ImVec2(260.0f, 200.0f), true);
PLOT_CHECK(std::abs(f.x_range().min - before_drag) > 1e-6); // the pan moved it
io.AddKeyEvent(ImGuiKey_Escape, true);
f.frame(ImVec2(260.0f, 200.0f), true);
io.AddKeyEvent(ImGuiKey_Escape, false);
PLOT_CHECK(std::abs(f.x_range().min - before_drag) < 1e-9);
f.frame(ImVec2(260.0f, 200.0f), false); // the release ends nothing: already cancelled
PLOT_CHECK(std::abs(f.x_range().min - before_drag) < 1e-9);
}
// The widget stamps every pointer event with ImGui's clock, which is what makes
// the double-click -- two presses of the same button within 0.35 s and 4 px --
// exist on this host: re-fitting every axis is the controller's gesture, and the
// widget is the only supplier of the times it differences. Two quick presses on
// the fixture (whose x range was pinned to {0, 20}) must hand the axes back to
// the data -- the fit's own bounds -- and the second press must not begin a drag.
PLOT_TEST(imgui_widget_stamps_presses_so_a_double_click_can_reset) {
Fixture f;
PLOT_CHECK(f.init());
// One quiet hovered frame first. The very first frame's events are not the
// widget's yet: ImGui has not decided the hover (and its input queue trickles),
// so a press queued on frame 1 is swallowed by the hover gate and the controller
// never sees it. Every press this test means to deliver needs the hover live,
// which is the same reason the other tests here warm up with a hovered frame.
f.frame(ImVec2(200.0f, 200.0f), false);
f.frame(ImVec2(200.0f, 200.0f), true); // press 1
f.frame(ImVec2(200.0f, 200.0f), false); // release
// Press 2 in the same spot one frame later: ~1/60 s apart with the default
// DeltaTime, well inside the 0.35 s and 4 px window.
f.frame(ImVec2(200.0f, 200.0f), true);
const double fitted = f.x_range().span();
const double fitted_min = f.x_range().min;
PLOT_CHECK(fitted > 40.0); // the data's own span, not the fixture's {0, 20}
f.frame(ImVec2(220.0f, 200.0f), true); // a move with the second press's button
// ... begins no drag: the fit's values stand exactly.
PLOT_CHECK_NEAR(f.x_range().span(), fitted, 1e-9);
PLOT_CHECK_NEAR(f.x_range().min, fitted_min, 1e-9);
f.frame(ImVec2(220.0f, 200.0f), false);
}
+39
View File
@@ -3,6 +3,7 @@
#include "harness.hpp"
#include <plot/plot/measure.hpp>
#include <plot/plot/plot.hpp>
#include <plot/data/array_series.hpp>
#include <plot/data/detail/cover_count.hpp>
#include <algorithm>
@@ -65,3 +66,41 @@ PLOT_TEST(empty_and_single_sample_intervals_are_valid_false) {
PLOT_CHECK(!plot::Measure::evaluate(s, 0, 0).valid);
PLOT_CHECK(!plot::Measure::evaluate(s, 1, 1).valid);
}
PLOT_TEST(a_measure_over_a_ruler_reports_invalid) {
// R8: a measurement is read from two *x* coordinates, and a horizontal ruler has
// no x -- it is the dead field of its orientation. Evaluating the pair anyway
// silently measured from x=0; now the id `add_measure` returns is real but
// reports an invalid measurement, and names no band to shade either.
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
std::vector<f32> ys(100);
for (u64 i = 0; i < 100u; ++i) ys[static_cast<std::size_t>(i)] = f32(i) * 0.01f;
plot::data::ArraySeries series(std::move(ys), 0.0, 1.0);
p.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
const u32 ra = p.add_h_ruler(0.5, plot::Color::hex(0xFFE080));
const u32 rb = p.add_h_ruler(-0.5, plot::Color::hex(0xFFE080));
const u32 rulers = p.add_measure(ra, rb);
PLOT_CHECK(!p.measurement(rulers).valid);
PLOT_CHECK(!p.measure_span(rulers).valid); // and no band between dead x's to shade
// A ruler paired with a real marker is refused for the same reason: one half of
// the pair has no x to measure from.
const u32 ca = p.add_cursor(10.0, plot::Color::hex(0xFFFFFF));
const u32 mixed = p.add_measure(ca, ra);
PLOT_CHECK(!p.measurement(mixed).valid);
PLOT_CHECK(!p.measure_span(mixed).valid);
// Sanity: a marker pair over the same series still measures. The extremes are
// the stored f32 samples' values, so the expectation is derived from the same
// arithmetic the store used, not from the decimal literals.
const u32 cb = p.add_cursor(20.0, plot::Color::hex(0xFFFFFF));
const u32 markers = p.add_measure(ca, cb);
const plot::Measurement& m = p.measurement(markers);
PLOT_CHECK(m.valid);
PLOT_CHECK_NEAR(m.ymin, static_cast<f64>(f32(10) * 0.01f), 1e-12);
PLOT_CHECK_NEAR(m.ymax, static_cast<f64>(f32(20) * 0.01f), 1e-12);
}
+52
View File
@@ -290,3 +290,55 @@ PLOT_TEST(pyramid_append_cost_is_amortised_constant) {
const u64 slid = plot::data::detail::merge_count().load(std::memory_order_relaxed);
PLOT_CHECK(slid <= kAppends / 16);
}
// --- B7: non-finite samples are excluded from every aggregate ----------------
PLOT_TEST(pyramid_excludes_non_finite_samples_from_aggregates) {
// NaN at the first, middle and last positions of the window, and a whole
// all-NaN bucket in the middle: the aggregates never see them.
MinMaxPyramid p;
p.append(std::nanf("")); // first
for (int i = 0; i < 63; ++i) p.append(1.0f);
for (int i = 0; i < kBucket; ++i) p.append(std::nanf("")); // bucket 1: all NaN
for (int i = 0; i < 63; ++i) p.append(2.0f);
p.append(std::nanf("")); // last
PLOT_CHECK(p.size() == 64 + kBucket + 64);
// Bucket 0: 63 ones; bucket 1: empty; bucket 2: 63 twos (the final NaN is
// the pending bucket's only sample, never a whole bucket).
PLOT_CHECK(p.bucket_count(0, 0) == 63);
PLOT_CHECK(p.bucket_count(0, 1) == 0);
PLOT_CHECK(p.bucket_count(0, 2) == 63);
const Range r0 = p.reduce_buckets(0, kBucket);
PLOT_CHECK_NEAR(r0.min, 1.0, 1e-6);
PLOT_CHECK_NEAR(r0.max, 1.0, 1e-6);
PLOT_CHECK(p.reduce_buckets(kBucket, 2 * kBucket).span() == 0.0); // the NaN bucket
PLOT_CHECK(p.sum_buckets(kBucket, 2 * kBucket) == 0.0);
PLOT_CHECK(p.sumsq_buckets(kBucket, 2 * kBucket) == 0.0);
// The merge above an empty child keeps the other child's extremes: level 1
// bucket 0 covers buckets 0 and 1, and must report the ones, not a 0.
PLOT_CHECK(p.level_size(1) >= 1);
const MinMax top = p.bucket(1, p.level_base(1));
PLOT_CHECK_NEAR(top.min, 1.0, 1e-6);
PLOT_CHECK_NEAR(top.max, 1.0, 1e-6);
PLOT_CHECK(p.bucket_count(1, p.level_base(1)) == 63);
// A range spanning the finite buckets on both sides of the empty one.
const Range all = p.reduce_buckets(0, 3 * kBucket);
PLOT_CHECK_NEAR(all.min, 1.0, 1e-6);
PLOT_CHECK_NEAR(all.max, 2.0, 1e-6);
PLOT_CHECK_NEAR(p.sum_buckets(0, 3 * kBucket), 63.0 + 126.0, 1e-6);
// bucket_sum / bucket_count is the mean the Mean gather mode folds.
PLOT_CHECK_NEAR(p.bucket_sum(0, 0) / f64(p.bucket_count(0, 0)), 1.0, 1e-9);
}
// --- R3: the deepest level is capped where the span shift still fits ---------
PLOT_TEST(pyramid_level_cap_keeps_the_span_shift_defined) {
// Level 57 is the last whose bucket span (kBucket << 57 == 2^63) fits a u64;
// past it the shift would be undefined, so the constant pins the cap and
// reserve() never materialises more levels than it.
PLOT_CHECK((u64{kBucket} << plot::data::kMaxPyramidLevel) == (u64{1} << 63));
MinMaxPyramid p;
p.reserve(~u64{0}); // a window no sample count can reach: still just the cap
p.append(1.0f);
PLOT_CHECK(p.level_count() >= 1);
}
+525 -30
View File
@@ -1939,10 +1939,11 @@ class CountingSeries final : public plot::data::Series {
}
plot::Range y_bounds() const override { return {0.0, 99.0}; }
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out) const override {
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 {
++gather_calls_;
samples_read_ += end - begin;
gather_impl(level, begin, end, out);
gather_impl(level, begin, end, x_origin, out, mode);
}
u64 gather_calls() const noexcept { return gather_calls_; }
@@ -1964,20 +1965,21 @@ class CountingSeries final : public plot::data::Series {
} // namespace
/// Spec §15, the lookup: a readout's y is **linear interpolation between the two samples
/// adjacent to the crosshair's x**, and hand-computed here rather than checked against the
/// implementation's own arithmetic. The four corners of the rule are all asserted: a query
/// between two samples, the two exact-sample ends of the window (where the span collapses onto
/// one sample), and a one-sample series, whose window is that sample alone.
PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) {
/// Spec §15, the lookup: a readout's y is **the nearer of the two samples adjacent to the
/// crosshair's x**, never a value between them. Every y asserted here is therefore one of the
/// series' own `tens` values -- a number *between* two of them, 15 or 22.5, is precisely what
/// this refuses, and what it used to return. The corners of the rule are all covered: a query
/// either side of the midpoint, a query exactly on it (the tie), the two exact-sample ends of
/// the window (where the span collapses onto one sample), and a one-sample series.
PLOT_TEST(crosshair_value_is_the_nearest_sample) {
CrosshairScene scene;
const u32 trace = scene.plot.add_trace(
{&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
// (x = 2, y = 20): the two neighbours, and the fraction between them. Asserting the span and
// not only the value is what pins the convention -- a nearest-sample lookup would report the
// span's `t` as 0 or 1, and an average of a *range* would not report a span at all.
// (x = 2, y = 20): the two neighbours, and the fraction between them. The span still reports
// both and the fraction between them -- `t` is a *selector* now, not a blend factor, so the
// bracketing is unchanged and only what is built from it differs.
const plot::CrosshairSpan half = plot::crosshair_span(scene.series, 1.5);
PLOT_CHECK(half.valid);
PLOT_CHECK(half.begin == 1u);
@@ -1985,14 +1987,25 @@ PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) {
PLOT_CHECK_NEAR(half.t, 0.5, 1e-12);
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_NEAR(y, 15.0, 1e-9);
// A quarter of the way through another pair: 20 + (30 - 20) * 0.25.
PLOT_CHECK_NEAR(y, 10.0, 1e-9);
// 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_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 --
// 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);
PLOT_CHECK(first.valid);
PLOT_CHECK(first.begin == 0u);
@@ -2007,9 +2020,10 @@ PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) {
PLOT_CHECK(scene.plot.crosshair_value(trace, 3.0, y));
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
// the window's, which are the clamped lies the header refuses -- and a series with no
// samples has no span at any x.
// Outside the window there is no value at all. The nearest-sample rule is a rule for an x the
// series *brackets*; extending it past the ends would answer with the first or last sample for
// 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, 3.0 + 1e-9).valid);
PLOT_CHECK(!scene.plot.crosshair_value(trace, 4.0, y));
@@ -2022,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
// 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::Plot one;
one.add_x_axis({"t", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false});
@@ -2038,7 +2052,9 @@ PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) {
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
// 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);
const plot::CrosshairLayout& crosshair = scene.plot.layout().crosshair;
PLOT_CHECK(crosshair.active);
@@ -2048,8 +2064,136 @@ PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) {
// the figure's case pins).
PLOT_CHECK(crosshair.rows.size() == 1u);
PLOT_CHECK(crosshair.rows[0].valid);
PLOT_CHECK_NEAR(crosshair.rows[0].y, 15.0, 1e-9);
PLOT_CHECK(crosshair.rows[0].value == "15");
PLOT_CHECK_NEAR(crosshair.rows[0].y, 10.0, 1e-9);
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
@@ -2079,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 == "#1");
// Both are listed with the value their own series has at x = 1.5, the swatch, the label's box
// and the value's box -- all placed by the plot, all inside the panel it measured.
// Both are listed with the value their own series has nearest x = 1.5, the swatch, the label's
// 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);
for (const plot::CrosshairRow& row : crosshair.rows) {
PLOT_CHECK(row.valid);
PLOT_CHECK(row.listed);
PLOT_CHECK_NEAR(row.y, 15.0, 1e-9);
PLOT_CHECK(row.value == "15");
PLOT_CHECK_NEAR(row.y, 10.0, 1e-9);
PLOT_CHECK(row.value == "10");
PLOT_CHECK(row.swatch.x >= crosshair.panel.x);
PLOT_CHECK(row.swatch.right() <= crosshair.panel.right());
PLOT_CHECK(row.swatch.y >= crosshair.panel.y);
@@ -2147,7 +2293,7 @@ PLOT_TEST(crosshair_readout_lists_every_visible_trace_including_unnamed_ones) {
PLOT_CHECK(on.rows[0].listed);
PLOT_CHECK(on.rows[1].listed);
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
@@ -2252,11 +2398,14 @@ PLOT_TEST(crosshair_lookup_does_not_scan) {
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});
// 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;
const u64 before_reads = series.samples_read();
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.gather_calls() <= 1u);
@@ -2287,10 +2436,356 @@ PLOT_TEST(crosshair_lookup_does_not_scan) {
const plot::CrosshairLayout& crosshair = p.layout().crosshair;
PLOT_CHECK(crosshair.rows.size() == 1u);
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.gather_calls() - calls <= 1u);
// And the readout is not vacuous: the series it read two samples of really does hold a
// quarter of a million of them.
PLOT_CHECK(series.size() == kSamples);
}
// --- hardening wave: R1, R2, B4, and the Series-virtual routing -------------
namespace {
/// A minimal **XY-like** series: the sample positions are this test's own array,
/// not a uniform grid, and only the lookups the plot layer routes through the
/// `Series` virtuals -- `x_at`/`neighbours`/`snap_index` -- are overridden. Any
/// plot-layer answer that assumed `x0() + i*dx()` is wrong here by construction,
/// which is what makes the fixture a regression test for the *routing* rather than
/// for the series: the real XY series (Stream A) must behave the same way, but
/// these tests stand on their own.
class XyFixture final : public plot::data::Series {
public:
XyFixture(std::vector<f64> x, std::vector<f32> y)
: x_(std::move(x)), y_(std::move(y)) {
pyramid_.reserve(y_.size());
for (const f32 v : y_) pyramid_.append(v);
}
u64 size() const override { return y_.size(); }
f64 x0() const override { return x_.empty() ? 0.0 : x_.front(); }
f64 dx() const override { return 1.0; } // nominal; the real positions are x_
plot::Range x_bounds() const override {
return x_.empty() ? plot::Range{} : plot::Range{x_.front(), x_.back()};
}
plot::Range y_bounds() const override { return {0.0, 20.0}; }
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
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 {
gather_impl(level, begin, end, x_origin, out, mode);
}
f64 x_at(u64 i) const override { return x_[static_cast<std::size_t>(i)]; }
bool neighbours(f64 x, u64& i0, u64& i1) const override {
if (x_.empty() || x < x_.front() || x > x_.back()) return false;
const auto upper = std::lower_bound(x_.begin(), x_.end(), x);
const auto first = static_cast<u64>(upper - x_.begin());
if (upper != x_.end() && *upper == x) {
i0 = i1 = first; // the query landed on a sample
return true;
}
i1 = first;
i0 = first == 0u ? 0u : first - 1u;
return true;
}
u64 snap_index(f64 x) const override {
if (x_.empty()) return 0u;
u64 i0 = 0;
u64 i1 = 0;
if (!neighbours(x, i0, i1)) {
return x < x_.front() ? 0u : static_cast<u64>(x_.size() - 1u);
}
if (i0 == i1) return i0;
const f64 before = x - x_at(i0);
const f64 after = x_at(i1) - x;
return after < before ? i1 : i0;
}
protected:
void copy_raw(u64 begin, u64 end, f32* out) const override {
for (u64 i = begin; i < end && i < y_.size(); ++i) out[i - begin] = y_[i];
}
private:
std::vector<f64> x_;
std::vector<f32> y_;
plot::data::MinMaxPyramid pyramid_;
};
/// The XY scene: samples at x = 0, 1, 10 (deliberately non-uniform), y = 0, 10, 20,
/// and a window wide enough to frame them all.
struct XyScene {
XyFixture series{{0.0, 1.0, 10.0}, {0.0f, 10.0f, 20.0f}};
plot::Plot plot;
u32 ax = 0;
u32 ay = 0;
XyScene() {
plot.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
ax = plot.add_x_axis({"t", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false});
ay = plot.add_y_axis({"v", {0.0, 20.0}, false, plot::AxisPlace::Left, false});
}
};
/// A streaming series with decade-wide spacing on a log axis: sample k sits at
/// x = 1 + 10k, so feeding samples walks the axis through decades.
struct LogLiveFixture {
plot::data::StreamSeries series;
plot::Plot graph;
u32 ax = 0;
explicit LogLiveFixture(u64 push) : series(256u, 1.0, 10.0) {
graph.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
ax = graph.add_x_axis({"t", {1.0, 1000.0}, false, plot::AxisPlace::Bottom, /*log=*/true});
graph.add_y_axis({"v", {0.0, 1.0}, false, plot::AxisPlace::Left, false});
graph.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, ax, 0u});
feed(push);
graph.set_x_follow(ax, plot::XFollow::Live);
graph.auto_fit_x();
graph.layout();
}
void feed(u64 n) {
for (u64 i = 0; i < n; ++i) series.push(0.5f);
series.acquire();
}
f64 newest() const { return series.x_bounds().max; }
/// The visible window in decades -- what a log follow must hold constant.
f64 decades() const {
const plot::Range r = graph.x_range(ax);
return std::log10(r.max) - std::log10(r.min);
}
};
} // namespace
/// R1: the layout clamps are defined even for a hostile viewport. `set_viewport()`
/// normalises negative dimensions at the door; the belt-and-braces guard inside
/// `layout()` means the two can never drift out of step -- and this pair is what a
/// caller that hands the plot a negative rect straight away exercises, in a build
/// where the undefined clamp would be visible (asan).
PLOT_TEST(layout_survives_a_negative_viewport) {
plot::Plot p;
p.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"v", {0.0, 1.0}, false, plot::AxisPlace::Left, false});
p.set_viewport({10.0f, 10.0f, -200.0f, -100.0f}); // normalised to 10x10 zero-size
const plot::PlotLayout& lay = p.layout();
PLOT_CHECK(lay.axes.w >= 0.0f);
PLOT_CHECK(lay.axes.h >= 0.0f);
// And a plot laid out at zero size draws nothing but says nothing either: the
// same call again on the guarded dimensions stays well-defined.
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
PLOT_CHECK(p.layout().axes.w > 0.0f);
}
/// R2: the range setters refuse non-finite endpoints. A stored NaN or infinity
/// would hand every transform a garbage mapping *and* clear `auto_scale` -- the
/// one way back -- so the call is refused outright: the axis keeps the range it
/// had and the auto-scale state it had.
PLOT_TEST(range_setters_refuse_non_finite_bounds) {
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
// A user-chosen range first, so the auto-scale state the refusal has to preserve
// is the "cleared by a gesture" one, which is the state a real caller is in.
p.set_x_range(ax, {10.0, 20.0});
PLOT_CHECK(!p.x_axis(ax).auto_scale);
p.set_x_range(ax, {std::nan(""), 30.0});
PLOT_CHECK_NEAR(p.x_range(ax).min, 10.0, 1e-9);
PLOT_CHECK_NEAR(p.x_range(ax).max, 20.0, 1e-9);
PLOT_CHECK(!p.x_axis(ax).auto_scale); // kept as it was, neither lost nor re-armed
p.set_x_range(ax, {5.0, std::numeric_limits<f64>::infinity()});
PLOT_CHECK_NEAR(p.x_range(ax).min, 10.0, 1e-9);
PLOT_CHECK_NEAR(p.x_range(ax).max, 20.0, 1e-9);
// The y setter's own refusal, with the same preservation both ways.
p.set_y_auto_scale(ay, true);
p.set_y_range(ay, {std::nan(""), 0.5});
PLOT_CHECK_NEAR(p.y_range(ay).min, -1.0, 1e-9);
PLOT_CHECK_NEAR(p.y_range(ay).max, 1.0, 1e-9);
PLOT_CHECK(p.y_axis(ay).auto_scale); // still armed: the fit is not lost
p.set_y_range(ay, {-1.0, std::numeric_limits<f64>::infinity()});
PLOT_CHECK_NEAR(p.y_range(ay).max, 1.0, 1e-9);
PLOT_CHECK(p.y_axis(ay).auto_scale);
// A finite range still works, and still disarms.
p.set_y_range(ay, {-2.0, 2.0});
PLOT_CHECK_NEAR(p.y_range(ay).min, -2.0, 1e-9);
PLOT_CHECK(!p.y_axis(ay).auto_scale);
}
/// B4, on a log axis: the live window's span and offset are *decades*, so new
/// samples slide the window without rescaling it -- the visible decades stay
/// exactly what the user zoomed to, whatever the live edge does. (A linear span on
/// a log axis is the defect: ten units of span at 1e0 is ten decades, at 1e9 it is
/// nothing.)
PLOT_TEST(live_log_axis_holds_decades_constant) {
LogLiveFixture f(5u); // newest = 41
const f64 decades = f.decades();
PLOT_CHECK(decades > 0.5);
PLOT_CHECK_NEAR(f.graph.x_range(f.ax).max, f.newest(), 1e-9);
for (int i = 0; i < 12; ++i) {
f.feed(2u); // newest += 20 per round
f.graph.layout();
PLOT_CHECK_NEAR(f.decades(), decades, 1e-9); // the zoom is preserved
PLOT_CHECK_NEAR(f.graph.x_range(f.ax).max, f.newest(), 1e-8); // the edge is too
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
}
}
/// B4, scrolled back: a window parked behind the live edge keeps its span *in
/// decades* as the stream advances, and the distance behind the edge -- the offset,
/// also in decades -- is what survives, not an absolute position.
PLOT_TEST(scrolled_back_live_window_keeps_its_decades) {
LogLiveFixture f(5u); // newest = 41
f.graph.set_x_range(f.ax, {1.0, 10.0}); // one decade wide, behind the edge
const f64 decades = f.decades();
PLOT_CHECK_NEAR(decades, 1.0, 1e-9);
const f64 offset = f.graph.x_offset(f.ax);
PLOT_CHECK(offset > 0.5); // one decade behind the 41 edge, in log10
PLOT_CHECK_NEAR(f.graph.x_range(f.ax).max, 10.0, 1e-9);
f.feed(6u); // newest = 101: the edge has moved a whole decade
f.graph.layout();
PLOT_CHECK_NEAR(f.decades(), 1.0, 1e-9); // the span the user chose
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), offset, 1e-12); // the scroll the user chose
const plot::Range r = f.graph.x_range(f.ax);
PLOT_CHECK_NEAR(std::log10(r.max), std::log10(f.newest()) - offset, 1e-9);
PLOT_CHECK(r.max < f.newest()); // genuinely parked behind the edge
}
/// The XY routing, crosshair half: the span brackets the samples the *array*
/// names, and `t` is measured on the samples' own positions -- (5-1)/(10-1) over the
/// wide gap, where a uniform-grid guess would have read samples that do not exist at
/// 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) {
XyScene scene;
const u32 trace = scene.plot.add_trace(
{&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay});
const plot::CrosshairSpan span = plot::crosshair_span(scene.series, 5.0);
PLOT_CHECK(span.valid);
PLOT_CHECK(span.begin == 1u);
PLOT_CHECK(span.end == 2u);
PLOT_CHECK_NEAR(span.t, 4.0 / 9.0, 1e-12);
f64 y = 0.0;
PLOT_CHECK(scene.plot.crosshair_value(trace, 5.0, y));
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
// closed-window rule a uniform series follows, on the array's own coordinates.
const plot::CrosshairSpan on = plot::crosshair_span(scene.series, 1.0);
PLOT_CHECK(on.valid);
PLOT_CHECK(on.begin == 1u);
PLOT_CHECK(on.end == 1u);
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.0, y));
PLOT_CHECK_NEAR(y, 10.0, 1e-9);
PLOT_CHECK(scene.plot.crosshair_value(trace, 10.0, y));
PLOT_CHECK_NEAR(y, 20.0, 1e-9);
PLOT_CHECK(!plot::crosshair_span(scene.series, -1e-9).valid);
PLOT_CHECK(!plot::crosshair_span(scene.series, 10.0 + 1e-9).valid);
PLOT_CHECK(scene.plot.crosshair_value(trace, 5.5, y));
PLOT_CHECK(!scene.plot.crosshair_value(trace, -1.0, y));
}
/// The XY routing, measurement half: a cursor snaps to the sample that is really
/// nearest -- 5.0 is nearer sample 1 (distance 4) than sample 2 (distance 5), where
/// the old uniform-grid index arithmetic named sample 2 -- and the measurement
/// covers exactly the samples the snapped pair brackets.
PLOT_TEST(xy_series_measurement_uses_the_true_nearest_samples) {
XyScene scene;
scene.plot.add_trace({&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay});
// Free cursors first: a cursor's stored x is verbatim under the default snap,
// and the *measurement* is what maps a cursor x onto samples -- through the
// series' own `snap_index()`, so 5.0 measures from the sample that is really
// nearest (x = 1, distance 4) and not from where a uniform `dx()` grid guessed.
const u32 ca = scene.plot.add_cursor(5.0, plot::Color::hex(0xFFFFFF));
const u32 cb = scene.plot.add_cursor(9.0, plot::Color::hex(0xFFFFFF));
PLOT_CHECK_NEAR(scene.plot.cursor(ca).x, 5.0, 1e-9); // Free: stored verbatim
const u32 measure = scene.plot.add_measure(ca, cb);
const plot::Measurement& m = scene.plot.measurement(measure);
PLOT_CHECK(m.valid);
PLOT_CHECK_NEAR(m.ymin, 10.0, 1e-9);
PLOT_CHECK_NEAR(m.ymax, 20.0, 1e-9);
PLOT_CHECK_NEAR(m.mean, 15.0, 1e-9);
// The snap, armed explicitly, lands on the same true nearest sample: 5.0 pulls
// onto x = 1 (distance 4 beats 5), 9.0 onto x = 10 (distance 1 beats 9).
scene.plot.set_cursor_snap(ca, plot::SnapMode::NearestSample);
PLOT_CHECK_NEAR(scene.plot.cursor(ca).x, 1.0, 1e-9);
scene.plot.set_cursor_snap(cb, plot::SnapMode::NearestSample);
PLOT_CHECK_NEAR(scene.plot.cursor(cb).x, 10.0, 1e-9);
// Region coverage is the same routing: [0.5, 5.0] covers exactly the sample at
// x = 1 -- the sample at x = 10 is not inside the interval, whatever a `dx()`
// grid would have guessed. The region is `Free`: a snapping region would land on
// a sample and change the interval under test.
const u32 region = scene.plot.add_region({0.5, 5.0, plot::RegionSnap::Free});
const plot::RegionStats& stats = scene.plot.region_stats(region, 0u);
PLOT_CHECK(stats.valid);
PLOT_CHECK(stats.count == 1u);
PLOT_CHECK_NEAR(stats.mean, 10.0, 1e-9);
PLOT_CHECK_NEAR(stats.min, 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);
}
+41 -14
View File
@@ -536,21 +536,21 @@ PLOT_TEST(text_batch_stages_one_quad_per_glyph) {
b.begin();
const plot::Vec2 end = b.add_text("12.5 ms", {0.0f, 0.0f}, 1.0f, f);
PLOT_CHECK(b.glyph_count() == 7);
PLOT_CHECK(b.data().size() == 42); // x,y,u,v,ax,ay per glyph
PLOT_CHECK(b.data().size() == 70); // x,y,u,v,ax,ay,r,g,b,a per glyph
PLOT_CHECK_NEAR(end.x, f.measure("12.5 ms"), 1e-6);
PLOT_CHECK_NEAR(end.y, 0.0, 1e-6);
PLOT_CHECK_NEAR(b.data()[0], 0.0, 1e-6); // first cell at the pen
PLOT_CHECK_NEAR(b.data()[1], 0.0, 1e-6);
PLOT_CHECK_NEAR(b.data()[6], f.glyph('1')->advance, 1e-6); // pen advanced per glyph
PLOT_CHECK_NEAR(b.data()[10], f.glyph('1')->advance, 1e-6); // pen advanced per glyph
// The staged cell's UV is the glyph's own atlas rect (see `Glyph`), and the
// basis of an unrotated run is the screen's +x. '.' is the string's third
// glyph, so its instance is the third: floats 12..17.
// glyph, so its instance is the third: floats 20..29.
const plot::render::Glyph* dot = f.glyph(static_cast<plot::u32>('.'));
PLOT_CHECK(dot != nullptr);
PLOT_CHECK_NEAR(b.data()[14], dot->u0, 1e-6);
PLOT_CHECK_NEAR(b.data()[15], dot->v0, 1e-6);
PLOT_CHECK_NEAR(b.data()[16], 1.0, 1e-6); // ax
PLOT_CHECK_NEAR(b.data()[17], 0.0, 1e-6); // ay
PLOT_CHECK_NEAR(b.data()[22], dot->u0, 1e-6);
PLOT_CHECK_NEAR(b.data()[23], dot->v0, 1e-6);
PLOT_CHECK_NEAR(b.data()[24], 1.0, 1e-6); // ax
PLOT_CHECK_NEAR(b.data()[25], 0.0, 1e-6); // ay
// Every glyph a batch stages is read through one cell, and the batch publishes
// it for the draw (`u_glyph_uv`).
PLOT_CHECK_NEAR(b.glyph_cell_uv().x, dot->u1 - dot->u0, 1e-6);
@@ -571,7 +571,7 @@ PLOT_TEST(text_batch_stages_one_quad_per_glyph) {
PLOT_CHECK(b.glyph_count() == 2);
PLOT_CHECK_NEAR(scaled.x, 10.0f + 2.0f * f.measure("AW"), 1e-6);
PLOT_CHECK_NEAR(scaled.y, 4.0, 1e-6);
PLOT_CHECK_NEAR(b.data()[6], 10.0f + 2.0f * f.glyph('A')->advance, 1e-6); // second cell's pen
PLOT_CHECK_NEAR(b.data()[10], 10.0f + 2.0f * f.glyph('A')->advance, 1e-6); // second cell's pen
}
// The pen follows the basis, not the screen axes. A run staged with `axis` has to
@@ -599,13 +599,13 @@ PLOT_TEST(text_batch_advances_along_its_basis) {
PLOT_CHECK_NEAR(up.x, pen.x, 1e-6);
PLOT_CHECK_NEAR(up.y, pen.y - width, 1e-4);
for (plot::u32 i = 0; i < b.glyph_count(); ++i) {
PLOT_CHECK_NEAR(b.data()[6u * i + 4u], 0.0, 1e-6);
PLOT_CHECK_NEAR(b.data()[6u * i + 5u], -1.0, 1e-6);
PLOT_CHECK_NEAR(b.data()[10u * i + 4u], 0.0, 1e-6);
PLOT_CHECK_NEAR(b.data()[10u * i + 5u], -1.0, 1e-6);
}
// The second cell is one advance further up than the pen, which is the same rule
// the unrotated case follows in x.
PLOT_CHECK_NEAR(b.data()[6], pen.x, 1e-6);
PLOT_CHECK_NEAR(b.data()[7], pen.y - f.glyph('1')->advance, 1e-6);
PLOT_CHECK_NEAR(b.data()[10], pen.x, 1e-6);
PLOT_CHECK_NEAR(b.data()[11], pen.y - f.glyph('1')->advance, 1e-6);
// A diagonal direction is normalised, so a caller can hand over any direction
// and the basis the shader receives is still unit.
@@ -616,6 +616,33 @@ PLOT_TEST(text_batch_advances_along_its_basis) {
PLOT_CHECK_NEAR(b.data()[5], 0.8, 1e-5);
}
// The tint is per instance (`a_colour`, the last four floats of every glyph),
// not a uniform: one batch can carry runs of several colours, which is what the
// crosshair readout needs (values in each row's trace colour, labels in the axis
// text colour, one draw call). The staged colour is exactly what the caller
// passed, straight alpha, on every glyph of the run and nothing else.
PLOT_TEST(text_batch_stages_its_colour_per_instance) {
plot::render::EmbeddedMonoFont f;
plot::render::TextBatch b;
const plot::Color tint{0.25f, 0.5f, 0.75f, 0.9f};
b.begin();
b.add_text("AB", {0.0f, 0.0f}, 1.0f, f, {1.0f, 0.0f}, tint);
PLOT_CHECK(b.glyph_count() == 2);
for (plot::u32 i = 0; i < 2; ++i) {
PLOT_CHECK_NEAR(b.data()[10u * i + 6u], tint.r, 1e-6);
PLOT_CHECK_NEAR(b.data()[10u * i + 7u], tint.g, 1e-6);
PLOT_CHECK_NEAR(b.data()[10u * i + 8u], tint.b, 1e-6);
PLOT_CHECK_NEAR(b.data()[10u * i + 9u], tint.a, 1e-6);
}
// A second run in another colour keeps its own tint per instance -- the old
// uniform-tint pipeline could not express this in one batch.
b.add_text("CD", {20.0f, 0.0f}, 1.0f, f, {1.0f, 0.0f},
plot::Color{1.0f, 1.0f, 1.0f, 1.0f});
PLOT_CHECK(b.glyph_count() == 4);
PLOT_CHECK_NEAR(b.data()[26u], 1.0, 1e-6); // 'C' is white where 'A' was not
PLOT_CHECK_NEAR(b.data()[6u], 0.25, 1e-6); // ... and 'A' still carries its own
}
/// A backend whose atlas is *not* a fixed-cell grid: it declares the cell its
/// pipeline needs and then publishes one glyph trimmed to its ink, which is what
/// a naive TrueType packing produces (`stbtt_GetCodepointBitmap` hands back an ink
@@ -844,8 +871,8 @@ PLOT_TEST(add_text_skips_a_codepoint_the_backend_cannot_draw) {
// glyph of its own, which the second instance's atlas rect says outright.
PLOT_CHECK_NEAR(batch.data()[0], pen.x, 1e-6);
PLOT_CHECK_NEAR(batch.data()[2], a->u0, 1e-6);
PLOT_CHECK_NEAR(batch.data()[6], pen.x + a->advance, 1e-6);
PLOT_CHECK_NEAR(batch.data()[8], b->u0, 1e-6);
PLOT_CHECK_NEAR(batch.data()[10], pen.x + a->advance, 1e-6);
PLOT_CHECK_NEAR(batch.data()[12], b->u0, 1e-6);
// A run of nothing but undrawable codepoints stages nothing at all and leaves
// the pen where it was, rather than drawing at the pen.
+90
View File
@@ -57,3 +57,93 @@ PLOT_TEST(pointer_anchor_reports_no_movement_before_its_first_sample) {
PLOT_CHECK_NEAR(delta.x, 0.0, 1e-9);
PLOT_CHECK_NEAR(delta.y, 0.0, 1e-9);
}
// --- the pinch --------------------------------------------------------------
// Qt reports a pinch as cumulative numbers -- `totalScaleFactor()` since the
// gesture began, `centerPoint()` where the fingers are now -- while the
// controller's `GestureEvent` wants the increments: this frame's zoom factor
// (1.0 = no change) and the focal point's movement since the previous event.
// `PinchTracker` is the fold, and the ratios it produces are what a pinch feels
// like through: a cumulative 1.0 -> 1.25 -> 1.5 spread is a 1.25 frame followed
// by a 1.2 frame, never a 1.25 then a 1.5 (which would zoom twice as far as the
// fingers did).
PLOT_TEST(pinch_tracker_folds_cumulative_scale_into_incremental_zoom) {
plot::qt::PinchTracker pinch;
// The begin anchors the fold and zooms nothing: a pinch that just started has
// moved no scale yet, so the first event the controller could see is 1.0.
pinch.begin(1.0f, {200.0f, 150.0f});
PLOT_CHECK(pinch.active);
const plot::input::GestureEvent started = pinch.update(1.0f, {200.0f, 150.0f}, 1.0);
PLOT_CHECK_NEAR(started.scale, 1.0, 1e-6);
PLOT_CHECK_NEAR(started.dx, 0.0, 1e-6);
PLOT_CHECK_NEAR(started.dy, 0.0, 1e-6);
// Fingers spread to 1.25 cumulative, focal steady: one 1.25 zoom about the
// focal point, and no pan.
const plot::input::GestureEvent spread = pinch.update(1.25f, {200.0f, 150.0f}, 1.0);
PLOT_CHECK_NEAR(spread.scale, 1.25, 1e-6);
PLOT_CHECK_NEAR(spread.dx, 0.0, 1e-6);
PLOT_CHECK_NEAR(spread.dy, 0.0, 1e-6);
// Spread on to 1.5: the *increment* is 1.5 / 1.25, not 1.5 again.
const plot::input::GestureEvent more = pinch.update(1.5f, {200.0f, 150.0f}, 1.0);
PLOT_CHECK_NEAR(more.scale, 1.2, 1e-6);
// And a pinch in (0.75 of the cumulative): an incremental shrink.
const plot::input::GestureEvent pinch_in = pinch.update(0.75f, {200.0f, 150.0f}, 1.0);
PLOT_CHECK_NEAR(pinch_in.scale, 0.5, 1e-6);
}
// The focal point's movement is the two-finger pan, and it is mapped through the
// ratio in force *now* from an anchor kept in **logical** pixels -- the same
// convention as PointerAnchor above, because the same defect applies: a pinch
// that crosses onto a display with another scale factor must not read the ratio
// change as a focal jump (which the controller would apply as a pan).
PLOT_TEST(pinch_tracker_maps_focal_movement_through_the_current_dpr) {
plot::qt::PinchTracker pinch;
pinch.begin(1.0f, {200.0f, 150.0f});
// A plain pan at 1.0: ten logical pixels right, no zoom.
const plot::input::GestureEvent moved = pinch.update(1.0f, {210.0f, 150.0f}, 1.0);
PLOT_CHECK_NEAR(moved.scale, 1.0, 1e-6);
PLOT_CHECK_NEAR(moved.dx, 10.0, 1e-6);
PLOT_CHECK_NEAR(moved.dy, 0.0, 1e-6);
PLOT_CHECK_NEAR(moved.x, 210.0, 1e-6); // the focal point the zoom anchors on
// Mid-pinch the window lands on the 1.25 display. The fingers move 4 more
// logical pixels: the movement is 4 * 1.25 = 5 device pixels, and the anchor
// the next frame is measured from is the new logical point -- not the old one
// rescaled, which would read the ratio change itself as a jump.
const plot::input::GestureEvent rescaled = pinch.update(1.0f, {214.0f, 150.0f}, 1.25);
PLOT_CHECK_NEAR(rescaled.scale, 1.0, 1e-6);
PLOT_CHECK_NEAR(rescaled.dx, 5.0, 1e-6);
PLOT_CHECK_NEAR(rescaled.dy, 0.0, 1e-6);
PLOT_CHECK_NEAR(rescaled.x, 214.0 * 1.25, 1e-6);
PLOT_CHECK_NEAR(rescaled.y, 150.0 * 1.25, 1e-6);
// A diagonal movement, both axes mapped.
const plot::input::GestureEvent diagonal = pinch.update(1.0f, {214.0f, 154.0f}, 1.25);
PLOT_CHECK_NEAR(diagonal.dx, 0.0, 1e-6);
PLOT_CHECK_NEAR(diagonal.dy, 5.0, 1e-6);
}
// An update outside a gesture -- before any begin, or after the end/cancel --
// zooms and moves nothing: a host that missed the start (or a straggler event
// Qt delivers out of order) must not zoom from a made-up anchor.
PLOT_TEST(pinch_tracker_ignores_updates_outside_a_gesture) {
plot::qt::PinchTracker pinch;
const plot::input::GestureEvent orphan = pinch.update(2.0f, {10.0f, 10.0f}, 1.0);
PLOT_CHECK_NEAR(orphan.scale, 1.0, 1e-9);
PLOT_CHECK_NEAR(orphan.dx, 0.0, 1e-9);
PLOT_CHECK_NEAR(orphan.dy, 0.0, 1e-9);
pinch.begin(1.0f, {0.0f, 0.0f});
pinch.end();
PLOT_CHECK(!pinch.active);
const plot::input::GestureEvent straggler = pinch.update(2.0f, {10.0f, 10.0f}, 1.0);
PLOT_CHECK_NEAR(straggler.scale, 1.0, 1e-9);
PLOT_CHECK_NEAR(straggler.dx, 0.0, 1e-9);
PLOT_CHECK_NEAR(straggler.dy, 0.0, 1e-9);
}
File diff suppressed because it is too large. Load diff
+15
View File
@@ -20,3 +20,18 @@ PLOT_TEST(ring_clear_resets) {
PLOT_CHECK(r.size() == 1);
PLOT_CHECK_NEAR(r[0], 42.0);
}
PLOT_TEST(ring_capacity_rounds_up_to_a_power_of_two) {
// The capacity is rounded up so slot addressing stays a mask, and capacity()
// reports the rounded value -- the one callers must size for (R4).
PLOT_CHECK(plot::data::RingBuffer<f32>(100).capacity() == 128);
PLOT_CHECK(plot::data::RingBuffer<f32>(1).capacity() == 1);
PLOT_CHECK(plot::data::RingBuffer<f32>(0).capacity() == 1);
PLOT_CHECK(plot::data::RingBuffer<f32>(64).capacity() == 64); // already a power
plot::data::RingBuffer<f32> r(100);
for (int i = 0; i < 1000; ++i) r.push(f32(i));
PLOT_CHECK(r.size() == 128); // size() saturates at the rounded capacity
PLOT_CHECK_NEAR(r[0], 872.0); // oldest retained
PLOT_CHECK_NEAR(r[127], 999.0); // newest
}
+475 -45
View File
@@ -24,11 +24,12 @@ PLOT_TEST(array_series_bounds_and_gather) {
PLOT_CHECK_NEAR(s.x_bounds().max, 11.5, 1e-9);
PLOT_CHECK_NEAR(s.y_bounds().min, -2.0, 1e-6);
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_NEAR(out[0], 10.0, 1e-6);
// 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[1], 1.0, 1e-6);
PLOT_CHECK_NEAR(out[6], 11.5, 1e-6);
PLOT_CHECK_NEAR(out[6], 1.5, 1e-6); // sample 3: x_at(3) - x0() = 3 * 0.5
}
PLOT_TEST(stream_series_acquire_publishes_snapshot) {
@@ -103,11 +104,13 @@ PLOT_TEST(stream_series_exact_queries_survive_ring_wrap) {
PLOT_CHECK_NEAR(s.sumsq(0, n), sumsq, 1e-1);
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_NEAR(out[0], s.x0(), 1e-6);
// x is relative to x0(): the oldest retained sample emits 0, the newest
// (n - 1) * dx, never the absolute data coordinates (relative-x contract).
PLOT_CHECK_NEAR(out[0], 0.0, 1e-6);
PLOT_CHECK_NEAR(out[1], f32(kFirstRetained % 97), 1e-6);
PLOT_CHECK_NEAR(out[2 * n - 2], s.x0() + f64(n - 1), 1e-6);
PLOT_CHECK_NEAR(out[2 * n - 2], f64(n - 1) * 1.0, 1e-6);
}
PLOT_TEST(stream_series_push_and_read_are_race_free) {
@@ -154,7 +157,7 @@ PLOT_TEST(stream_series_push_and_read_are_race_free) {
(void)s.sum(0, n);
(void)s.sumsq(0, n);
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;
}
++reads;
@@ -250,38 +253,43 @@ PLOT_TEST(stream_series_sliding_window_queries_match_bruteforce) {
// level-1 gather: the envelope pairs of the retained buckets, bracketed by the
// two partial edge buckets as raw samples, so the geometry spans the range
// exactly. This is the mapping the renderer draws from.
// exactly. This is the mapping the renderer draws from. Bucket selection is
// ceil/floor on the range edges (B3): `first` is the first level-1 bucket
// 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.
std::vector<f32> out;
s.gather(1, 0, n, out);
const u64 first = std::max(base / span, lbase);
const u64 last = std::min((base + n - 1) / span + 1, lbase + lsize);
s.gather(1, 0, n, s.x0(), out);
const u64 first =
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 lead = first * span - base; // raw pairs before the first whole bucket
const u64 trail = last * span - base; // first window index after the last one
const u64 tail = trail < n ? n - trail : 0;
const u64 env = last - first;
PLOT_CHECK(out.size() == 2 * (lead + 2 * env + tail)); // each bucket is two pairs
for (u64 i = 0; i < lead; ++i) { // the partial leading bucket, raw
PLOT_CHECK_NEAR(out[2 * i], f32(f64(base + i) * 1e-6), 1e-9);
// x relative to x0(): window index i emits i * dx, not the absolute
// (base + i) * dx (relative-x contract).
PLOT_CHECK_NEAR(out[2 * i], f32(f64(i) * 1e-6), 1e-9);
PLOT_CHECK_NEAR(out[2 * i + 1], value(base + i), 1e-6);
}
for (u64 j = 0; j < env; ++j) {
// Each whole bucket contributes two pairs: (first sample, min) and (last
// sample, max). x is in data coordinates -- x0() is the retained window's
// first sample -- so window index i plots at (base + i) * dx.
// sample, max). x is relative to x0(): window index i plots at i * dx.
const u64 bi = first + j;
const std::array<f64, 4> want = brute(bi * span - base, (bi + 1) * span - base);
const u64 first_sample = bi * span - base;
const u64 last_sample = std::min((bi + 1) * span, base + n) - 1 - base;
const plot::usize lo = static_cast<plot::usize>(2 * (lead + 2 * j));
const plot::usize hi = lo + 2;
PLOT_CHECK_NEAR(out[lo], f32(f64(base + first_sample) * 1e-6), 1e-9);
PLOT_CHECK_NEAR(out[lo], f32(f64(first_sample) * 1e-6), 1e-9);
PLOT_CHECK_NEAR(out[lo + 1], want[0], 1e-6);
PLOT_CHECK_NEAR(out[hi], f32(f64(base + last_sample) * 1e-6), 1e-9);
PLOT_CHECK_NEAR(out[hi], f32(f64(last_sample) * 1e-6), 1e-9);
PLOT_CHECK_NEAR(out[hi + 1], want[1], 1e-6);
}
for (u64 i = 0; i < tail; ++i) { // the partial trailing bucket: the newest samples
const plot::usize k = static_cast<plot::usize>(2 * (lead + 2 * env + i));
PLOT_CHECK_NEAR(out[k], f32(f64(base + trail + i) * 1e-6), 1e-9);
PLOT_CHECK_NEAR(out[k], f32(f64(trail + i) * 1e-6), 1e-9);
PLOT_CHECK_NEAR(out[k + 1], value(base + trail + i), 1e-6);
}
}
@@ -318,29 +326,6 @@ PLOT_TEST(stream_series_steady_state_acquire_is_proportional_to_produced) {
PLOT_CHECK(big <= 2 * small + kBucket);
}
PLOT_TEST(stream_series_steady_state_acquire_is_fast) {
// Wall-clock backstop for the merge count above: rebuilding the 1M-sample
// window measured ~44 ms, so 128 such frames would need ~5.6 s. The sliding
// path does the same work in a few milliseconds; the bound is two orders of
// magnitude clear of both, so it is not a thin timing margin.
plot::data::StreamSeries s(1u << 20, 0.0, 1.0);
constexpr u64 kSaturate = 1u << 20;
for (u64 i = 0; i < kSaturate; ++i) s.push(f32(i % 977));
s.acquire();
constexpr u64 kFrames = 128;
constexpr u64 kFrame = 1u << 13;
const auto t0 = std::chrono::steady_clock::now();
for (u64 f = 0; f < kFrames; ++f) {
for (u64 k = 0; k < kFrame; ++k) s.push(f32((kSaturate + f * kFrame + k) % 977));
s.acquire();
}
const f64 ms =
std::chrono::duration<f64>(std::chrono::steady_clock::now() - t0).count() * 1.0e3;
PLOT_CHECK(s.size() == 1u << 20);
PLOT_CHECK(ms < 250.0);
}
PLOT_TEST(stream_series_pyramid_memory_is_bounded_by_the_window) {
// 50M samples through a 1M-sample ring: the pyramid's footprint must track the
// retained window (n/64 level-0 buckets plus the levels over them), not the
@@ -389,15 +374,16 @@ PLOT_TEST(gather_at_a_lod_level_reaches_both_ends_of_the_window) {
const auto check_ends = [&](u32 level) {
std::vector<f32> out;
s.gather(level, 0, n, out);
s.gather(level, 0, n, s.x0(), out);
PLOT_CHECK(out.size() >= 8u);
// Oldest: the first emitted pair is the retained sample at index 0, not the
// first whole bucket (which starts `lead` samples later).
PLOT_CHECK_NEAR(out[0], f32(s.x0()), 1e-9);
// first whole bucket (which starts `lead` samples later). x is relative to
// x0(), so it emits 0 (relative-x contract).
PLOT_CHECK_NEAR(out[0], 0.0, 1e-9);
PLOT_CHECK_NEAR(out[1], 25.0, 1e-6);
// Newest: the last emitted pair is the sample at index n-1, not the last
// complete bucket.
PLOT_CHECK_NEAR(out[out.size() - 2], f32(s.x0() + f64(n - 1) * 1e-6), 1e-9);
PLOT_CHECK_NEAR(out[out.size() - 2], f64(n - 1) * 1e-6, 1e-9);
PLOT_CHECK_NEAR(out[out.size() - 1], -25.0, 1e-6);
};
PLOT_CHECK(s.pyramid().level_size(1) >= 2);
@@ -417,9 +403,453 @@ 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::data::ArraySeries a(y, 0.0, 1e-6);
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_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() - 1], -25.0, 1e-6);
}
// --- B3: gather never emits geometry from outside [begin, end) ---------------
PLOT_TEST(neighbours_exact_hits_collapse_to_one_index) {
// The contract says the bracket degenerates to (k, k) when x lands exactly on
// a sample -- at the first sample too, where the old floor-only path answered
// (0, 1) -- and stays (k, k+1) strictly between two samples.
plot::data::ArraySeries s(std::vector<f32>{1, 2, 3, 4, 5}, 10.0, 0.25);
u64 i0 = 9, i1 = 9;
PLOT_CHECK(s.neighbours(s.x_at(0), i0, i1)); // first: was (0, 1)
PLOT_CHECK(i0 == 0 && i1 == 0);
PLOT_CHECK(s.neighbours(s.x_at(2), i0, i1)); // middle
PLOT_CHECK(i0 == 2 && i1 == 2);
PLOT_CHECK(s.neighbours(s.x_at(4), i0, i1)); // last
PLOT_CHECK(i0 == 4 && i1 == 4);
PLOT_CHECK(s.neighbours(s.x_at(1) + 0.1, i0, i1)); // strictly between
PLOT_CHECK(i0 == 1 && i1 == 2);
// A step whose k*dx does not round-trip through the division: the exact-hit
// test must compare samples, not trust t's fraction being an integer.
plot::data::ArraySeries t(std::vector<f32>{0, 1, 2, 3, 4, 5, 6, 7}, -1.0, 0.1);
for (u64 i = 0; i < 8; ++i) {
PLOT_CHECK(t.neighbours(t.x_at(i), i0, i1));
PLOT_CHECK(i0 == i && i1 == i);
}
PLOT_CHECK(t.neighbours((t.x_at(3) + t.x_at(4)) / 2.0, i0, i1));
PLOT_CHECK(i0 == 3 && i1 == 4);
}
PLOT_TEST(neighbours_outside_and_empty_are_false) {
plot::data::ArraySeries s(std::vector<f32>{1, 2, 3}, 0.0, 1.0);
u64 i0 = 7, i1 = 7;
PLOT_CHECK(!s.neighbours(-0.5, i0, i1)); // left of the window
PLOT_CHECK(!s.neighbours(3.5, i0, i1)); // right of the window
PLOT_CHECK(!s.neighbours(std::nan(""), i0, i1));
PLOT_CHECK(i0 == 7 && i1 == 7); // untouched on false
plot::data::ArraySeries e({}, 0.0, 1.0);
PLOT_CHECK(!e.neighbours(1.0, i0, i1));
PLOT_CHECK(e.snap_index(1.0) == 0); // empty series snaps to 0
}
PLOT_TEST(gather_envelope_stays_inside_an_unaligned_range) {
// The defect this pins: the first bucket was floor-selected and the bucket
// containing `end - 1` was emitted whole, so an unaligned range drew
// aggregates covering samples outside it. The samples outside [begin, end)
// here are extreme on purpose: any envelope value beyond the exact reduce is
// geometry that leaked in from outside the range.
constexpr u64 kN = 4096;
std::vector<f32> y(kN);
for (u64 i = 0; i < kN; ++i) {
y[i] = i < 1000 || i >= 3000 ? (i % 2 == 0 ? 1000.0f : -1000.0f)
: 0.25f * f32(i % 4) - 0.375f;
}
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
const u64 b = 1000 + 37, e = 3000 - 61; // unaligned on every level's grid
const plot::Range exact = s.reduce(b, e); // |values| <= ~0.375 inside the range
PLOT_CHECK(exact.max < 1.0 && exact.min > -1.0);
const auto check_range = [&](u32 level) {
std::vector<f32> out;
s.gather(level, b, e, s.x0(), out);
PLOT_CHECK(out.size() >= 4u);
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(e));
}
for (plot::usize i = 1; i < out.size(); i += 2) {
PLOT_CHECK(f64(out[i]) >= exact.min - 1e-6);
PLOT_CHECK(f64(out[i]) <= exact.max + 1e-6);
}
};
check_range(1);
check_range(2);
check_range(3);
check_range(6);
// And the envelope values are not merely *inside* the range: each emitted
// (min, max) pair equals the exact reduce over the samples that pair's two x
// positions bracket. Walk the layout the contract fixes -- raw lead pairs,
// two pairs per complete bucket, raw trail pairs -- so the raw edge spans
// are not mistaken for envelope pairs.
const u32 level = 2;
const u64 span = u64{kBucket} << level;
const u64 lend = s.pyramid().level_size(level);
const u64 first = std::min(b / span + (b % span == 0 ? u64{0} : u64{1}), lend);
const u64 last = std::min(e / span, lend);
const u64 lead = first * span;
const u64 buckets = last - first;
PLOT_CHECK(lead > b && last * span < e); // both edge spans really are partial
std::vector<f32> out;
s.gather(level, b, e, s.x0(), out);
PLOT_CHECK(out.size() == 2 * ((lead - b) + 2 * buckets + (e - last * span)));
plot::usize o = 0;
for (u64 i = b; i < lead; ++i, o += 2) { // the partial leading bucket, raw
PLOT_CHECK_NEAR(out[o], f32(f64(i)), 1e-6);
PLOT_CHECK(f64(out[o + 1]) >= exact.min - 1e-6 && f64(out[o + 1]) <= exact.max + 1e-6);
}
for (u64 j = 0; j < buckets; ++j) {
const u64 bi = first + j;
const u64 lo = bi * span;
const u64 hi = std::min((bi + 1) * span, e) - 1;
const plot::Range r = s.reduce(lo, hi + 1);
PLOT_CHECK_NEAR(out[o], f32(f64(lo)), 1e-6);
PLOT_CHECK_NEAR(out[o + 1], r.min, 1e-6);
PLOT_CHECK_NEAR(out[o + 2], f32(f64(hi)), 1e-6);
PLOT_CHECK_NEAR(out[o + 3], r.max, 1e-6);
o += 4;
}
for (u64 i = last * span; i < e; ++i, o += 2) { // the partial trailing bucket
PLOT_CHECK_NEAR(out[o], f32(f64(i)), 1e-6);
PLOT_CHECK(f64(out[o + 1]) >= exact.min - 1e-6 && f64(out[o + 1]) <= exact.max + 1e-6);
}
PLOT_CHECK(o == out.size());
}
PLOT_TEST(stream_gather_envelope_stays_inside_an_unaligned_range) {
// Same invariant on a sliding window whose base is off the bucket grid. The
// tame zone covers every sample the queried range reaches -- window [3808,
// 12000) queried at window indices [300, n-77), absolute [4108, 11923) -- and
// the extremes sit strictly before it, from 3808 to 4000.
const auto value = [](u64 i) {
return i < 4000 ? (i % 2 == 0 ? 500.0f : -500.0f)
: 0.125f * f32(i % 5) - 0.25f;
};
constexpr u64 kPushes = 12000;
plot::data::StreamSeries s(1u << 13, 0.0, 1.0); // window of 8192, base 3816
for (u64 i = 0; i < kPushes; ++i) s.push(value(i));
s.acquire();
const u64 n = s.size();
const u64 base = kPushes - n;
PLOT_CHECK((base % kBucket) != 0); // window starts mid-bucket
const u64 b = 300, e = n - 77; // unaligned inside the window
const plot::Range exact = s.reduce(b, e);
PLOT_CHECK(exact.max < 1.0 && exact.min > -1.0);
std::vector<f32> out;
s.gather(1, b, e, s.x0(), out);
PLOT_CHECK(out.size() >= 4u);
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(e));
}
for (plot::usize i = 1; i < out.size(); i += 2) {
PLOT_CHECK(f64(out[i]) >= exact.min - 1e-6);
PLOT_CHECK(f64(out[i]) <= exact.max + 1e-6);
}
}
// --- B7: non-finite samples are excluded from every aggregate ----------------
PLOT_TEST(nan_samples_are_excluded_from_bounds_and_sums) {
// NaN at the first, middle and last positions: bounds and reduce stay finite,
// sums skip the NaN samples entirely.
std::vector<f32> y{std::nanf(""), 1.0f, 2.0f, std::nanf(""), 3.0f, 4.0f,
std::nanf("")};
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
PLOT_CHECK_NEAR(s.y_bounds().min, 1.0, 1e-9);
PLOT_CHECK_NEAR(s.y_bounds().max, 4.0, 1e-9);
const plot::Range r = s.reduce(0, 7);
PLOT_CHECK_NEAR(r.min, 1.0, 1e-9);
PLOT_CHECK_NEAR(r.max, 4.0, 1e-9);
PLOT_CHECK_NEAR(s.sum(0, 7), 10.0, 1e-9);
PLOT_CHECK_NEAR(s.sumsq(0, 7), 30.0, 1e-9);
// A range that is *all* NaN: zero-span, zero sums.
PLOT_CHECK(s.reduce(0, 1).span() == 0.0);
PLOT_CHECK(s.sum(0, 1) == 0.0);
PLOT_CHECK(s.sumsq(0, 1) == 0.0);
// And a NaN-only window reports a zero-span Range, not NaN.
PLOT_CHECK(s.reduce(5, 7).span() == 0.0);
}
PLOT_TEST(stream_series_bounds_skip_nan_samples) {
// acquire()'s bounds go through the same exact folds, so a stream with NaN
// gaps publishes finite bounds -- and NaN at the edges of the window too.
plot::data::StreamSeries s(256, 0.0, 1.0);
s.push(std::nanf("")); // oldest retained sample
for (int i = 0; i < 254; ++i) s.push(i % 2 == 0 ? -3.0f : 7.0f);
s.push(std::nanf("")); // newest sample
s.acquire();
PLOT_CHECK(s.size() == 256);
PLOT_CHECK_NEAR(s.y_bounds().min, -3.0, 1e-6);
PLOT_CHECK_NEAR(s.y_bounds().max, 7.0, 1e-6);
PLOT_CHECK_NEAR(s.reduce(0, s.size()).max, 7.0, 1e-6);
PLOT_CHECK_NEAR(s.sum(0, s.size()), 127.0 * 4.0, 1e-3);
}
PLOT_TEST(window_without_finite_samples_aggregates_to_zero) {
plot::data::ArraySeries s(std::vector<f32>(8, std::nanf("")), 0.0, 1.0);
PLOT_CHECK(s.y_bounds().span() == 0.0);
PLOT_CHECK(s.reduce(0, 8).span() == 0.0);
PLOT_CHECK(s.sum(0, 8) == 0.0);
PLOT_CHECK(s.sumsq(0, 8) == 0.0);
// x bounds stay the grid's: the window exists even though nothing is finite.
PLOT_CHECK_NEAR(s.x_bounds().min, 0.0, 1e-9);
PLOT_CHECK_NEAR(s.x_bounds().max, 7.0, 1e-9);
}
// --- R3: any u32 gather level is safe ----------------------------------------
PLOT_TEST(gather_level_beyond_the_pyramid_clamps) {
// R3: `kBucket << level` used to shift by >= 64 for a public level past 57
// (undefined). Now any u32 level is accepted and clamped to the deepest one.
std::vector<f32> y(8192);
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);
std::vector<f32> deepest;
s.gather(plot::data::kMaxPyramidLevel, 0, s.size(), s.x0(), deepest);
PLOT_CHECK(!deepest.empty());
for (const u32 level : {u32{58}, u32{200}, u32{1000}, ~u32{0}}) {
std::vector<f32> out;
s.gather(level, 0, s.size(), s.x0(), out);
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.
PLOT_CHECK(plot::data::kMaxPyramidLevel == 57);
PLOT_CHECK((u64{kBucket} << plot::data::kMaxPyramidLevel) == (u64{1} << 63));
}
// --- R4: the ring's capacity is the rounded one -------------------------------
PLOT_TEST(stream_series_capacity_is_rounded_up_and_pre_sized) {
// StreamSeries(100) retains 128 samples: the ring rounds up, size() saturates
// at the rounded capacity, and the pyramid was reserved for it (a saturated
// stream must not grow the level table mid-acquire).
plot::data::StreamSeries s(100, 0.0, 1.0);
for (int i = 0; i < 1000; ++i) s.push(f32(i));
s.acquire();
PLOT_CHECK(s.size() == 128);
PLOT_CHECK_NEAR(s.y_bounds().max, 999.0, 1e-4);
PLOT_CHECK_NEAR(s.x_bounds().max, s.x0() + 127.0, 1e-9);
const plot::Range r = s.reduce(0, s.size());
PLOT_CHECK_NEAR(r.min, 872.0, 1e-4); // the oldest retained sample
}
// --- GatherMode: Envelope / Nearest / Mean ------------------------------------
PLOT_TEST(gather_modes_envelope_nearest_mean) {
// 4096 samples of a sawtooth, level 2 (256-sample buckets, 16 of them).
constexpr u64 kN = 4096;
const auto value = [](u64 i) { return f32(i % 7) - 3.0f; };
std::vector<f32> y(kN);
for (u64 i = 0; i < kN; ++i) y[i] = value(i);
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
const u32 level = 2;
const u64 span = u64{kBucket} << level;
const u64 buckets = kN / span;
PLOT_CHECK(buckets == 16);
std::vector<f32> env, near, mean;
s.gather(level, 0, kN, s.x0(), env, plot::data::GatherMode::Envelope);
s.gather(level, 0, kN, s.x0(), near, plot::data::GatherMode::Nearest);
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
// its last (this sawtooth's per-bucket min is its first sample's value and
// its max its last sample's value).
PLOT_CHECK(env.size() == 4 * buckets);
// Nearest and Mean: one point per bucket, x at the bucket's centre sample.
PLOT_CHECK(near.size() == 2 * buckets);
PLOT_CHECK(mean.size() == 2 * buckets);
for (u64 j = 0; j < buckets; ++j) {
const u64 lo = j * span;
const u64 centre = lo + span / 2;
const f64 centre_rel = f64(centre); // dx == 1, relative to x0() == 0
// x positions are relative and land where the mode says.
PLOT_CHECK_NEAR(env[4 * j], f64(lo), 1e-6);
PLOT_CHECK_NEAR(env[4 * j + 2], f64(lo + span - 1), 1e-6);
PLOT_CHECK_NEAR(near[2 * j], centre_rel, 1e-6);
PLOT_CHECK_NEAR(mean[2 * j], centre_rel, 1e-6);
// Envelope y is the exact per-bucket min/max.
const plot::Range r = s.reduce(lo, lo + span);
PLOT_CHECK_NEAR(env[4 * j + 1], r.min, 1e-6);
PLOT_CHECK_NEAR(env[4 * j + 3], r.max, 1e-6);
// Nearest y is the raw centre sample.
PLOT_CHECK_NEAR(near[2 * j + 1], value(centre), 1e-6);
// Mean y is the bucket's mean.
const f64 want_mean = s.sum(lo, lo + span) / f64(span);
PLOT_CHECK_NEAR(mean[2 * j + 1], want_mean, 1e-6);
}
// Level 0 and sub-bucket ranges are the raw samples for every mode.
std::vector<f32> raw;
s.gather(0, 5, 300, s.x0(), raw, plot::data::GatherMode::Mean);
PLOT_CHECK(raw.size() == 2 * (300 - 5));
PLOT_CHECK_NEAR(raw[0], 5.0, 1e-9);
}
PLOT_TEST(gather_skips_all_nan_buckets) {
// Level-1 bucket 1 (samples [128, 256)) is entirely NaN: the exact queries
// treat it as absent, the gathered geometry emits nothing for it in Envelope
// and Mean, and Nearest draws its centre sample raw -- NaN, as level 0 would.
std::vector<f32> y(384, 1.0f);
for (u64 i = 128; i < 256; ++i) y[i] = std::nanf("");
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
PLOT_CHECK(s.reduce(128, 256).span() == 0.0);
PLOT_CHECK(s.sum(128, 256) == 0.0);
const plot::Range r = s.reduce(0, 384);
PLOT_CHECK_NEAR(r.min, 1.0, 1e-9);
PLOT_CHECK_NEAR(r.max, 1.0, 1e-9);
// first = 0, last = 256/128 = 2: buckets 0 and 1, no lead/trail raw spans.
std::vector<f32> env, mean, near;
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_NEAR(env[0], 0.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[3], 1.0, 1e-9);
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_NEAR(mean[0], 64.0, 1e-9);
PLOT_CHECK_NEAR(mean[1], 1.0, 1e-9);
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(near[0], 64.0, 1e-9);
PLOT_CHECK_NEAR(near[1], 1.0, 1e-9);
PLOT_CHECK_NEAR(near[2], 192.0, 1e-9); // bucket 1's centre, drawn raw
PLOT_CHECK(std::isnan(near[3]));
}
// --- Snapshot: StreamSeries::freeze() -----------------------------------------
PLOT_TEST(stream_series_freeze_copies_the_last_acquire_window) {
plot::data::StreamSeries s(1024, 5.0, 0.5);
for (u64 i = 0; i < 400; ++i) s.push(f32(i % 31));
s.acquire();
const u64 n = s.size();
const plot::Range bounds = s.y_bounds();
std::vector<f32> gathered;
s.gather(1, 0, n, s.x0(), gathered);
const plot::Range reduced = s.reduce(0, n);
// Freeze now, while the ring still holds exactly the published window.
const plot::data::ArraySeries frozen = s.freeze();
PLOT_CHECK(frozen.size() == n);
PLOT_CHECK_NEAR(frozen.x0(), 5.0, 1e-12);
PLOT_CHECK_NEAR(frozen.dx(), 0.5, 1e-12);
PLOT_CHECK_NEAR(frozen.y_bounds().min, bounds.min, 1e-6);
PLOT_CHECK_NEAR(frozen.y_bounds().max, bounds.max, 1e-6);
const plot::Range fr = frozen.reduce(0, n);
PLOT_CHECK_NEAR(fr.min, reduced.min, 1e-6);
PLOT_CHECK_NEAR(fr.max, reduced.max, 1e-6);
PLOT_CHECK_NEAR(frozen.sum(0, n), s.sum(0, n), 1e-3);
std::vector<f32> frozen_gathered;
frozen.gather(1, 0, n, frozen.x0(), frozen_gathered);
PLOT_CHECK(frozen_gathered == gathered); // same geometry, byte for byte
// The producer now laps the ring (700 more pushes overwrite the oldest
// slots) and a later acquire() grows the published window -- the frozen copy
// answers from its own samples and never moves.
for (u64 i = 0; i < 700; ++i) s.push(-1000.0f);
s.acquire();
PLOT_CHECK(s.size() == 1024);
PLOT_CHECK(frozen.size() == n);
PLOT_CHECK_NEAR(frozen.y_bounds().max, bounds.max, 1e-6);
PLOT_CHECK_NEAR(frozen.y_bounds().min, bounds.min, 1e-6);
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() ----------------------------------
PLOT_TEST(array_series_push_matches_commit) {
// The incremental path must land exactly where the wholesale path does:
// bounds, x bounds, exact queries and the pyramid's buckets themselves.
std::vector<f32> y;
y.reserve(1000);
for (u64 i = 0; i < 1000; ++i) {
y.push_back(i % 97 == 0 ? std::nanf("") : f32((i * 37) % 251) - 125.0f);
}
plot::data::ArraySeries pushed({}, 10.0, 0.25);
for (const f32 v : y) pushed.push(v);
const plot::data::ArraySeries committed(y, 10.0, 0.25);
PLOT_CHECK(pushed.size() == committed.size());
PLOT_CHECK_NEAR(pushed.x_bounds().min, committed.x_bounds().min, 1e-12);
PLOT_CHECK_NEAR(pushed.x_bounds().max, committed.x_bounds().max, 1e-12);
PLOT_CHECK_NEAR(pushed.y_bounds().min, committed.y_bounds().min, 1e-6);
PLOT_CHECK_NEAR(pushed.y_bounds().max, committed.y_bounds().max, 1e-6);
for (u64 a = 0; a < 1000; a += 173) {
for (u64 b = a + 1; b <= 1000; b += 331) {
const plot::Range ra = pushed.reduce(a, b);
const plot::Range rb = committed.reduce(a, b);
PLOT_CHECK_NEAR(ra.min, rb.min, 1e-9);
PLOT_CHECK_NEAR(ra.max, rb.max, 1e-9);
PLOT_CHECK_NEAR(pushed.sum(a, b), committed.sum(a, b), 1e-9);
PLOT_CHECK_NEAR(pushed.sumsq(a, b), committed.sumsq(a, b), 1e-9);
}
}
for (u32 l = 0; l < 3; ++l) {
PLOT_CHECK(pushed.pyramid().level_base(l) == committed.pyramid().level_base(l));
PLOT_CHECK(pushed.pyramid().level_size(l) == committed.pyramid().level_size(l));
for (u64 b = pushed.pyramid().level_base(l);
b < pushed.pyramid().level_base(l) + pushed.pyramid().level_size(l); ++b) {
const plot::data::MinMax ma = pushed.pyramid().bucket(l, b);
const plot::data::MinMax mb = committed.pyramid().bucket(l, b);
PLOT_CHECK_NEAR(ma.min, mb.min, 1e-9);
PLOT_CHECK_NEAR(ma.max, mb.max, 1e-9);
PLOT_CHECK_NEAR(pushed.pyramid().bucket_sum(l, b), committed.pyramid().bucket_sum(l, b),
1e-9);
PLOT_CHECK(pushed.pyramid().bucket_count(l, b) == committed.pyramid().bucket_count(l, b));
}
}
}
+198 -3
View File
@@ -2,6 +2,7 @@
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "harness.hpp"
#include <plot/plot.hpp>
#include <plot/plot/ticks.hpp>
#include <cmath>
#include <cstdlib>
@@ -97,10 +98,111 @@ PLOT_TEST(minor_ticks_subdivide_and_the_total_is_capped) {
PLOT_CHECK(std::fabs(tick.value * scale - std::round(tick.value * scale)) < 1e-9);
PLOT_CHECK(tick.major ? !tick.label.empty() : tick.label.empty());
}
// A range dense enough to want millions of minors stops at the hard cap.
}
/// R9: the cap counts **minors only**. A range dense enough to want more minors
/// than fit is truncated in its subdivisions and never in its labels: every major
/// of the span is present, labelled, and the far end of the axis keeps its number.
PLOT_TEST(minor_cap_spares_the_majors) {
std::vector<plot::Tick> capped;
plot::generate_ticks({0.0, 1e9}, 1e6f, 1.0f, capped, /*include_minors=*/true);
PLOT_CHECK(capped.size() == plot::kMaxTicks);
// Step 1000 over [0, 65000] (65 major intervals): 260 minors want in, more than
// the 256 the cap holds, and every one of the 66 majors still counts.
plot::generate_ticks({0.0, 65000.0}, 65000.0f, 1000.0f, capped, /*include_minors=*/true);
plot::usize majors = 0;
plot::usize minors = 0;
for (const plot::Tick& tick : capped) {
if (tick.major) {
++majors;
} else {
++minors;
}
}
PLOT_CHECK(minors == plot::kMaxTicks); // minors capped exactly as before
PLOT_CHECK(majors == 66u); // every major of the span kept
for (plot::usize i = 1; i < capped.size(); ++i) PLOT_CHECK(capped[i].value > capped[i - 1].value);
// The far end of the axis still carries its label.
PLOT_CHECK(capped.back().major);
PLOT_CHECK(!capped.back().label.empty());
PLOT_CHECK_NEAR(capped.back().value, 65000.0, 1e-9);
}
/// B2: the major/minor split is an integer-index test, so an epoch origin with a
/// small step no longer fights a ULP-sized residue: majors are classified exactly,
/// whatever the origin's magnitude. The +/-300 s window is the reported defect's
/// shape; the sub-second window is where the old float-tolerance test actually
/// misclassified (step 0.01 at 1.75e9 carries an error far above step*1e-9).
PLOT_TEST(epoch_window_yields_labelled_majors) {
std::vector<plot::Tick> t;
plot::generate_ticks({1.75e9 - 300.0, 1.75e9 + 300.0}, 800.0f, 60.0f, t,
/*include_minors=*/true);
plot::usize majors = 0;
for (const plot::Tick& tick : t) {
if (!tick.major) continue;
++majors;
PLOT_CHECK(!tick.label.empty());
PLOT_CHECK(tick.value >= 1.75e9 - 300.0);
PLOT_CHECK(tick.value <= 1.75e9 + 300.0);
}
// Step 50 s over a 600 s window: the labelled ticks span it.
PLOT_CHECK(majors >= 11);
std::vector<plot::Tick> deep;
plot::generate_ticks({1.75e9 - 0.05, 1.75e9 + 0.05}, 800.0f, 60.0f, deep,
/*include_minors=*/true);
plot::usize deep_majors = 0;
for (const plot::Tick& tick : deep) {
if (!tick.major) continue;
++deep_majors;
PLOT_CHECK(!tick.label.empty());
}
// Step 0.01 s over a 0.1 s window: again a full set of labelled majors.
PLOT_CHECK(deep_majors >= 9);
}
/// The tick that *means* zero reads `0`. The walk starts at `ceil(lo/stride)*stride`,
/// inexact for every stride that is not a power of two, so a range straddling the
/// origin used to reach a few e-16 there and label it from the far end of the SI
/// table: `-1 -500m -139a 500m 1`. The tolerance is relative to the stride, which is
/// also the smallest magnitude a non-zero tick can have, so a range whose ticks are
/// *legitimately* that small keeps them.
PLOT_TEST(the_tick_at_the_origin_reads_zero) {
std::vector<plot::Tick> t;
const float widths[] = {80.0f, 40.0f, 10.0f};
for (const float min_px : widths) {
for (const bool minors : {false, true}) {
plot::generate_ticks({-1.0, 1.0}, 400.0f, min_px, t, minors);
plot::usize zeros = 0;
for (const plot::Tick& tick : t) {
if (!tick.major) continue;
PLOT_CHECK(!tick.label.empty());
// Nothing between -500m and 500m but the origin itself, so no major may
// carry a prefix from below `m`.
PLOT_CHECK(tick.label.find('a') == std::string::npos);
PLOT_CHECK(tick.label.find('f') == std::string::npos);
if (tick.label == "0") {
++zeros;
PLOT_CHECK(tick.value == 0.0); // exact, so a Time axis relabels it as 0 too
}
}
PLOT_CHECK(zeros == 1u);
}
}
// A range whose ticks really are atto-scale is untouched: only the origin folds.
plot::generate_ticks({-2e-16, 2e-16}, 400.0f, 80.0f, t, /*include_minors=*/true);
plot::usize tiny = 0;
plot::usize tiny_zeros = 0;
for (const plot::Tick& tick : t) {
if (!tick.major) continue;
if (tick.value == 0.0) {
++tiny_zeros;
} else {
++tiny;
PLOT_CHECK(tick.label.find('a') != std::string::npos);
}
}
PLOT_CHECK(tiny_zeros == 1u);
PLOT_CHECK(tiny >= 2u);
}
PLOT_TEST(si_labels_are_utf8_and_never_overrun) {
@@ -134,6 +236,11 @@ PLOT_TEST(si_labels_are_utf8_and_never_overrun) {
PLOT_TEST(format_time_shapes) {
char b[32];
plot::format_time(0.5, b, sizeof b); PLOT_CHECK(std::string(b) == "500ms");
plot::format_time(0.005, b, sizeof b); PLOT_CHECK(std::string(b) == "5ms");
plot::format_time(0.0005, b, sizeof b); PLOT_CHECK(std::string(b) == "500\u00b5s");
plot::format_time(5.0e-6, b, sizeof b); PLOT_CHECK(std::string(b) == "5\u00b5s");
plot::format_time(5.0e-9, b, sizeof b); PLOT_CHECK(std::string(b) == "5ns");
plot::format_time(5.0e-12, b, sizeof b); PLOT_CHECK(std::string(b) == "5ps");
plot::format_time(1.5, b, sizeof b); PLOT_CHECK(std::string(b) == "1.5s");
plot::format_time(90.0, b, sizeof b); PLOT_CHECK(std::string(b) == "1:30");
plot::format_time(3661.0, b, sizeof b); PLOT_CHECK(std::string(b) == "1:01:01");
@@ -156,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(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.
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,
@@ -167,3 +286,79 @@ PLOT_TEST(format_time_never_lets_a_field_reach_its_radix) {
check_no_field_reaches_its_radix(label);
}
}
// An axis origin shifts what the labels *say* and which grid the ticks sit on: a window
// zoomed into 12.345 s must read `0, 0.5ms, 1ms` against its own left edge, because three
// significant digits cannot tell 12.345671 s from 12.345672 s. A host prints the origin once
// beside the axis and the user adds.
PLOT_TEST(ticks_are_written_relative_to_an_axis_origin) {
// The same window twice: absolute, and relative to its own start.
const plot::Range window{12.345, 12.346};
std::vector<plot::Tick> absolute;
plot::generate_ticks(window, 400.0f, 80.0f, absolute, /*include_minors=*/false, 0.0);
PLOT_CHECK(!absolute.empty());
PLOT_CHECK(absolute.front().value >= window.min);
std::vector<plot::Tick> relative;
plot::generate_ticks(window, 400.0f, 80.0f, relative, /*include_minors=*/false, window.min);
PLOT_CHECK(!relative.empty());
// The walk starts on the origin's grid: the first major is the origin itself, and every
// major is an exact multiple of the step away from it.
PLOT_CHECK_NEAR(relative.front().value, window.min, 1e-12);
PLOT_CHECK_NEAR(relative[1].value - relative[0].value,
(absolute[1].value - absolute[0].value), 1e-12);
for (const plot::Tick& t : relative) {
PLOT_CHECK(t.value >= window.min - 1e-12);
PLOT_CHECK(t.value <= window.max + 1e-12);
}
// And through an axis: the *labels* are the relative values, the positions the absolute
// ones. That is the whole contract -- `format_value()` is where the origin is applied.
plot::Plot p;
p.set_viewport({0, 0, 800, 400});
const u32 ax = p.add_x_axis({"time", window, false, plot::AxisPlace::Bottom, false,
plot::TickFormat::Time});
p.x_axis(ax);
PLOT_CHECK_NEAR(p.x_axis_origin(ax), 0.0, 1e-12);
p.set_x_axis_origin(ax, window.min);
PLOT_CHECK_NEAR(p.x_axis_origin(ax), window.min, 1e-12);
char buf[64];
p.x_axis(ax).format_value(window.min, buf, sizeof buf);
PLOT_CHECK(std::string(buf) == "0"); // the window's start reads zero
p.x_axis(ax).format_value(window.min + 0.0005, buf, sizeof buf);
PLOT_CHECK(std::string(buf) == "500\u00b5s"); // half a millisecond reads in microseconds
// An id that names no axis is ignored; the axis that exists is untouched by it.
p.set_x_axis_origin(7u, 1234.0);
PLOT_CHECK_NEAR(p.x_axis_origin(ax), window.min, 1e-12);
}
// The moving-window case, which is why the mode exists: the origin is re-taken from the range
// at refresh, so the tick on the left edge reads exactly zero however far the window has
// scrolled since the host last looked. A host cannot predict the edge (the follow places it
// from the newest sample), and a value it wrote a frame earlier is a frame of scroll stale --
// at 1 MS/s that is more than a millisecond-scale window shows.
PLOT_TEST(a_range_origin_puts_the_zero_tick_on_the_left_edge) {
plot::Plot p;
p.set_viewport({0, 0, 800, 400});
const plot::Range window{12.345, 12.347};
const u32 ax = p.add_x_axis({"time", window, false, plot::AxisPlace::Bottom, false,
plot::TickFormat::Time});
p.set_x_axis_origin_from_range(ax, true);
// `layout()` is the library's own path to the ticks (it ends in `refresh_ticks()`), so this
// is the order a frame really runs in.
p.layout();
PLOT_CHECK(!p.x_axis(ax).ticks.empty());
const plot::Tick& first = p.x_axis(ax).ticks.front();
PLOT_CHECK(first.major);
PLOT_CHECK_NEAR(f64(first.value), window.min, 1e-12); // exactly the left edge ...
PLOT_CHECK(first.label == "0"); // ... and it reads zero
// The window slides: the origin follows it, so the zero tick is on the *new* edge.
const plot::Range moved{12.6, 12.602};
p.set_x_range(ax, moved);
p.layout();
PLOT_CHECK_NEAR(f64(p.x_axis(ax).ticks.front().value), moved.min, 1e-12);
PLOT_CHECK(p.x_axis(ax).ticks.front().label == "0");
PLOT_CHECK_NEAR(p.x_axis_origin(ax), moved.min, 1e-12); // and the host reads it back
}
+274
View File
@@ -0,0 +1,274 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "harness.hpp"
#include <plot/data/array_series.hpp>
#include <plot/data/xy_series.hpp>
#include <algorithm>
#include <cmath>
#include <random>
#include <vector>
using plot::data::kBucket;
namespace {
/// Monotone but decidedly non-uniform x: early samples a millisecond apart,
/// later ones seconds apart. Exact in f64, no fixed dx anywhere.
std::vector<f64> spread_x(u64 n) {
std::vector<f64> x;
x.reserve(static_cast<plot::usize>(n));
f64 v = 0.0;
for (u64 i = 0; i < n; ++i) {
x.push_back(v);
v += 0.001 + f64(i) * 0.01;
}
return x;
}
std::vector<f32> saw_y(u64 n) {
std::vector<f32> y;
y.reserve(static_cast<plot::usize>(n));
for (u64 i = 0; i < n; ++i) y.push_back(f32((i * 13) % 97) - 48.0f);
return y;
}
} // namespace
PLOT_TEST(xy_series_reports_its_own_grid) {
auto x = spread_x(500);
const std::vector<f64> xs = x; // keep an un-moved copy for the checks
plot::data::XYSeries s(std::move(x), saw_y(500));
PLOT_CHECK(s.size() == 500);
PLOT_CHECK_NEAR(s.x0(), xs.front(), 1e-12);
PLOT_CHECK(s.dx() == 0.0); // meaningless by contract; nothing reads it
for (const u64 i : {u64{0}, u64{1}, u64{250}, u64{499}}) {
PLOT_CHECK_NEAR(s.x_at(i), xs[static_cast<plot::usize>(i)], 1e-12);
}
// x_bounds come from the array's ends, wherever they sit on the number line.
PLOT_CHECK_NEAR(s.x_bounds().min, xs.front(), 1e-12);
PLOT_CHECK_NEAR(s.x_bounds().max, xs.back(), 1e-12);
PLOT_CHECK_NEAR(s.y_bounds().min, -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
// 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);
const std::vector<f64> es = epoch; // un-moved copy for the expectations
for (auto& v : epoch) v += 1.7e9;
plot::data::XYSeries e(std::move(epoch), saw_y(500));
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);
std::vector<f32> out;
e.gather(0, 0, 500, e.x0(), out);
PLOT_CHECK(out.size() == 1000);
// 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
// the epoch array itself, since that is the subtraction the contract does.
PLOT_CHECK_NEAR(out[0], 0.0, 1e-9); // x_at(0) - x0()
PLOT_CHECK_NEAR(out[2], f32(es[1] - es[0]), 1e-6);
PLOT_CHECK_NEAR(out[998], f32(es[499] - es[0]), 1e-3); // span, not 1.7e9
PLOT_CHECK(out[998] < 2000.0f);
}
PLOT_TEST(xy_series_neighbour_lookups_crosshair_style) {
// The crosshair asks neighbours() at an arbitrary x and reports whichever of
// the two samples it gets back is nearer. On a non-uniform grid that needs
// real searches: a uniform-grid formula would land megabytes off here.
auto x = spread_x(1000);
const std::vector<f64> xs = x;
plot::data::XYSeries s(std::move(x), saw_y(1000));
const auto at = [&](u64 i) { return xs[static_cast<plot::usize>(i)]; };
u64 i0 = 7, i1 = 7;
// Midway between samples 100 and 101: brackets those two.
const f64 mid = (at(100) + at(101)) / 2.0;
PLOT_CHECK(s.neighbours(mid, i0, i1));
PLOT_CHECK(i0 == 100);
PLOT_CHECK(i1 == 101);
// Exactly on a sample: both indices are that sample.
PLOT_CHECK(s.neighbours(at(250), i0, i1));
PLOT_CHECK(i0 == 250 && i1 == 250);
// Just left of a sample: still bracketed from below.
PLOT_CHECK(s.neighbours(std::nextafter(at(250), 0.0), i0, i1));
PLOT_CHECK(i0 == 249 && i1 == 250);
// Edges are inclusive.
PLOT_CHECK(s.neighbours(at(0), i0, i1));
PLOT_CHECK(i0 == 0 && i1 == 0);
PLOT_CHECK(s.neighbours(at(999), i0, i1));
PLOT_CHECK(i0 == 999 && i1 == 999); // exact hit on the last sample
// Outside the bounds (and NaN): false, indices untouched.
const u64 keep = 42;
i0 = i1 = keep;
PLOT_CHECK(!s.neighbours(at(0) - 1.0, i0, i1));
PLOT_CHECK(!s.neighbours(at(999) + 1.0, i0, i1));
PLOT_CHECK(!s.neighbours(std::nan(""), i0, i1));
PLOT_CHECK(i0 == keep && i1 == keep);
// Clustered duplicates: the contract is "last index <= x" and "first index
// >= x", so a run of equal x brackets the run's ends.
std::vector<f64> dup{1.0, 2.0, 2.0, 2.0, 3.0};
plot::data::XYSeries d(dup, {1.0f, 2.0f, 3.0f, 4.0f, 5.0f});
PLOT_CHECK(d.neighbours(2.0, i0, i1));
PLOT_CHECK(i0 == 3 && i1 == 1); // last <= 2 is index 3, first >= 2 is index 1
}
PLOT_TEST(xy_series_snap_index_finds_the_nearest_sample) {
auto x = spread_x(1000);
const std::vector<f64> xs = x;
plot::data::XYSeries s(std::move(x), saw_y(1000));
const auto at = [&](u64 i) { return xs[static_cast<plot::usize>(i)]; };
PLOT_CHECK(s.snap_index(at(400)) == 400); // exact hit
PLOT_CHECK(s.snap_index((at(400) + at(401)) / 2.0 + 1e-9) == 401);
PLOT_CHECK(s.snap_index((at(400) + at(401)) / 2.0 - 1e-9) == 400);
PLOT_CHECK(s.snap_index(at(0) - 100.0) == 0); // clamped
PLOT_CHECK(s.snap_index(at(999) + 100.0) == 999); // clamped
PLOT_CHECK(s.snap_index(std::nan("")) == 0); // NaN lands on 0
// A tie goes to the lower index (midpoints are impossible on this x, so make
// one explicitly).
std::vector<f64> tie{0.0, 10.0};
plot::data::XYSeries t(tie, {1.0f, 2.0f});
PLOT_CHECK(t.snap_index(5.0) == 0);
PLOT_CHECK(t.snap_index(5.0 + 1e-9) == 1);
}
PLOT_TEST(xy_series_pyramid_matches_array_series_over_the_same_y) {
// The pyramid buckets by sample index, exactly as for a uniform series, so an
// XYSeries over x = i*dx must answer every exact query identically to the
// ArraySeries over the same y -- and the bucket aggregates must agree too.
constexpr u64 kN = 3000;
std::mt19937 rng(11);
std::uniform_real_distribution<float> d(-50.0f, 50.0f);
std::vector<f32> y(kN);
for (auto& v : y) v = d(rng);
std::vector<f64> x(kN);
for (u64 i = 0; i < kN; ++i) x[i] = f64(i) * 0.5;
const plot::data::XYSeries xy(x, y);
const plot::data::ArraySeries uni(y, 0.0, 0.5);
PLOT_CHECK_NEAR(xy.x0(), uni.x0(), 1e-12);
for (u64 a = 0; a < kN; a += 211) {
for (u64 b = a + 1; b <= kN; b += 499) {
const plot::Range rx = xy.reduce(a, b);
const plot::Range ru = uni.reduce(a, b);
PLOT_CHECK_NEAR(rx.min, ru.min, 1e-6);
PLOT_CHECK_NEAR(rx.max, ru.max, 1e-6);
PLOT_CHECK_NEAR(xy.sum(a, b), uni.sum(a, b), 1e-6);
PLOT_CHECK_NEAR(xy.sumsq(a, b), uni.sumsq(a, b), 1e-6);
}
}
for (u32 l = 0; l < 3; ++l) {
PLOT_CHECK(xy.pyramid().level_base(l) == uni.pyramid().level_base(l));
PLOT_CHECK(xy.pyramid().level_size(l) == uni.pyramid().level_size(l));
for (u64 b = xy.pyramid().level_base(l);
b < xy.pyramid().level_base(l) + xy.pyramid().level_size(l); ++b) {
PLOT_CHECK_NEAR(xy.pyramid().bucket(l, b).min, uni.pyramid().bucket(l, b).min, 1e-6);
PLOT_CHECK_NEAR(xy.pyramid().bucket(l, b).max, uni.pyramid().bucket(l, b).max, 1e-6);
}
}
}
PLOT_TEST(xy_series_gather_envelope_matches_exact_reduces) {
// gather() buckets by index with the same B3 ceil/floor selection, but the x
// of every emitted point is the array's own coordinate relative to x0() --
// and each envelope pair equals the exact reduce over the samples its two x
// positions bracket.
constexpr u64 kN = 4096;
auto x = spread_x(kN);
const std::vector<f64> xs = x;
const std::vector<f32> ys = saw_y(kN);
plot::data::XYSeries s(std::move(x), ys);
const auto at = [&](u64 i) { return xs[static_cast<plot::usize>(i)]; };
const auto val = [&](u64 i) { return ys[static_cast<plot::usize>(i)]; };
const f64 x0 = at(0);
const u32 level = 2;
const u64 span = u64{kBucket} << level;
const u64 b = 500 + 33, e = 3500 - 57; // unaligned on the bucket grid
std::vector<f32> out;
s.gather(level, b, e, s.x0(), out, plot::data::GatherMode::Envelope);
PLOT_CHECK(out.size() >= 8u);
// 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).
const plot::Range exact = s.reduce(b, e);
f64 emitted_min = 1e30, emitted_max = -1e30;
for (plot::usize i = 0; i + 1 < out.size(); i += 2) {
const f64 rel = f64(out[i]);
// The emitted x is f32(x_at(i) - x0()), so allow the f32 cast's slack
// (relative positions here reach ~1.4e4, where f32 ULP is ~1e-3).
PLOT_CHECK(rel >= at(b) - x0 - 1e-3);
PLOT_CHECK(rel <= at(e - 1) - x0 + 1e-3);
emitted_min = std::min(emitted_min, f64(out[i + 1]));
emitted_max = std::max(emitted_max, f64(out[i + 1]));
}
PLOT_CHECK_NEAR(emitted_min, exact.min, 1e-6);
PLOT_CHECK_NEAR(emitted_max, exact.max, 1e-6);
// Envelope pairs == exact reduce over their own spans. Walk the layout the
// gather contract fixes -- raw lead pairs, then two pairs per complete
// bucket, then raw trail pairs -- and read each pair's span off its x
// positions (all saw_y values are finite, so no bucket is skipped).
const u64 lend = s.pyramid().level_size(level);
const u64 first = std::min(b / span + (b % span == 0 ? u64{0} : u64{1}), lend);
const u64 last = std::min(e / span, lend);
const u64 lead = first * span;
const u64 buckets = last - first;
PLOT_CHECK(out.size() ==
2 * (lead - b + 2 * buckets + (e - last * span)));
plot::usize o = 0;
for (u64 i = b; i < lead; ++i, o += 2) { // the partial leading bucket, raw
PLOT_CHECK_NEAR(out[o], f32(at(i) - x0), 1e-3);
PLOT_CHECK_NEAR(out[o + 1], val(i), 1e-6);
}
for (u64 j = 0; j < buckets; ++j) {
const u64 bi = first + j;
const u64 lo = bi * span;
const u64 hi = std::min((bi + 1) * span, e) - 1;
const plot::Range r = s.reduce(lo, hi + 1);
PLOT_CHECK_NEAR(out[o], f32(at(lo) - x0), 1e-3);
PLOT_CHECK_NEAR(out[o + 1], r.min, 1e-6);
PLOT_CHECK_NEAR(out[o + 2], f32(at(hi) - x0), 1e-3);
PLOT_CHECK_NEAR(out[o + 3], r.max, 1e-6);
o += 4;
}
for (u64 i = last * span; i < e; ++i, o += 2) { // the partial trailing bucket
PLOT_CHECK_NEAR(out[o], f32(at(i) - x0), 1e-3);
PLOT_CHECK_NEAR(out[o + 1], val(i), 1e-6);
}
PLOT_CHECK(o == out.size());
}
PLOT_TEST(xy_series_nan_policy_matches_the_other_series) {
// Non-finite y: excluded from bounds and sums; an all-NaN window aggregates
// to zero-span / zero.
std::vector<f64> x{0.0, 1.0, 2.0, 3.0, 4.0, 5.0};
plot::data::XYSeries s(x, {std::nanf(""), 1.0f, 2.0f, std::nanf(""), 3.0f, std::nanf("")});
PLOT_CHECK_NEAR(s.y_bounds().min, 1.0, 1e-9);
PLOT_CHECK_NEAR(s.y_bounds().max, 3.0, 1e-9);
const plot::Range r = s.reduce(0, 6);
PLOT_CHECK_NEAR(r.min, 1.0, 1e-9);
PLOT_CHECK_NEAR(r.max, 3.0, 1e-9);
PLOT_CHECK_NEAR(s.sum(0, 6), 6.0, 1e-9);
PLOT_CHECK(s.reduce(4, 5).span() == 0.0);
plot::data::XYSeries all(x, std::vector<f32>(6, std::nanf("")));
PLOT_CHECK(all.y_bounds().span() == 0.0);
PLOT_CHECK(all.sum(0, 6) == 0.0);
PLOT_CHECK_NEAR(all.x_bounds().min, 0.0, 1e-12); // x bounds are the array's
PLOT_CHECK_NEAR(all.x_bounds().max, 5.0, 1e-12);
}
PLOT_TEST(xy_series_commit_rebuilds_and_reset_replaces) {
auto x = spread_x(256);
plot::data::XYSeries s(std::move(x), saw_y(256));
const plot::Range before = s.y_bounds();
std::vector<f64> x2{0.0, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0};
s.reset(std::move(x2), {5.0f, 3.0f, 1.0f, 4.0f, 4.0f, 4.0f, 4.0f, 4.0f});
PLOT_CHECK(s.size() == 8);
PLOT_CHECK_NEAR(s.y_bounds().min, 1.0, 1e-9);
PLOT_CHECK_NEAR(s.y_bounds().max, 5.0, 1e-9);
PLOT_CHECK_NEAR(s.x_bounds().max, 32.0, 1e-12);
PLOT_CHECK(s.y_bounds().max != before.max);
// reset() re-asserts the precondition too: the arrays above are sorted, so
// this passes; unsorted x is a debug-abort by contract (see the header) and
// is deliberately not exercised here.
}