Compare commits

..
36 Commits
Author SHA1 Message Date
Martino FerrariandClaude Opus 4.6 cc908efa36 feat(render): a reduced column can be graded by dwell, not drawn as one flat band
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 pixel column reduced from a dense envelope is honest about where the signal
went and silent about how long it was there: its min and max are identical
whether its samples fill the span or pile at its two ends, so a ramp and a pulse
are one band. Style::intensity_grading grades it instead -- bright where the
signal sits, faint where it only passes through -- by asking the gather for a
level finer than the pixel grid and reading the distribution from those
sub-buckets. Off by default: a change of look, not a correction of one.

The measure is dwell per row, each segment depositing one unit spread over the
rows it spans; counting +1 per row instead grades a sine's fast middle as
brightly as its rails, which is the pattern inverted. The accumulator is one
column wide (a full w*h density image is 33 MB at 4K) with each span written as
two delta writes, and normalisation is against the column's own peak -- a
frame-wide scale fades the trace out exactly where it moves fastest. Alpha is
quantised into tiers, one LineBatch and one draw call each, because the line
pipeline's colour is a uniform and a per-segment alpha is inexpressible.

The finer ask is floored at level 1 whenever the flat path would have used the
pyramid: level_for() answers 0 -- raw samples -- as soon as a sub-column falls
under one bucket, so an unfloored ask turns a moderately dense frame into an
O(visible) gather (measured at 172 samples a pixel: raw=1154 lod=0 and 6.4 ms a
frame against raw=710 lod=453 and 2.3 ms). Grading is a way of drawing a
reduction and may not undo it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 03:02:36 +02:00
Martino FerrariandClaude Opus 4.6 e81e84645f fix(render): a dense trace is drawn one column at a time, not as a polyline
An LOD envelope staged as a polyline moirés. level_for() sizes a bucket at one to
two pixel columns and Envelope emits two points per bucket, so a column carries
three or four near-vertical hairlines at as many sub-pixel phases; under src-alpha
compositing its brightness is then a non-linear function of those phases, the
pattern repeats at the beat between the bucket pitch and the pixel pitch, and a pan
sweeps the beat so the banding crawls.

draw_traces now reduces such a window onto the pixel grid: one exactly vertical
segment per integer column of the axes, from the lowest to the highest screen y any
gathered point put in it, flushed in framebuffer pixels with the snap on. One phase
for the whole trace, so there is nothing left to beat -- and the geometry becomes
O(axes.w) rather than O(buckets).

Gated on the gathered density rather than on the level (the raw path beats the same
way), and on Envelope alone. A hole stays a hole, disjoint neighbours are joined so
a fast edge keeps it, and a span under the stroke is drawn as the stroke since the
shader caps nothing. It is the one carve-out in u_snap's data/chrome rule: a column
is a pixel column, and the y bounds it reports are untouched.

Measured by the new test as the number of distinct coverage levels in the ink --
4 against the polyline's 126, with 627 segments against 60 054. A count of
partially covered pixels was tried first and separates nothing (577 against 652):
a 1.5 px stroke's fringe always spills into the next column.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 02:00:25 +02:00
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
Martino Ferrari f132a5ec92 test(plot): pin the hot-handle re-index on region removal, refresh coverage
`remove_region()` now clears or decrements the published `hot_.region` with the
cache it re-indexes, so a frame after a removal highlights the region the handle
actually names. The new case covers both branches (a handle above the hole
follows it down, the removed region's handle is cleared with its zone), and the
coverage document is regenerated by the command that measured it: 95.2% of src/,
every file at or above 80%.
2026-09-28 22:42:56 +02:00
Martino Ferrari 1f5ce0d4a5 fix(plot,figure): re-index hot region, logical row floor, refuse live on a linked axis
Three smaller findings at the plot/figure seam:

* `Plot::remove_region()` re-indexed `region_stats_` but left `hot_.region`
  naming the old index, so a frame after a removal highlighted the wrong
  region's bound and an open drag edited a different region. The published
  handle (and its zone) is now cleared or decremented with the cache, and the
  contract documents that a host must not remove a region mid-drag.

* `Figure`'s `kMinRowHeightPx` was applied as a *device*-pixel floor while
  chrome is logical, so at 2x the guarantee was half as strong exactly where
  the chrome is twice as large. It is now a logical constant converted through
  the row's own `ui_scale()`, 1:1 for a non-positive scale as `Style::device()`
  does.

* `Figure::link_x()` refused a member whose axis 0 was `XFollow::Live`, but
  nothing refused `set_x_follow(0u, XFollow::Live)` *after* linking. The group
  then wrote its range into an axis that reinterpreted it against its own live
  edge, read it back as an external change, and tracked one member instead of
  the union. `Plot` now carries a linkage flag (`set_x_link_member()`), set by
  `link_x()` and cleared by `unlink_row()`, and `set_x_follow()` refuses `Live`
  on a linked axis.

Tests: arming `Live` on a linked member leaves it `Off` and unlinking restores
the ability to arm it; the row-height floor is 24 and 48 device pixels at 1x
and 2x respectively.
2026-09-28 22:36:37 +02:00
Martino Ferrari 2fbd1de7d4 fix(render,input): guard the zoom gain, clip annotation captions, no-throw dump
Three findings reachable by ordinary use:

* `Controller::axis_zoom()` divided by `Plot::ui_scale()` directly, though
  `Style::device()` documents a non-positive scale as host state that must be
  tolerated. With `ui_scale == 0` and a negative drag delta, `logical_dy` was
  `-inf`, the factor `+inf`, and the guard `!(factor > 0.0)` did not reject it:
  `zoom_x_axis`/`zoom_y_axis` computed `anchor + (bound - anchor) * inf`, and
  `set_y_range()` stored it verbatim and cleared `auto_scale` -- an axis left
  non-finite until an auto-fit. A negative scale silently inverted the zoom.
  A scale that is not positive and finite now means no zoom at all.

* `draw_annotations()` stages its caption into the frame's single text batch,
  which `flush_text()` draws *after* `clear_clip()`, so a caption staged while
  the axes clip was engaged was rasterised with the viewport clip. An
  annotation anchored outside the visible range kept a caption with no cross,
  and in a `Figure` -- whose rows tile the figure -- that caption painted into
  the neighbouring row. A caption whose anchor is outside `axes` is now skipped
  with its cross, rather than splitting the frame's one text draw call (which
  would cost a draw call per row and mis-clip the labels staged outside `axes`).

* `dump_frame_ppm()` was the one public entry point not `noexcept`, so a
  `std::bad_alloc` crossed the API boundary. Its buffers are now sized before
  anything is on disk and a failure is reported as `ErrorCode::OutOfMemory`,
  exactly as `TrueTypeFont::from_file()` reports it.

Tests: `ui_scale` 0 and -1 leave the axis range finite and unchanged with
`auto_scale` intact; an annotation anchored outside a bottom figure row's y
range leaves no caption ink in the row above, while an in-range one still
draws; a `static_assert` pins the dump's `noexcept`.
2026-09-28 22:36:34 +02:00
Martino Ferrari 9aff06acee fix(glfw): route a drag by the row it began in, and drop stale hover state
`controller_at()` routed every pointer event by the row the pixel was in,
ignoring the row a drag started in, so a press on one figure row followed by a
motion and release in another delivered the motion and release to a controller
whose `mode_ == None`. The owning controller kept `mode_`, `drag_region_`,
`drag_cursor_` and `drag_fixed_x_` set for ever: `publish_hot()` kept publishing
the grabbed handle, `grabbed_region()` reported a drag that had ended, and a
later gesture re-entering the row resumed the stale edit from the old anchor.

`feed_pointer_event()` now owns the routing: a press captures the row it landed
in and every motion and the release until the button comes up go to that row
(`pointer_target()`), cleared on release and when another figure is set. The
GLFW callbacks build the event and call it, so a windowless host or a test can
drive the routing too.

Two neighbours in the same seam:

* `sync_figure_crosshair()` called the four-argument overload unconditionally,
  so a documented vertical-only crosshair (`horizontal == false`) was promoted
  to a full cross at the stored y == 0, clamped to the axes edge and made
  permanent; the change detection also keyed on x alone, missing a y-only move
  to another row at the same x (two rows showing one horizontal line, spec 17).
  It now consults `crosshair().horizontal` and compares the hovered row's y.
* Only the row under the pointer republished its hot handles, so a row the
  pointer left kept drawing a hovered marker or bound as if it were still
  under the pointer, and answered `hovered_region()`/`grabbed_cursor()` for
  it. The run loop clears every other row's handles, keeping the captured
  row while a drag owns the button, and reads the frame's one layout once
  instead of recomputing every row five times.

Test: a press on row A's region, a motion into row B, a release in row B
asserts no controller keeps a drag mode, nothing reports a grabbed handle, and
a later press-and-motion that re-enters row A does not resume the old edit.
2026-09-28 22:36:30 +02:00
Martino Ferrari 81792dcd05 docs: correct three header comments that had drifted from the code 2026-09-28 22:17:55 +02:00
Martino Ferrari 040e40254d chore: remove a stray fragment committed by a shell redirect 2026-09-28 22:13:59 +02:00
Martino Ferrari ed3a8ce645 licence: MIT LICENSE and SPDX identifiers in every first-party file
The repository had no licence at all, so every source was all-rights-reserved
by default. Add the verbatim MIT text at LICENSE (Copyright (c) 2026 Martino
Ferrari) and a two-line SPDX header to each of the 113 first-party sources --
48 include/plot, 26 src, 10 adapters, 5 examples, 21 tests, 3 tools -- so the
grant travels with each file rather than relying on the root document alone.

Scripts keep their shebang on line 1; the SPDX lines follow it. Genuinely
vendored code is untouched: third_party/stb_truetype.h carries no header of
ours. src/render/embedded_mono_font.cpp is generated, so tools/fonts/gen_atlas.py
emit() now writes the two lines into its header comment and the file was
regenerated -- gen_atlas.py --check passes and the regeneration adds only those
comment lines to the previously committed file.

README's provenance section now states MIT and links LICENSE.

No code, test or public API change.
2026-09-28 22:13:40 +02:00
Martino Ferrari 059227d080 docs: README with build, embedding and verification entry points 2026-09-28 22:00:57 +02:00
Martino Ferrari 19c8d4368d docs(coverage): regenerate the measured table from the command that measures it 2026-09-28 22:00:57 +02:00
Martino Ferrari b7d0f901cc docs: a user guide covering getting started, architecture, embedding, data, interaction, styling and testing 2026-09-28 21:50:19 +02:00
136 changed files with 11896 additions and 931 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()
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Martino Ferrari
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+239
View File
@@ -0,0 +1,239 @@
# plot
An embeddable OpenGL time-series plotting library for real-time, high-sample-rate data.
The core (`plot::core`, C++20, OpenGL 3.3 core) has no third-party dependency at all;
the host integrations that own a window and an event loop are separate, opt-in targets
built on top of it.
## Hosts, and what a frame costs
| host | CMake target | source |
|---|---|---|
| GLFW | `plot::glfw` | `adapters/glfw/` |
| Dear ImGui | `plot::imgui` (build-tree only) | `adapters/imgui/` |
| Qt6 | `plot::qt6` | `adapters/qt6/` |
| GTK4 | `plot::gtk4` | `adapters/gtk4/` |
The visible x range is mapped onto sample indices through the series' own x
(`Series::neighbours()`, a binary search over the real stamps), never through the
`(x - x0()) / dx()` grid: `dx()` is a *nominal* spacing, and a series whose x is a streamed
timestamp would otherwise have its draw window land inside the visible range and lose the two
segments that enter and leave the frame — the empty left and right margins of a zoomed-in view.
Per-frame rendering cost is O(visible pixels + samples produced since the last frame),
not O(samples): a trace denser than one sample per pixel is drawn from a min/max LOD
pyramid rather than from raw samples. The `plot_multiplot_demo` run stages **20,901 GPU
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
run touched a GPU, so read it as an upper bound and a record of the software path.
## 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);
- a trace whose gathered window is denser than the axis is reduced **onto the pixel grid** --
one snapped vertical segment per integer column, lit from that column's own extremes --
because an LOD envelope staged as a polyline puts three or four near-vertical hairlines per
column at as many sub-pixel phases, and the beat between the bucket pitch and the pixel pitch
is a crawling moiré; one segment per column leaves one phase, so there is nothing to beat
(`FrameStats::column_traces`, and `docs/data-and-performance.md` for the four rules it keeps);
- and such a column can be drawn **graded by dwell** instead of as one flat band
(`Style::intensity_grading`, off by default, `FrameStats::graded_traces`): bright where the
signal sits, faint where it only passes through, the way a digital-phosphor scope reads. It is
what puts back the information the reduction throws away — a column's min and max are the same
whether its samples fill the span or pile at its two ends — so the gather is asked at a level
finer than the pixel grid and the distribution read from those sub-buckets;
- 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`), 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 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
(`plot::font_ttf`).
## Build and install
Needs a C++20 compiler and CMake **3.24+**. Presets live in `CMakePresets.json`
(`linux-debug`, `linux-release`, `linux-asan`, `linux-coverage`).
```sh
cmake --preset linux-release
cmake --build build/release
cmake --install build/release --prefix /where/you/want
```
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. It is committed
at the repository root as `readme_main.cpp`:
```cpp
#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;
}
```
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). 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 -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
```
## Documentation
The user guide lives under `docs/`; `docs/superpowers/` is the design record, not user
documentation.
- [Getting started](docs/getting-started.md) — prerequisites, build/install/consume, and a
minimal GLFW program against `plot::core`.
- [Architecture](docs/architecture.md) — the layers (`core`/`math` → `gl` → `data` →
`render` → `plot` → `input`), what each owns, and the dependency rules.
- [Embedding into a host](docs/embedding.md) — the four adapters, the `ui_scale`/DPI rule
per host, and the headless demo invocations.
- [Data and performance](docs/data-and-performance.md) — the sample stores, the
single-producer/single-consumer contract, the LOD pyramid, and the measured frame times.
- [Interaction](docs/interaction.md) — cursors, regions, the measurement band, the legend,
the crosshair, the gesture table, and live follow.
- [Styling](docs/styling.md) — every `Style` field with its default and unit, the grid
classes, and installing a text backend.
- [Building and testing](docs/building-and-testing.md) — presets, `PLOT_BUILD_*` options,
the suite, the headless demo modes, and the coverage command.
- [Coverage](docs/coverage.md) — the coverage method and the measured per-file table.
## Testing and coverage
```sh
cmake --preset linux-debug
cmake --build build/debug
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 **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 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
- **This library** is **MIT licensed**: the full text is in [`LICENSE`](LICENSE), and
every first-party file (C++, Python and shell) carries its SPDX identifier in the file
header — `// SPDX-License-Identifier: MIT` — so the grant travels with each source file,
not just the repository root.
- **Adwaita Mono** (the embedded font, `render::EmbeddedMonoFont`) is under **SIL OFL
1.1**, committed verbatim at [`licences/OFL-1.1.txt`](licences/OFL-1.1.txt). The backend
class is named neutrally (`EmbeddedMonoFont`, not "Adwaita Mono") because OFL reserves
the font name for modified redistributions. The baked atlas is generated at authoring
time by `tools/fonts/gen_atlas.py` and committed as data, so `plot_core` rasterises
nothing at build or run time.
- **stb_truetype v1.26** (public domain, Sean Barrett / RAD Game Tools) is vendored at
`third_party/stb_truetype.h` and compiled **only** by the optional `plot_font_ttf`
target; its include directory is `PRIVATE`, so no consumer and no other target can reach
it. `PLOT_BUILD_TTF_FONT` defaults on exactly when the vendored header is present.
- **`plot_core` is third-party-free.** It links only `${CMAKE_DL_LIBS}`, and no public
header under `include/plot/` includes a third-party header. The header self-containment
and umbrella checks in `tests/CMakeLists.txt` fail the build if either rule breaks.
## Limitations
- **Timings are llvmpipe under Xvfb** (`tools/coverage.sh`, the demo summaries). No
measurement here used a GPU; there are no hardware numbers.
- **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. 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.
- **`render` ↔ `plot` is not a strict include chain.** `Renderer` includes `plot/plot.hpp`
because drawing *is* being handed a `Plot`; the `plot` layer names only `render`'s value
types (`Style`, `TextBackend`), never `Renderer`. The cycle and its direction are
documented in [docs/architecture.md](docs/architecture.md).
+135 -19
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <glfw/glfw_host.hpp>
// Before GLFW's header, so it does not include <GL/gl.h>: that header's macros
@@ -108,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;
@@ -207,12 +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);
figure_->layout();
// Every row's controller gets that row's rect, so a gesture -- routed by
// controller_at() 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.
const std::vector<Rect>& rows = figure_->layout().rows; // the one layout of this frame
// 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]);
}
@@ -222,6 +229,18 @@ bool Host::run(const RunConfig& cfg, const FrameFn& frame, Status* st) {
// signals at the one x the pointer is over. In the run loop rather than in a callback, so a
// scripted run that injects its events straight into a row controller is synced too.
sync_figure_crosshair(rows);
// Only the row the pointer's events go to ever republishes its own hot handles, so a row
// the pointer left keeps the `HotHandles` it last published and goes on drawing a hovered
// marker or region bound as if the pointer were still on it -- and its `hovered_region()`/
// `grabbed_cursor()` keep answering for it. Clear every *other* row here, in the run loop,
// where that row is known (the captured row while a drag owns the button, so an in-flight
// gesture keeps its highlight).
if (have_pointer_ && !rows.empty()) {
const u32 active = pointer_row(rows);
for (u32 i = 0; i < rows.size() && i < row_controllers_.size(); ++i) {
if (i != active) figure_->plot(i).set_hot_handles(HotHandles{});
}
}
} else {
controller_.set_viewport(viewport); // also Plot::set_viewport, then layout()
}
@@ -337,7 +356,8 @@ void Host::on_cursor_pos(f64 wx, f64 wy) noexcept {
have_pointer_ = true;
e.mods = mods_now(window_);
e.down = any_button_down(window_);
controller_at(e.x, e.y).on_pointer(e);
e.time_s = glfwGetTime(); // GLFW's monotonic clock; only differences are read
feed_pointer_event(e);
}
void Host::on_mouse_button(int button, int action, int mods) noexcept {
@@ -364,7 +384,8 @@ 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_);
controller_at(e.x, e.y).on_pointer(e);
e.time_s = glfwGetTime(); // the double-click detector reads differences of these
feed_pointer_event(e);
}
void Host::on_scroll(f64 dx, f64 dy) noexcept {
@@ -390,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;
@@ -415,14 +443,34 @@ void Host::set_figure(Figure* figure) noexcept {
last_row_ = 0u;
// A new figure holds no crosshair this host has spread: the comparison in
// `sync_figure_crosshair()` starts from "nothing spread", which is the state its rows are in.
// Nor does it hold a captured drag row: the controllers are rebuilt below, so the old
// capture names a controller that no longer exists.
spread_active_ = false;
spread_x_ = 0.0;
if (figure_ == nullptr) return;
spread_y_ = 0.0;
captured_ = false;
captured_row_ = 0u;
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 {
@@ -431,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);
@@ -446,6 +496,54 @@ input::Controller& Host::controller_at(f32 x, f32 y) noexcept {
return controller;
}
void Host::feed_pointer_event(const input::PointerEvent& e) noexcept {
if (figure_ == nullptr) {
controller_.on_pointer(e);
return;
}
pointer_target(e.x, e.y, e.button, e.pressed, e.released).on_pointer(e);
}
input::Controller& Host::pointer_target(f32 x, f32 y, input::Button button, bool pressed,
bool released) noexcept {
(void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in()
if (figure_ == nullptr || row_controllers_.empty()) return controller_;
const std::vector<Rect>& rows = row_rects_;
if (rows.empty() || rows.size() != row_controllers_.size()) return controller_;
u32 row = 0u;
if (captured_) {
// A button-down gesture belongs to the row it began in for its whole life: a drag
// that crosses into a neighbouring row must not hand its motion -- or its 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_;
// 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
// mid-drag, and the guard keeps a stale index from reaching past the controller list.
if (row >= row_controllers_.size()) row = 0u;
last_row_ = row;
input::Controller& controller = *row_controllers_[row];
controller.set_viewport(rows[row]);
return controller;
}
u32 Host::pointer_row(const std::vector<Rect>& rows) noexcept {
if (captured_ && captured_row_ < rows.size()) return captured_row_;
return row_in(rows, last_pointer_y_);
}
u32 Host::row_in(const std::vector<Rect>& rows, f32 y) noexcept {
if (rows.empty()) return 0u;
// The row the pixel is in, or -- when it is in none of them, which is the row gap a
@@ -482,11 +580,18 @@ void Host::sync_figure_crosshair(const std::vector<Rect>& rows) noexcept {
// keeps it.
u32 hovered = row_count;
if (have_pointer_) {
hovered = row_in(rows, last_pointer_y_);
// The row the pointer's events currently go to -- the captured row while a drag owns
// the button, the hovered row otherwise -- so the hover follows the gesture it began.
hovered = pointer_row(rows);
} else {
for (u32 i = 0; i < row_count; ++i) {
const Crosshair& candidate = figure_->plot(i).crosshair();
if (candidate.active != spread_active_ || (candidate.active && candidate.x != spread_x_)) {
// A row differs when its active state, its x, or the y of *its own* horizontal line
// differs from what was spread: a pointer that moves to another row at the same x is
// a new hover and must move the one horizontal line, not leave the old row's behind.
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;
}
@@ -502,8 +607,19 @@ void Host::sync_figure_crosshair(const std::vector<Rect>& rows) noexcept {
// 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) {
figure_->set_crosshair(row.crosshair().x, hovered, row.crosshair().y, true);
if (row.crosshair().horizontal) {
// The row carries its own y (its controller placed both coordinates), so the figure's
// four-argument overload gives the pointer row the one horizontal line.
figure_->set_crosshair(row.crosshair().x, hovered, row.crosshair().y, true);
} else {
// The row's crosshair is vertical-only -- a caller placed it through the
// single-coordinate `Figure::set_crosshair()`, which is documented vertical-only. The
// four-argument overload would turn the horizontal line on at the stored (meaningless)
// y == 0, clamped to the axes edge and made permanent. Promote only the vertical line.
figure_->set_crosshair(row.crosshair().x, true);
}
spread_x_ = row.crosshair().x;
spread_y_ = row.crosshair().y;
spread_active_ = true;
} else {
figure_->clear_crosshair();
-13
View File
@@ -1,13 +0,0 @@
e.mods = static_cast<u32>(mods);
// GLFW reports auto-repeat as its own action; a held key keeps acting, exactly
// as holding the key on a keyboard does.
e.down = action != GLFW_RELEASE;
// A key has no position of its own: it goes to the row the pointer was last over,
// which is the row the user is looking at.
input::Controller& controller =
(figure_ == nullptr || last_row_ >= row_controllers_.size()) ? controller_
: *row_controllers_[last_row_];
controller.on_key(e);
}
} // namespace plot::glfw
+85 -7
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <functional>
@@ -125,18 +128,58 @@ 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.
///
/// 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 event callbacks
/// route through it, and so can a host that wants to know where a synthetic gesture
/// would land.
/// hit-test geometry in step with the layout between two frames. It is the pure
/// query -- what a gesture *would* land on -- and it does not move the capture
/// below; the GLFW callbacks and `feed_pointer_event()` are the routing.
input::Controller& controller_at(f32 x, f32 y) noexcept;
/// Feeds one pointer event, stated in framebuffer pixels, through the host's own
/// routing and into the controller it belongs to. The GLFW callbacks fill the
/// position and the button transitions out of GLFW and call exactly this, so a host
/// or a test with no window can drive the routing directly.
///
/// 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
/// 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_; }
@@ -180,6 +223,20 @@ class Host {
/// that has just laid the figure out does not make it lay out again for this.
static u32 row_in(const std::vector<Rect>& rows, f32 y) noexcept;
/// The controller a pointer event at (x, y) belongs to, applying the button capture:
/// a press claims the row it landed in (`captured_row_` + `captured_button_`) and
/// 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
/// every *other* row's hot handles, so a row the pointer left cannot keep drawing a
/// hover or a grab it last published.
u32 pointer_row(const std::vector<Rect>& rows) noexcept;
/// Spreads the hover over a whole `Figure` (spec §15).
///
/// A figure's rows share one x, so the crosshair one row's controller placed is read back here and
@@ -217,10 +274,31 @@ class Host {
/// `sync_figure_crosshair()`): what a row is compared against to find the one a
/// scripted run touched, 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.
/// sync, and the one a clear leaves. `spread_y_` is the horizontal line's own
/// value, compared as well so a y-only change -- the same x in another row --
/// is not mistaken for "nothing changed".
f64 spread_x_ = 0.0;
f64 spread_y_ = 0.0;
bool spread_active_ = false;
/// 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;
/// Framebuffer pixels per window pixel. Taken from the two sizes GLFW reports
+114
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <plot/core/rect.hpp>
@@ -226,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
+326 -33
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <gtk4/gtk_plot_area.hpp>
#include <gtk4/gtk_input.hpp>
@@ -15,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
@@ -28,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 {
@@ -155,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
@@ -197,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 {
@@ -276,6 +476,7 @@ void PlotArea::unrealize() noexcept {
renderer_.reset();
press_open_ = false;
drag_.have = false;
zoom_.end();
}
void PlotArea::render() noexcept {
@@ -296,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.
controller_.set_viewport(viewport);
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
@@ -310,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);
renderer_->draw(*plot_);
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);
@@ -361,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;
@@ -380,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;
@@ -397,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_));
}
@@ -409,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,
@@ -418,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
@@ -431,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,
@@ -450,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)));
}
}
@@ -479,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
@@ -493,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,
@@ -525,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
}
@@ -547,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 -------------------------------------------------------------
@@ -557,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;
@@ -578,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
}
+167 -4
View File
@@ -1,14 +1,20 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#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>
@@ -29,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 {
@@ -131,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
@@ -162,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
@@ -193,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; }
@@ -268,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.
@@ -286,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,
@@ -304,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_;
@@ -315,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_{};
+26 -9
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <imgui/imgui_plot.hpp>
#include <imgui.h>
@@ -198,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);
};
@@ -228,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;
@@ -236,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];
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <optional>
+108 -11
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <qt6/qt_plot_widget.hpp>
#include <qt6/qt_pointer.hpp>
@@ -8,6 +11,9 @@
#include <QOpenGLContext>
#include <QPointF>
#include <QWheelEvent>
#include <QEvent>
#include <QGestureEvent>
#include <QPinchGesture>
#include <chrono>
#include <cmath>
@@ -90,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() {
@@ -240,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();
@@ -253,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;
}
@@ -270,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);
@@ -289,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;
@@ -304,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
+28 -4
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
// The plot headers come first, and must. Qt's <QOpenGLWidget> reaches
@@ -31,6 +34,9 @@ class QKeyEvent;
class QMouseEvent;
class QPointF;
class QWheelEvent;
class QEvent;
class QGestureEvent;
class QPinchGesture;
namespace plot::qt {
@@ -103,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
///
@@ -167,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;
@@ -193,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_;
@@ -204,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;
+69
View File
@@ -1,6 +1,10 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <plot/core/types.hpp>
#include <plot/input/events.hpp>
#include <plot/math/vec2.hpp>
namespace plot::qt {
@@ -46,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)
+42 -1
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,8 +130,19 @@ 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}")
list(APPEND plot_umbrella_dangling "${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()
+68
View File
@@ -0,0 +1,68 @@
# Architecture
`plot` is one library, `plot_core`, plus optional adapter targets. The public headers are
grouped into seven directories, and the umbrella header `include/plot/plot.hpp` includes
them in this order:
```
core / math → gl → data → render → plot → input
```
with the host adapters (`adapters/glfw`, `adapters/imgui`, `adapters/qt6`, `adapters/gtk4`)
built on top. This page says what each layer owns and the rules the build enforces.
## What each layer owns
| layer | directory | owns |
|---|---|---|
| `core` | `include/plot/core/`, `src/core/` | the vocabulary: `f32`/`f64`/`u32`/`usize`, `kInvalid`, `Range`, `Rect`, `Color`, `Span`, and `Status`/`ErrorCode` for fallible operations. |
| `math` | `include/plot/math/`, `src/math/` | `Vec2`, `Mat3`, and `Transform` — the data↔pixel affine mapping, including the log-axis handling and its invertibility guarantee. |
| `gl` | `include/plot/gl/`, `src/gl/` | a hand-written OpenGL surface: enumerator typedefs, the entry-point table `plot::gl::load()` fills, `plot::gl::caps()`, and the move-only RAII wrappers (`gl::Buffer`, `VertexArray`, `Program`, `Texture`, `Framebuffer`, `Fence`). No GL loader library is used. |
| `data` | `include/plot/data/`, `src/data/` | the sample stores: the `Series` interface, `ArraySeries`, `StreamSeries`, `RingBuffer`, and the min/max LOD `MinMaxPyramid`. |
| `render` | `include/plot/render/`, `src/render/` | presentation: `Style`, the `Renderer`, the text machinery (`TextBackend`, `TextAtlas`, `EmbeddedMonoFont`), and the shader/program/buffer/vertex-array primitives. |
| `plot` | `include/plot/plot/`, `src/plot/` | the scene model: `Plot`, `Figure`, and the primitives they own — `Axis`, `Trace`, `Cursor`, `Region`, `Measure`, `Legend`, `Crosshair`, `Annotation`, `Grid`, `Ticks`. Pure CPU state, no GL. |
| `input` | `include/plot/input/`, `src/input/` | `Controller` and the host-neutral event types (`PointerEvent`, `WheelEvent`, `KeyEvent`); turns events into gestures. |
| adapters | `adapters/*/` | one per host: they own the window/context/event loop and translate their toolkit's events into `input` events. |
## The dependency rules
- **`plot_core` is third-party-free.** Its `CMakeLists.txt` links only
`${CMAKE_DL_LIBS}`, and the public headers under `include/plot/` include no third-party
header. The one exception is the optional `plot::font_ttf` target, which compiles the
vendored `third_party/stb_truetype.h` (stb_truetype v1.26, public domain) in its own
object library `plot_stb_truetype`, whose include directory is `PRIVATE` — so nothing
else in the build, and no consumer, can reach it by accident. `PLOT_BUILD_TTF_FONT`
defaults to on exactly when the vendored header is present.
- **Every public header compiles standalone, and `plot.hpp` includes every one of them.**
`tests/CMakeLists.txt` generates one translation unit per header (globbed, so a new
header is picked up automatically), builds an umbrella TU, and registers ctest cases
(`plot_headers_self_contained`, `plot_umbrella_covers_public_headers`,
`plot_umbrella_check`) that fail the build if either rule breaks.
- **The umbrella stops at the library.** It never includes an adapter header, so
`#include <plot/plot.hpp>` cannot drag in GLFW, ImGui, Qt or GTK. The adapter headers are
reached as `<glfw/glfw_host.hpp>`, `<imgui/imgui_plot.hpp>`, `<qt6/qt_plot_widget.hpp>`
and `<gtk4/gtk_plot_area.hpp>`.
- **The renderer never owns the context or the event loop.** `Renderer` never creates,
binds or resizes a framebuffer: `begin_frame(fb_w, fb_h, viewport_px)` draws into
whatever framebuffer is bound, clears exactly the given rectangle (scissored, so a host
that painted the rest of its surface keeps it), and `end_frame()` turns the GL state it
moved back off. The renderer also never sees a pointer, a button or a modifier — it draws
the handles the plot reports as hot, and only a host that drives the plot with an
`input::Controller` ever makes anything hot. Owning the window, the context and the loop
is the adapter's job (or, for a library user, the application's).
One direction is worth stating precisely, because it is the one place the layer order is
not a strict include chain: `Renderer` (in `render/`) includes `plot/plot.hpp` and
`plot/figure.hpp`, because drawing *is* being handed a `Plot` or a `Figure` to draw. The
`plot` layer names only `render`'s value types — `render::Style` and
`render::TextBackend` — and never names `Renderer`. So the runtime direction is
"the renderer knows the plot, the plot does not know the renderer".
## The design record
The specifications and implementation plans — the requirements, the numbered spec sections
this documentation sometimes refers to, and the reasoning behind decisions — live under
[`docs/superpowers/`](superpowers/) (`specs/` and `plans/`). That tree is the project's
design record, not user documentation: it is where a decision is argued, not where the
library is explained. This `docs/` guide and [docs/coverage.md](coverage.md) are the
user-facing documents.
+127
View File
@@ -0,0 +1,127 @@
# Building and testing
## Presets and options
Four configure/build presets live in `CMakePresets.json`:
| preset | binaryDir | what it builds |
|---|---|---|
| `linux-debug` | `build/debug` | Debug; tests, examples and all four adapters |
| `linux-release` | `build/release` | Release; `PLOT_BUILD_TESTS=OFF` (so the installed `libplot_core.a` carries none of the test-only counter instrumentation); examples and all four adapters |
| `linux-asan` | `build/asan` | Debug with `PLOT_SANITIZE=address`; tests and all four adapters |
| `linux-coverage` | `build/cov` | the instrumented tree behind `tools/coverage.sh`: `--coverage -O0 -g`, `PLOT_COVERAGE=ON`, tests on, adapters and examples off |
The options (`cmake/plot_options.cmake`) and their defaults:
| option | default | meaning |
|---|---|---|
| `PLOT_BUILD_TESTS` | `ON` | build the test suite (needs EGL) |
| `PLOT_BUILD_EXAMPLES` | `OFF` | build the demos |
| `PLOT_BUILD_GLFW` | `OFF` | build the GLFW adapter and its demo |
| `PLOT_BUILD_IMGUI` | `OFF` | build the Dear ImGui adapter and its demo (ImGui is fetched at configure time) |
| `PLOT_BUILD_QT6` | `OFF` | build the Qt6 adapter and its demo |
| `PLOT_BUILD_GTK4` | `OFF` | build the GTK4 adapter and its demo |
| `PLOT_BUILD_TTF_FONT` | on iff `third_party/stb_truetype.h` exists | build the optional `plot::text::TrueTypeFont` backend |
| `PLOT_INSTALL` | `ON` | generate install/export rules for `find_package(plot)` |
| `PLOT_SANITIZE` | `""` | sanitizer list for `-fsanitize=` (e.g. `address`) |
| `PLOT_COVERAGE` | `OFF` | add the `coverage` target (requires an instrumented tree) |
Adapters and examples are off by default, so the core library and its tests build on a bare
toolchain. Each example is guarded by the adapter target it needs, so
`-DPLOT_BUILD_EXAMPLES=ON` without the matching adapter is a no-op rather than a configure
error.
## Running the suite
```sh
cmake --preset linux-debug
cmake --build build/debug
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 **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
Each demo supports two environment variables, implemented once and reused:
- `PLOT_DEMO_EXIT_AFTER_MS=<n>` — stop the loop after `n` milliseconds and return. Unset,
unparsable or not positive means "run until the window is closed". A run with a deadline
creates its window hidden, so an automated run never flashes a window on a real display,
and its frame times are not throttled by a vblank.
- `PLOT_DEMO_SCREENSHOT=<path>` — on that final frame, write it as a binary PPM before the
swap. The GLFW host writes the framebuffer with `Renderer::dump_frame_ppm()`; the ImGui
demo writes the whole framebuffer, while Qt and GTK write their widget's/area's own
framebuffer (their toolkits' chrome lives outside it).
That pair is what lets CI run the GUI demos under `xvfb-run` with no display access and no
screen capture (the invocations are in [embedding.md](embedding.md)).
Three demos add env-gated **self-checking** modes. These are verification affordances for
the project's own testing, driven through the real host and its own controllers — they are
not a second implementation of anything, and they exit non-zero if a measured property fails:
| variable | demo | what it drives |
|---|---|---|
| `PLOT_DEMO_FOLLOW=1` | `plot_glfw_demo` | the live-follow sequence: three wheel notches and a pan on a `XFollow::Live` axis, printing the range width, the distance behind the newest sample and the follow state at each step |
| `PLOT_DEMO_REGIONS` (`=hot` to hover a bound) | `plot_glfw_demo` | the direct-manipulation sequence: freeze the view, place a region, pan 100 px with the press outside it, and check the region came along with its width untouched |
| `PLOT_DEMO_SYNC=1` | `plot_multiplot_demo` | the synced-follow check: the three rows hold one range and its right edge keeps tracking the newest sample through a zoom and a pan on different rows |
| `PLOT_DEMO_CROSSHAIR=<row>` | `plot_multiplot_demo` | parks the pointer in a row each frame so a headless screenshot shows the one horizontal line the hovered row draws |
(`plot_glfw_demo` also accepts `PLOT_DEMO_CROSSHAIR=1`, which parks the pointer at the centre
of the data area so the two-line crosshair has ink to measure.)
## Coverage
```sh
tools/coverage.sh # from the repository root
cmake --build build/cov --target coverage # the same thing, through CMake
```
The script configures `build/cov` from the `linux-coverage` preset, builds it, clears the
`.gcda` counters, runs the whole suite, captures `src/**` with `lcov`, renders an HTML report
to `build/cov/html/index.html`, prints the per-file table, and **exits non-zero when a floor
is violated**. It needs `cmake`, `ninja`, `ctest`, `lcov`, `genhtml` and `python3`, and exits
`2` with a message when one is missing.
The floors are enforced by `tools/coverage_floor.py`:
| scope | floor |
|---|---|
| every file under `src/` | **80%** of its instrumented lines |
| `src/` as a whole | **90%** of instrumented lines |
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 **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).
+54 -58
View File
@@ -3,6 +3,13 @@
`plot` measures line coverage of the compiled library bodies under `src/` and enforces a floor on
it, so a regression fails a command instead of quietly lowering a number in a document.
**The table and the floor verdict below are generated.** `tools/coverage.sh` rewrites the region
between `<!-- BEGIN GENERATED: tools/coverage.sh -->` and
`<!-- END GENERATED: tools/coverage.sh -->` from the report that run just captured, by calling
`tools/coverage_floor.py --write-doc`. Do not edit inside those markers: the numbers there come
from the command, not from a hand-typed copy, and a run on an unchanged tree rewrites the region
with identical content.
## The one command
```sh
@@ -12,12 +19,10 @@ cmake --build build/cov --target coverage # the same thing, through CMake
The script configures `build/cov` from the `linux-coverage` preset, builds it, runs the whole
`ctest` suite, captures `src/**` with `lcov` into `build/cov/coverage.info`, renders the HTML report
to `build/cov/html/index.html`, prints the per-file table below, and **exits non-zero when a floor
is violated**. It needs `cmake`, `ninja`, `ctest`, `lcov`, `genhtml` and `python3`
(Arch: `pacman -S cmake ninja lcov python`); it exits `2` with a message when one is missing.
A clean run of it — no build tree, all 26 translation units under `src/` compiled, 274 cases — takes
about two minutes here, of which the suite is ~107 s.
to `build/cov/html/index.html`, prints the per-file table, rewrites the generated region of this
file, and **exits non-zero when a floor is violated**. It needs `cmake`, `ninja`, `ctest`, `lcov`,
`genhtml` and `python3` (Arch: `pacman -S cmake ninja lcov python`); it exits `2` with a message
when one is missing.
## The floors
@@ -35,45 +40,41 @@ about two minutes here, of which the suite is ~107 s.
## The measured table
Measured on 2026-09-28 with GCC 16.2.1 from a clean `build/cov`, over the full test suite.
`coverage.sh` prints exactly this table on every run.
<!-- BEGIN GENERATED: tools/coverage.sh -->
```
coverage_floor: ok -- 24 files under src/, aggregate 93.9% lines (3730/3971), every file at or above 80%, aggregate target 90%
```
<!-- BEGIN GENERATED TABLE (tools/coverage_floor.py --table --format markdown) -->
| 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` | 214/256 | 83.6% | 100.0% |
| `src/gl/loader.cpp` | 53/81 | 65.4% | 77.8% |
| `src/input/controller.cpp` | 327/341 | 95.9% | 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` | 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` | 213/218 | 97.7% | 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` | 733/753 | 97.3% | 98.9% |
| `src/plot/ticks.cpp` | 93/99 | 93.9% | 100.0% |
| `src/render/buffer.cpp` | 87/98 | 88.8% | 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` | 529/604 | 87.6% | 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` | 49/59 | 83.1% | 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** | **3203/3447** | **92.9%** | **97.8%** |
<!-- END GENERATED TABLE -->
| **src/ aggregate** | **3730/3971** | **93.9%** | **98.0%** |
**The floor currently fails, and that is the point.** `src/gl/loader.cpp` sits at 65.4% lines
(53/81) — below the 80% per-file floor — so `tools/coverage.sh` exits `1` on today's tree. What is
uncovered there is the loader's error paths and its extension scanner; `src/gl/handle.cpp` (83.6%)
and `src/render/vertex_array.cpp` (83.1%) are the same kind of gap, just above the line. Those tests
are Task 2 of `docs/superpowers/plans/2026-09-28-plot-docs-and-coverage.md`; the floor went in first
so the fix has something to satisfy rather than something to negotiate with, and so the *next*
regression in that area is a failing command rather than a number nobody reads.
<!-- END GENERATED: tools/coverage.sh -->
## What the report measures
@@ -118,40 +119,35 @@ before `genhtml` re-creates it.
per-file `LF`/`LH`/`FNF`/`FNH` tallies that are identical for all 23 files — verified by diffing the
two tracefiles — but the per-line hit *counts* differ by a few parts per million, because a handful
of tests drive a producer thread whose iteration count follows wall-clock timing. Everything the
floor reads (the `LF`/`LH` tallies, and therefore every rate in the table above) is unaffected.
floor reads (the `LF`/`LH` tallies, and therefore every rate in the generated table above) is
unaffected.
## Adjusting the floors, and seeing the gate fail
Both floors are flags, which is how the failure path is demonstrated — and how a later task tightens
the contract. Against `build/cov/coverage.info` from the table above:
the contract. Against the report in the generated table above, a floor raised above every file's
rate fails the check and names each offender:
```sh
$ python3 tools/coverage_floor.py build/cov/coverage.info # the real floors
coverage_floor: FAILED -- 1 floor(s) violated in build/cov/coverage.info:
src/gl/loader.cpp: 65.4% lines (53/81) is below the 80% per-file floor
$ echo $?
1
# Any file not at 100% fails: a floor that is guaranteed to bite, whatever the
# current rates are. The list of offenders is the files in the table above whose
# rate is below 100%.
python3 tools/coverage_floor.py --per-file 100 build/cov/coverage.info
# coverage_floor: FAILED -- <n> floor(s) violated in build/cov/coverage.info:
# <file>: <rate>% lines (<hit>/<found>) is below the 100% per-file floor
# ...
# $? == 1
$ python3 tools/coverage_floor.py --per-file 95 build/cov/coverage.info # floor raised above rates
coverage_floor: FAILED -- 10 floor(s) violated in build/cov/coverage.info:
src/gl/handle.cpp: 83.6% lines (214/256) is below the 95% per-file floor
src/gl/loader.cpp: 65.4% lines (53/81) is below the 95% per-file floor
src/plot/grid.cpp: 93.8% lines (15/16) is below the 95% per-file floor
src/plot/ticks.cpp: 93.9% lines (93/99) is below the 95% per-file floor
src/render/buffer.cpp: 88.8% lines (87/98) is below the 95% per-file floor
src/render/primitives.cpp: 94.7% lines (71/75) is below the 95% per-file floor
src/render/renderer.cpp: 87.6% lines (529/604) is below the 95% per-file floor
src/render/text.cpp: 90.0% lines (27/30) is below the 95% per-file floor
src/render/vertex_array.cpp: 83.1% lines (49/59) is below the 95% per-file floor
src/text/true_type_font.cpp: 92.8% lines (270/291) is below the 95% per-file floor
$ echo $?
1
# The same, for the aggregate: a target above the measured aggregate fails with one line.
python3 tools/coverage_floor.py --aggregate 99 build/cov/coverage.info
# coverage_floor: FAILED -- 1 floor(s) violated in build/cov/coverage.info:
# src/ aggregate: <rate>% lines (<hit>/<found>) is below the 99% aggregate floor
# $? == 1
$ python3 tools/coverage_floor.py --per-file 60 --aggregate 90 build/cov/coverage.info
coverage_floor: ok -- 23 files under src/, aggregate 92.9% lines (3203/3447), every file at or
above 60%, aggregate target 90%
$ echo $?
0
# A floor at or below the measured rates passes.
python3 tools/coverage_floor.py build/cov/coverage.info
# coverage_floor: ok -- ...
# $? == 0
```
## Why the check is a CMake target and not a ctest case
+331
View File
@@ -0,0 +1,331 @@
# Data and performance
## The sample stores
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; // 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 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)
f64 sumsq(u64 begin, u64 end) const; // exact sum of squares
};
```
Indices are the series' **own window**: index 0 is the oldest sample it currently holds, so
`size()`, `x0()`, `reduce()`, `gather()` and `pyramid()` are all in the same coordinates.
**`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), `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)`. `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
One producer thread, one consumer thread, and the division is strict
(`stream_series.hpp`):
- **The producer may only call** `push(f32 y)`, `push(const f32* ys, u64 n)` and
`produced()`. Those touch the ring buffer and nothing else: **no lock, no pyramid, no GL**.
The acquisition thread can therefore never stall the renderer.
- **The consumer calls `acquire()`**, which snapshots the produced count and brings the
pyramid and bounds up to it, and then queries the published window. `size()`, `reduce()`,
`sum()`, `sumsq()`, `gather()` and `pyramid()` all use the window published by the last
`acquire()`.
`acquire()` costs O(samples produced since the last acquire), not O(capacity): the pyramid
is indexed in absolute sample space, so a sliding window only drops the leading buckets the
ring overwrote and appends what is new; aggregates still in the window are never recomputed.
The one exception is a producer that laps the consumer (more than `capacity()` samples
between two acquires), where the missing samples cannot be represented and the pyramid
restarts at the new base, re-reading the whole window.
The ring overwrites the oldest samples — that is what keeps a live stream showing the newest
data — so it is **not a frozen window**. Those slots are relaxed atomics, so this is not a
data race and no value tears; but if the producer pushes `k` samples while a read is in
flight, the first `k` positions of that read can hold one of those `k` newer samples rather
than the one the snapshot named. Ingest with no reuse underneath a read at all needs the
producer to stop before unread data (drop-on-full) or a claim protocol — a different
contract from this one.
`streaming()` returns `true` for `StreamSeries` and `false` for everything else. Only a
streaming series has a **live edge** (the newest x it holds, which moves on its own), and it
is that property `XFollow::Live` follows (see [interaction.md](interaction.md)).
## The min/max LOD pyramid
`data::MinMaxPyramid` (`minmax_pyramid.hpp`) is the index that makes per-frame cost depend on
the visible pixels rather than on the sample count.
- Samples are grouped into buckets of `kBucket = 64` (**`kBucket`** is a fixed constant, so
all series share one LOD geometry). Level `l` bucket `k` covers the absolute samples
`[k * (kBucket << l), (k + 1) * (kBucket << l))`, and bucket boundaries are fixed for the
pyramid's life — appending never re-buckets existing aggregates, and a window can slide
over the same buckets.
- Each level holds its complete buckets in a `std::deque` (O(1) `push_back` and
`pop_front`) plus a `base`. `append()` folds a sample into the in-progress level-0 bucket
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 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
for whole buckets, and partial edge buckets are excluded rather than approximated. Exact
range queries live on `Series`, which owns the raw samples.
`level_for(u64 n, f64 samples_per_pixel)` returns the pyramid level whose buckets hold about
`samples_per_pixel` samples for a window of `n` samples (clamped to levels that exist, to
levels with more than one bucket, and to levels that give the window at least two buckets).
The renderer uses this: a trace's x range is mapped onto sample indices, and `Series::gather`
is asked for that window *at `pyramid().level_for(n, samples_per_pixel)`* whenever a pixel
covers `Style::dense_threshold_spp` samples or more (default 1.5). A 100-million-sample
series therefore stages O(visible pixels) vertices, not O(samples), and `FrameStats` records
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.
## Dense traces are reduced onto the pixel grid
A gathered window denser than the axis is **not** staged as a polyline. For each integer
column of `layout.axes`, `Renderer::draw_traces` emits one exactly vertical segment from the
lowest to the highest screen y any gathered point put in that column, flushed in framebuffer
pixels with `u_snap` on. `FrameStats::column_traces` counts the traces that took this path.
It exists because a polyline over such a window **moirés**, and three things compound to make
it. `MinMaxPyramid::level_for()` picks the level whose buckets hold about
`samples_per_pixel` samples, so a bucket spans **one to two pixel columns**;
`GatherMode::Envelope` emits two points per bucket, so each bucket contributes a near-vertical
min→max hairline *and* a near-vertical connector to the next — about 3.6 near-vertical
hairlines per column; and each of those lands at a **fractional** x, so with ordinary
src-alpha compositing a column's brightness is a non-linear function of the sub-pixel phases
overlapping it. The phase pattern repeats at the beat between the bucket pitch and the pixel
pitch, which is a visible banding, and panning sweeps the beat so it crawls. Reducing to one
segment per column leaves the trace **one** sub-pixel phase, so there is nothing left to beat,
and the snap then puts that one phase on the grid.
Four properties of the reduction are load-bearing:
- **It is gated on the gathered *density*, not on the level.** It runs when the window is
`GatherMode::Envelope` and carries at least two gathered points per column of the axes —
the raw path at 1.5–64 samples per pixel has the same beat and the same cure. The density
term is also what keeps it away from a *sparse* window, whose trace is drawn by the two
segments that enter and leave the frame (`a_sparse_window_between_samples_still_inks_both_margins`);
`Nearest` and `Mean` are excluded because they are one-point-per-bucket modes a band defeats.
- **A hole stays a hole.** A column no gathered point landed in emits nothing. Bridging it
would draw a vertical line over x the series says nothing about.
- **Disjoint columns are joined.** Two neighbouring columns' extremes need not overlap — a
fast edge rises through a whole column — and two disjoint vertical segments read as a broken
trace, which is what the polyline's connector prevented. The later column is extended to
meet the earlier one's nearest bound, so the join is made inside the column that moved.
- **A span shorter than the stroke is drawn *as* the stroke.** The line fragment shader ramps
on the distance to the segment's axis and does not cap its ends, so a vertical segment
shorter than its own width rasterises a sliver of itself, and a flat trace's column, where
the two bounds are equal, nothing at all.
This is the **one carve-out** in `u_snap`'s rule that chrome may be snapped and data may not
(see `LineProgram::set_snap()`): a column *is* a pixel column and its x is already a screen
coordinate, so there is no data position to move it off, and the y bounds the segment reports
are untouched by the snap.
`a_dense_trace_is_drawn_as_whole_pixel_columns` is the guard, and what it measures is the
**number of distinct coverage levels** in the ink: 4 with the reduction against 126 with the
polyline, over one sine period across 60 000 samples. A count of *partially covered pixels* is
**not** the reading and was tried first — a 1.5 px stroke with a 1 px anti-aliasing fringe
always spills into its neighbour column, so the two renderings measure 577 and 652, which
separates nothing. The frame's whole geometry collapses with it: 627 segments against 60 054.
## A reduced column can be graded by dwell
`Style::intensity_grading` draws each of those columns **graded** rather than as one flat band:
bright where the signal sits, faint where it only passes through, the way a digital-phosphor
scope reads. It is off by default — a change of look, not a correction of one — and
`FrameStats::graded_traces` counts the traces that took it, a subset of `column_traces`, since
grading is a way of drawing the reduction and not a third path.
What it puts back is the information the reduction throws away: a column's min and max are the
**same** whether its samples fill the span evenly or pile at its two ends, so a ramp and a pulse
are one band. The distribution cannot be recovered from the band, which is why the gather is
asked at a level **finer than the pixel grid** (`intensity_sub_columns` sub-buckets per column)
and the dwell read from those sub-buckets.
Five things carry it, and each was arrived at by being wrong first:
- **The measure is dwell per row, each segment depositing one unit spread over the rows it
spans** (`1 / height`). Counting +1 per row instead grades a sine's fast middle as brightly as
its rails, which is the pattern inverted — the middle is where the signal spends the *least*
time.
- **The accumulator is one column wide.** Gathered x is monotonic, so points arrive column by
column: accumulate, read, clear. A full `axes.w × axes.h` density image is 33 MB at 4K; one
column of `axes.h` floats is L1-resident. A span is written as a `+1/height` delta at its first
row and the negation one past its last, so it costs two writes whatever its height, and the
running sum over the column is the per-row dwell.
- **Normalisation is per column, against the column's own peak.** A frame-wide scale needs the
whole image to exist before anything can be drawn, *and* reads worse: a column the signal flew
through uniformly comes out faint at every row, so the trace fades out exactly where it moves
fastest. The cost of the local scale is one extra non-clearing walk of the accumulator to find
the peak.
- **Alpha is quantised into `intensity_steps` tiers, one `LineBatch` and one draw call each.**
The line pipeline's colour is a **uniform**, not per-instance (`LineInstance` is four floats of
geometry; only `TextInstance` carries `a_colour`), so a per-segment alpha is inexpressible and
the tiers are what make the gradient drawable at all. `intensity_floor` keeps the faintest tier
visible — a fast riser is one sub-bucket deep everywhere and must not be dimmed out of
existence — and `intensity_gamma` lifts the faint end. A connector between two disjoint columns
is emitted at the **faintest** tier: it is inferred geometry, not a place the signal was
measured to dwell.
- **Grading may not undo the reduction.** `level_for()` answers 0 — raw samples — as soon as the
asked-for fraction of a column falls under one bucket (`kBucket` is 64), so the finer ask is
**floored at level 1** whenever the flat path would have used the pyramid. Without the floor
every moderately dense frame became an O(visible) gather: measured in udpscope at 1.3 MS/s over
a 2 s window, 172 samples a pixel, the flat path sat on the pyramid and grading dropped the
whole figure to raw (`raw=1154 lod=0` against `raw=710 lod=453`) and cost 2.3 → 6.4 ms of draw
mean. The floor degrades the *look* instead — fewer sub-columns than asked for, down to what the
coarsest buckets give, which at 64 samples a bucket is still two or three per column. That is
also why raising `intensity_sub_columns` past about 8 buys nothing: the bucket size is the
limit, not the number.
`a_dense_trace_can_be_graded_by_dwell` is the guard, and it draws the same frame both ways. It
profiles the mean peak coverage over the band's two outer fifths against its middle fifth: the
flat band is flat (**191 and 191**, one ink) and the graded one is **191 against 131**, a middle
at 0.69 of the edge. That ratio is a *tier* ratio and not the dwell ratio — the default gamma
0.45 and floor 0.3 compress it on purpose. The geometry stays bounded by the axis and not by its
area: **8303 segments over a 572 px axis**, 14.5 runs a column, against the flat path's 627 at
one end and `572 × 156` rows at the other. A fourth frame over a window wide enough that the
flat path is on the pyramid is what exercises the floor (`raw=0 lod=1 … seg=634
visible=200001`); the test's first window sits at 105 samples a pixel, where both paths are raw
and the floor is never reached.
## One sample or a statistic of many
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
implemented once over `copy_raw()`: the bucket-aligned interior comes from the pyramid
(O(log n) bucket aggregates) and at most two partial edge buckets are read raw and
scanned, so the cost is O(`kBucket`) worst case and **independent of the series length**,
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 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
is part of the result's contract, not an implementation detail.
## Measured frame times
These are the demos' own frame summaries, taken from a `linux-release` build, **all of them
llvmpipe (Mesa software rasteriser) under Xvfb** — no run touched a GPU. Treat them as
upper bounds and as a record of the software path, not as hardware numbers.
| demo | window | mean ms/frame | work |
|---|---|---|---|
| GLFW (`plot_glfw_demo`) | 1280×720 | 0.42 | one 1 MS/s `StreamSeries` |
| ImGui (`plot_imgui_demo`) | 1280×720 | 0.37 | the same stream, embedded |
| multiplot (`plot_multiplot_demo`) | 1280×720 | 0.66 | 3 rows, 6 traces, 500 kS/s each |
| Qt6 (`plot_qt_demo`) | 820×588 at exit | 3.23 | one stream, `QOpenGLWidget` FBO |
| GTK4 (`plot_gtk_demo`) | 810×564 at exit | 2.85 | one stream, `GtkGLArea` FBO |
The multiplot run stages 18 651 segments for ~4.5 million visible samples, which is the
pyramid doing its job: the frame is bounded by the pixels, not the samples. The Qt and GTK
figures are dominated by their toolkit's texture-backed FBO under llvmpipe, not by the
library.
+298
View File
@@ -0,0 +1,298 @@
# Embedding into a host
A host owns the window, the GL context and the event loop; `plot` owns none of them. The
contract the renderer offers is small: make a 3.3 core context current, resolve the entry
points with `plot::gl::load()`, call `Renderer::init()`, then each frame
```cpp
renderer.style().ui_scale = <device pixels per logical unit>;
renderer.begin_frame(fb_w, fb_h, viewport_px); // clears exactly viewport_px
renderer.draw(plot); // or renderer.draw(figure)
renderer.end_frame(); // restores the GL state it moved
```
and hand the plot (or figure) whatever events the host receives. The `Renderer` never
creates, binds or resizes a framebuffer, never reads your event loop, and never sees a
pointer or a button.
Below are the four shipped adapters. Each is a separate opt-in target
(`plot::glfw`, `plot::imgui`, `plot::qt6`, `plot::gtk4`) and each reads its own source
under `adapters/`.
## Shared conventions
**Pixel space.** Every `Rect`, viewport and pointer position `plot` speaks is in
**device (framebuffer) pixels, origin top-left**. Every host below reports geometry in
*logical* units (window points, widget pixels) and converts before forwarding, so a
gesture lands on the pixels the plot was laid out in.
**DPI / `ui_scale`.** The metrics in `render::Style` (font size, stroke widths, paddings,
tick spacing) are **logical** units; `Style::ui_scale` is device pixels per logical unit,
and the renderer converts each metric through `Style::device()` exactly once. The host
writes `ui_scale` from its own display, so a 12 px label is the same physical size on a 1x
and a 2x panel. The one metric that is *not* scaled is `dense_threshold_spp`, a
samples-per-pixel density (see [styling.md](styling.md)). How each host supplies the
scale:
| host | value written to `ui_scale` | source |
|---|---|---|
| GLFW | `fb_scale_x` = framebuffer width / window width | `glfwGetFramebufferSize()` vs `glfwGetWindowSize()`, re-read on resize |
| ImGui | `io.DisplayFramebufferScale.x` | filled by the platform backend |
| 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
and no screen capture. The invocations it uses (each demo, from the repository root, all
five exit `0`):
```sh
PLOT_DEMO_EXIT_AFTER_MS=4000 PLOT_DEMO_SCREENSHOT=/tmp/glfw.ppm \
xvfb-run -a ./build/release/examples/plot_glfw_demo
PLOT_DEMO_EXIT_AFTER_MS=4000 PLOT_DEMO_SCREENSHOT=/tmp/multiplot.ppm \
xvfb-run -a ./build/release/examples/plot_multiplot_demo
PLOT_DEMO_EXIT_AFTER_MS=4000 PLOT_DEMO_SCREENSHOT=/tmp/imgui.ppm \
xvfb-run -a ./build/release/examples/plot_imgui_demo
PLOT_DEMO_EXIT_AFTER_MS=4000 PLOT_DEMO_SCREENSHOT=/tmp/qt.ppm \
QT_QPA_PLATFORM=xcb xvfb-run -a -s "-screen 0 1920x1200x24" \
./build/release/examples/plot_qt_demo
PLOT_DEMO_EXIT_AFTER_MS=4000 PLOT_DEMO_SCREENSHOT=/tmp/gtk.ppm \
GDK_BACKEND=x11 xvfb-run -a -s "-screen 0 1920x1200x24" \
./build/release/examples/plot_gtk_demo
```
The `-s "-screen 0 1920x1200x24"` shape matters for Qt and GTK, whose demos script a
resize before the exit frame; a bare `xvfb-run` screen (640×480) clamps the window and
that self-check reports a false failure.
## GLFW — `plot::glfw`
`#include <glfw/glfw_host.hpp>`, link `plot::glfw` (which links `plot::core`). This is the
reference host: `Host` owns the GLFW window and its 3.3 core context, the GL entry points,
the `Renderer`, the `Plot`, an `input::Controller` and the frame loop.
```cpp
plot::glfw::Host host;
plot::Plot& p = host.plot(); // populate axes/traces here
plot::Status st;
if (!host.run({1280, 720, "plot"}, [](plot::Plot& p, plot::f64 dt) { /* feed data */ }, &st)) { ... }
```
What `run()` does, in order:
1. `glfwInit()`, then window hints for OpenGL **3.3 core** (plus forward compatibility) and
`glfwCreateWindow()`, `glfwMakeContextCurrent()`, `glfwSwapInterval(vsync)`.
2. `plot::gl::load(&resolve_gl)`, where `resolve_gl` wraps `glfwGetProcAddress` (a function
pointer respelled as `void*` by bit copy).
3. `Renderer::init()`.
4. Per frame: `glfwPollEvents()` → `refresh_sizes()` →
`controller.set_viewport(framebuffer rect)` → the host's `frame` callback →
`begin_frame(fb_width, fb_height, viewport)` → `draw(plot)` (or `draw(figure)`) →
`[screenshot]` → `end_frame()` → `glfwSwapBuffers()`.
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`, 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 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.
## Dear ImGui — `plot::imgui`
`#include <imgui/imgui_plot.hpp>`, link `plot::imgui`. Build-tree only: ImGui is supplied
by `FetchContent`, so `find_package(plot)` does not export this target.
`PlotWidget::draw(id, plot, size, renderer)` must be called between `ImGui::NewFrame()` and
`ImGui::Render()`, from inside a window. It:
1. opens a `BeginChild` region of the requested size (no scrollbar) and forces
`ImGuiCol_ChildBg` transparent, so the widget never paints over its own plot;
2. intersects the child's rectangle with ImGui's clip rect, so a child the host cuts off
does not paint over what is beside it;
3. covers the visible region with an `InvisibleButton` and reads `IsItemHovered()`; this is
the hover gate (false while another widget is being dragged) and the item that
`SetItemKeyOwner(ImGuiKey_MouseWheelY)` claims the wheel with, so the plot's zoom wins
over window scrolling;
4. maps the child's point rectangle to framebuffer pixels through
`io.DisplayFramebufferScale`, gives the controller that region, and renders immediately
(`begin_frame`/`draw`/`end_frame`) into it.
Because ImGui reports button *levels* and the controller wants *edges*, the widget
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). 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
default framebuffer. A host that renders into an FBO or a sub-viewport must place the plot
itself with `Renderer::begin_frame()` and `plot.set_viewport()`.
## Qt6 — `plot::qt6`
`#include <qt6/qt_plot_widget.hpp>`, link `plot::qt6`. `PlotWidget` is a `QOpenGLWidget`,
so it draws into the FBO Qt binds before `paintGL()`, and `QOpenGLWidget` needs no target
of its own.
**Precondition, set before the widget exists** (a `QOpenGLWidget` is handed the
application default `QSurfaceFormat` and cannot renegotiate):
```cpp
QSurfaceFormat fmt;
fmt.setVersion(3, 3);
fmt.setProfile(QSurfaceFormat::CoreProfile);
fmt.setSwapBehavior(QSurfaceFormat::DoubleBuffer);
fmt.setDepthBufferSize(0);
fmt.setStencilBufferSize(0);
QSurfaceFormat::setDefaultFormat(fmt);
QCoreApplication::setAttribute(Qt::AA_UseDesktopOpenGL); // before QApplication
```
Lifecycle: `initializeGL()` calls `initializeOpenGLFunctions()`, `plot::gl::load()`,
`Renderer::init()` and records the outcome in `init_status()` (a `QOpenGLWidget` virtual
cannot return a `Status`; it does not assert). `resizeGL()` refreshes the viewport so a
gesture arriving before the next paint maps against the new geometry. `paintGL()` sets the
viewport on the controller, runs the frame callback, sets `ui_scale` to
`devicePixelRatioF()`, then `begin_frame`/`draw`/`end_frame`, and records frame timings
(`frames_painted()`, `last_frame_ms()`, `mean_frame_ms()`).
Input translation: Qt reports press/release edges directly, so `mousePressEvent` /
`mouseMoveEvent` / `mouseReleaseEvent` / `mouseDoubleClickEvent` / `wheelEvent` /
`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`). 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
translation unit using the widget must keep that order and must not name a `plot::gl::GL_*`
enumerator after a Qt GL header.
## GTK4 — `plot::gtk4`
`#include <gtk4/gtk_plot_area.hpp>`, link `plot::gtk4`. GTK cannot have a GObject subclass
declared around a C++ class (and `GtkGLArea`'s data is private), so this adapter is a
factory, not a subclass:
```cpp
plot::gtk4::PlotArea* area = plot::gtk4::PlotArea::create(plot);
GtkWidget* widget = area->widget(); // put it in your container
```
`create()` builds a `gtk_gl_area_new()`, registers the signals and controllers that drive
it, and returns the wrapper — which GTK owns and frees with the widget; never delete it.
Signals: `::realize` makes the context current, checks `gtk_gl_area_get_error()`, loads the
entry points and initialises the renderer (recording the outcome in `init_status()`);
`::unrealize` releases the renderer's GL objects while the context can still be made
current; `::resize` derives the device-pixel mapping from the widget's logical allocation
and the device size GTK reports, and refreshes the viewport; `::render` sets the viewport on
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 (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 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).
A `GtkGLArea` renders into its own FBO, so a screenshot of the *plot* is a readback of that
FBO, not of the window (GTK's chrome lives outside it) — see the demo's `dump_area_ppm`.
+195
View File
@@ -0,0 +1,195 @@
# Getting started
`plot` is a C++20 library for real-time, high-sample-rate time-series plots drawn with
OpenGL 3.3 core. The core library — the CMake target `plot::core` — has no third-party
dependency at all; the host adapters for GLFW, Dear ImGui, Qt6 and GTK4 are separate,
opt-in targets on top of it (see [embedding.md](embedding.md)).
## Prerequisites
- A C++20 compiler.
- CMake **3.24 or newer**. `cmake/plot_options.cmake` fails the configure with a
`FATAL_ERROR` below that, and the presets in `CMakePresets.json` declare
`cmakeMinimumRequired` 3.24.
- A driver exposing an **OpenGL 3.3 core** profile. `plot::gl::load()` resolves only the
3.3 entry points the library actually calls; anything newer (persistent buffer mapping,
sync objects) is optional, probed through `plot::gl::caps()` and only used when the
driver offers it.
- To build the **test suite** you also need EGL: `tests/CMakeLists.txt` fails the configure
without it, because the GL tests create a real headless EGL context. The library itself
does not need EGL.
- Each adapter needs its own toolkit (GLFW, Dear ImGui downloaded at configure time, Qt6 or
GTK4) — see [building-and-testing.md](building-and-testing.md) for the options.
## Build and install
The presets live in `CMakePresets.json`:
```sh
cmake --preset linux-release # or linux-debug, linux-asan, linux-coverage
cmake --build build/release
cmake --install build/release --prefix /where/you/want
```
Install is on by default (`PLOT_INSTALL`, `ON`). It installs the `plot_core` archive, the
public headers under `include/plot/`, and a CMake package config under
`lib/cmake/plot/`. Adapters join the install/export set only when their toolkit can be
re-found on a consumer's machine; the configure log records which ones were exported and
why.
## Consuming it
```cmake
find_package(plot 0.1 REQUIRED)
target_link_libraries(my_app PRIVATE plot::core)
```
`plot::core` is the alias of the `plot_core` target (`EXPORT_NAME core`, so the installed
name matches the build-tree name). It carries the C++20 requirement and the include
directory as its public interface and nothing else. The exported adapters are named
`plot::glfw`, `plot::qt6`, `plot::gtk4` and (when built) `plot::font_ttf`;
`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
#include <plot/plot.hpp> // core, math, gl, data, render, plot, input — in layer order
```
Adapters are deliberately **not** in the umbrella: their headers are reached as
`<glfw/glfw_host.hpp>`, `<imgui/imgui_plot.hpp>`, `<qt6/qt_plot_widget.hpp>` and
`<gtk4/gtk_plot_area.hpp>`, so including the umbrella never drags a toolkit in.
## A minimal GLFW program
The program below opens a window, creates a 3.3 core context, loads the GL entry points,
and draws one trace. It uses the GLFW toolkit for the window and `plot::core` directly —
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 (`-I build/debug/include` is where
CMake generates `plot/version.hpp`, which the umbrella pulls in):
```sh
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
```
```cpp
// A minimal plot: one window, one axis pair, one trace.
#include <plot/plot.hpp> // the whole public surface, in layer order
#include <plot/gl/loader.hpp>
#define GLFW_INCLUDE_NONE // keep GLFW from including <GL/gl.h> and its macros
#include <GLFW/glfw3.h>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <vector>
namespace {
// GLFW hands back `void (*)(void)`; plot::gl::ProcLoader wants `void*`.
void* resolve_gl(const char* name) {
GLFWglproc p = glfwGetProcAddress(name);
void* out = nullptr;
static_assert(sizeof(out) == sizeof(p));
std::memcpy(&out, &p, sizeof(out));
return out;
}
} // namespace
int main() {
if (glfwInit() != GLFW_TRUE) return 1;
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE);
GLFWwindow* window = glfwCreateWindow(800, 480, "plot quick-start", nullptr, nullptr);
if (window == nullptr) { glfwTerminate(); return 1; }
glfwMakeContextCurrent(window);
glfwSwapInterval(1);
plot::Status st;
if (!plot::gl::load(&resolve_gl)) { std::fprintf(stderr, "gl::load failed\n"); return 1; }
plot::Renderer renderer;
if (!renderer.init(&st)) { std::fprintf(stderr, "init failed: %s\n", st.message); return 1; }
// One trace: 512 samples of a sine, x = sample index.
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);
plot::Plot plot;
const plot::u32 x_axis = plot.add_x_axis({"x", {0.0, 511.0}, true});
const plot::u32 y_axis =
plot.add_y_axis({"signal", {-1.0, 1.0}, true, plot::AxisPlace::Left});
plot.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, x_axis, y_axis, "sine"});
plot::input::Controller controller(plot);
while (glfwWindowShouldClose(window) == GLFW_FALSE) {
glfwPollEvents();
int fb_w = 0, fb_h = 0;
glfwGetFramebufferSize(window, &fb_w, &fb_h);
if (fb_w == 0 || fb_h == 0) continue;
const plot::Rect viewport{0.0f, 0.0f, static_cast<plot::f32>(fb_w),
static_cast<plot::f32>(fb_h)};
controller.set_viewport(viewport);
renderer.style().ui_scale = 1.0f; // device pixels per logical unit
renderer.begin_frame(static_cast<plot::u32>(fb_w), static_cast<plot::u32>(fb_h), viewport);
renderer.draw(plot);
renderer.end_frame();
glfwSwapBuffers(window);
}
glfwDestroyWindow(window);
glfwTerminate();
return 0;
}
```
Things worth noticing, because they are the embedding contract in miniature:
- `plot::gl::load()` must run on the thread that owns the current context, and
`Renderer::init()` returns `false` with a `Status` when the load failed or the driver
refused a shader/buffer. The renderer never creates, binds or resizes a framebuffer.
- `begin_frame(fb_w, fb_h, viewport_px)` takes the framebuffer size (for the projection)
and the top-left-origin rectangle the plot occupies; `draw()` fills exactly that region;
`end_frame()` restores the GL state it changed.
- The `Plot` is pure CPU state. When you draw it through a `Renderer`, set colours and
metrics on `renderer.style()`, not on the plot — `draw()` copies the style into the plot
and hands it the `ui_scale` before laying out.
- `ui_scale` is device pixels per logical unit. Here it is `1.0` because the program uses
the framebuffer 1:1; a HiDPI host passes the framebuffer/window ratio instead (see
[embedding.md](embedding.md)).
Next: [architecture.md](architecture.md) for how the pieces fit, [embedding.md](embedding.md)
to put a plot into a real host, and [data-and-performance.md](data-and-performance.md) for
feeding it a live stream.
+240
View File
@@ -0,0 +1,240 @@
# Interaction
Everything in this page is driven by `input::Controller` (`include/plot/input/controller.hpp`,
`src/input/controller.cpp`), which turns host events into gestures knowing nothing about Qt,
GTK, GLFW or ImGui. It is pure CPU and needs no GL context and no font — everything it reads
comes from the `Plot`'s layout, ranges and transforms. It owns no viewport of its own:
`Controller::set_viewport(Rect)` forwards to `Plot::set_viewport()` and refreshes the layout,
so the two cannot disagree.
The plot's own state — cursors, regions, the measurement, the legend, the crosshair — is
owned by `Plot`; the controller only moves it and publishes what is hot.
## Cursors
A cursor is a draggable marker at a data coordinate (`Cursor`, `plot/cursor.hpp`). There are
two orientations, one type:
| orientation | created by | live coordinate | live snap | dead fields |
|---|---|---|---|---|
| `CursorOrientation::Vertical` | `Plot::add_cursor(f64 x, Color)` | `x` on `axis_x` | `SnapMode` | `y`, `h_snap`, `axis_y` |
| `CursorOrientation::Horizontal` | `Plot::add_h_ruler(f64 y, Color)` | `y` on `axis_y` | `HRulerSnap` | `x`, `snap`, `axis_x` |
A vertical cursor is a marker at `x` spanning the axes' **height**; a horizontal ruler is at
`y` spanning their **width**. Dead fields are *ignored*, not honoured: `set_cursor_y()` on a
vertical cursor and `set_cursor_x()` on a ruler change nothing, and neither does the other
orientation's snap setter.
Snaps. `SnapMode::NearestSample` pulls a marker onto the nearest sample of the first visible
trace on its x axis; `SnapMode::Free` leaves it. `HRulerSnap::NearestTick` pulls a ruler onto
the nearest tick of its y axis' tick set (the *last* `layout()`'s set — ticks are generated
there and nowhere else); `HRulerSnap::Free` leaves it.
Grab radius. A pointer within **7 logical pixels** (`kCursorHitRadiusLogical`) of a cursor's
line grabs it. The nearest of a vertical and a horizontal cursor wins; neither orientation
widens the other's reach. The distance is logical, converted through `Style::device()` at the
plot's scale, so the aim is the same physical distance on any display.
Snap on drop. `set_cursor_x()`/`set_cursor_y()` take a `CursorPlacement`: `Snapped` (the
default) applies the cursor's snap rule immediately — the programmatic path; `Free` stores
the value verbatim and leaves the snap to `Plot::snap_cursor()`. The controller uses `Free`
while dragging, so the marker tracks the pointer under the hand and the snap is committed
once, on release. A dragged ruler is additionally clamped to its own y axis' range, because a
pointer past the top or bottom of the axes has no data under it.
## Regions
A region is a shaded x interval with exact statistics (`Region`, `plot/region.hpp`). Create
with `Plot::add_region(const Region&)`; `Region::snap` is `RegionSnap::NearestSample` or
`Free`. The bounds are **ordered**: `set_region(id, x0, x1)` stores min/max, so a drag that
crosses one bound over the other **swaps** them rather than inverting the region. A pair of
bounds that is not both finite is refused (nothing changes).
Three grab zones (`RegionGrab`): `Left` (the bound at `x0`), `Right` (the bound at `x1`) and
`Body` (the interior). The controller resolves them nearest-first against the geometry
`Plot::layout()` measured — **a bound within the grab radius beats the body**, and the nearer
of the two bounds wins; with no bound in range, the topmost region whose interior holds the
pointer is the body. That geometry is the same `RegionLayout` the renderer paints, so what is
drawn and what can be grabbed cannot drift apart.
The bound band is the same 7-logical-pixel radius, cut back so a narrow region keeps a
middle a body drag can reach (`min(radius, (width − 2)/2)`, with a minimum interior of
**2 logical pixels**, `kMinRegionBodyLogical`). A region narrower than **3 logical pixels**
(`kMinRegionEdgeGrabLogical`) is grabbed by its body instead, widened to that same minimum —
otherwise its two bounds would be one target and it could never be moved again. `Body` drag
translates both bounds by one delta, preserving the width exactly; a bound drag moves one end
and holds the other. A snapping region is put on the sample grid once, on release
(`snap_region()`), not on every motion. `remove_region(id)` drops the region and shifts the
ids above it down.
## The measurement band
`Plot::add_measure(cursor_a, cursor_b, trace = 0)` measures the interval between two cursors
over the series of `trace`. The renderer draws the shaded band the readout belongs to from
`Plot::measure_span(id)` (which reports the cursor pair), and the statistics come from
`Plot::measurement(id)`: `dx`, `dy`, `slope`, `mean`, `rms`, `ymin`, `ymax` and the
left-Riemann `integral`. The band's edges *are* those cursors, so it is resized by dragging
either of them. A measurement is re-evaluated only when a cursor moved, when it was added, or
when the series underneath changed — never once per frame; `const` because recomputing fills
a cache.
## The legend
The legend is on by default (`Legend::visible = true`) and free until a trace is named: with
no named visible trace — or no text backend to measure a name with — `Plot::layout()` produces
an empty layout and nothing is drawn. A trace earns an entry by being given a `name`
(`TraceDesc::name`/`Trace::name`). Configure it with `Plot::set_legend(const Legend&)`;
`Legend::place` anchors the panel to a corner of the axes (`TopLeft`, `TopRight`,
`BottomLeft`, `BottomRight`) and `Legend::margin` is both the anchor's inset and the budget
the panel has to fit in, so a panel that does not fit drops entries and ends with `...` rather
than overhanging. See [styling.md](styling.md) for the knobs and their defaults.
## The crosshair
The crosshair is a **hover** instrument, not a cursor: it follows the pointer and exists only
while the pointer is over the data area (`Crosshair`, `plot/crosshair.hpp`). There is at most
one per plot.
- `Plot::set_crosshair(f64 x, bool active)` places a **vertical-only** crosshair: the column
of `x`, a marker on every visible trace at that x, and the readout panel beside it. This is
the form for a caller that has a coordinate and not a pointer.
- `Plot::set_crosshair(f64 x, f64 y, bool active)` adds the **horizontal** line at `y`. This is
the form the controller uses, because a pointer over the axes has both coordinates.
- `Plot::clear_crosshair()` hides it, leaving it where it was. The controller calls this when
the pointer leaves the axes; a press, wheel or key that moves the data under the pointer
*re-anchors* the crosshair rather than clearing it.
- `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
across the figure, and `Figure::set_crosshair(x, hovered_row, y_in_row, active)` adds the
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.
### 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)
A press opens the first of these that applies, in this order (from `Controller::begin_drag`
and `Controller::begin_handle_drag`):
| gesture | drag mode | effect |
|---|---|---|
| **shift + left drag** | `BoxZoom` | both ranges from the selection rect, applied on release |
| **middle drag on the x label strip** | `AxisZoomX` | x-only zoom |
| **middle drag on the y label strip** | `AxisZoomY` | y-only zoom |
| middle drag anywhere else | — | nothing |
| **left or right drag on a handle** | `MoveCursor` / `MoveRegion` / `ResizeRegionLeft` / `ResizeRegionRight` | move the grabbed handle |
| **left or right drag elsewhere** | `Pan` | translate both ranges |
| **wheel** | — | zoom about the pointer (shift: x only, ctrl: y only; neither or both: both axes) |
| **`+` / `-`** | — | zoom about the plot centre |
| **`f`** (or `F`) | — | `auto_fit_x()` / `auto_fit_y()` every axis |
Precedence for a press that is not shift+left and not middle: **cursor marker → region bound
→ region body → pan**. Shift is checked first of all, so a selection box is what shift+left
means even when it starts over a handle. The right button **pans and grabs** exactly as the
left does — both are buttons a host drives drags with, so neither is singled out for handles.
`Ctrl` is not needed for a cursor move and does not change what a press on one does.
Values behind the gestures:
- one wheel notch (or one `+`/`-` press) changes the span by `exp(-0.15)` (~14%,
`kZoomPerNotch`); positive = zoom in;
- a middle-drag axis zoom is one decade per **200 logical pixels**
(`kZoomPerPixel = ln10 / 200`), with the wheel's sign convention (dragging down zooms in);
- a box-zoom selection under **4 logical pixels** on either side (`kMinBoxLogical`) is a
click and leaves the ranges alone.
Events are sanitised before they act: a non-finite coordinate or delta becomes the last known
position and no movement, but the buttons still flow, so a drag whose release arrives with a
garbage position still ends. A wheel amount that is not a number is refused outright (a
zeroed notch would still run the range setters, which clear auto scale). A gesture that sets
a range also clears that axis' `auto_scale` — one place, in `Plot`'s range setters.
What the pointer is on is published to the plot (`Plot::set_hot_handles()`) after every event
that can move the geometry under the pointer, so the renderer draws hovered/grab highlights
without knowing what a pointer is. `hovered_cursor()`, `grabbed_cursor()`, `hovered_region()`,
`grabbed_region()` and `region_grab(u32)` expose the same answers to a host (a resize cursor, a
tooltip, a status line).
## Live follow
An x axis has a follow state, `XFollow::Off` (the default) or `XFollow::Live`
(`Plot::set_x_follow(axis, f)`). `Live` makes the visible window a view from the live edge:
every `layout()` places it at `[newest − offset − span, newest − offset]`, where `newest` is
the newest x any visible streaming trace bound to the axis holds (`Plot::x_live_edge()`).
`Off` means the range is exactly what a caller set or an auto-fit produced.
**`set_x_range()` on a live axis is not an absolute window.** A range handed to
`Plot::set_x_range(axis, r)` while the axis is `Live` *and* has a live edge is read as the
**span** the window should have and the **offset** it implies — `offset = newest − r.max`,
clamped to what the traces still retain — and the visible window follows from those at the
live edge of the moment. `XFollow::Off` stores the range verbatim. The point of the
reinterpretation is that every gesture (pan, wheel, box zoom, axis zoom) reaches the x range
through this one setter, so they all keep following for free.
The offset is clamped to `[0, retained span]` (`Plot::set_x_offset()`, read back with
`Plot::x_offset()`): the upper limit is what the traces still hold, so the window's right
edge never goes behind the oldest retained sample (and the view is left showing the dropped
data rather than inventing samples that are gone); the lower limit is why dragging toward the
live edge stops instead of turning the window inside out. A non-finite offset is refused. The
clamp is applied again by `layout()` every frame.
Consequences while an axis is `Live` *and* has a live edge:
- `auto_scale` is **inert** on that axis: `layout()` does not fit it, because a fit would
overwrite the span (the user's zoom) on every frame. The follow state wins; `auto_fit_x()`
is how such an axis is framed (it fits, sets the span to the retained window's, and returns
the offset to 0 without turning following off).
- Nothing but `set_x_follow()` disarms following. No gesture and no `set_x_range()` reaches
the follow state.
- **Zoom is relative (multiplicative)**: the wheel multiplies the span, so notches compound,
and the anchor is kept still by adjusting the offset. **Pan** shifts the offset by the
drag's data delta and leaves the span alone, so a user can walk back through the retained
window and stops at the live edge.
Arming `Live` on an axis with a live edge places its window at the live edge at once, from the
span it already had. Turning `Live` off leaves the axis where it is: the last live window
becomes an ordinary stored range.
**Figures.** In a `Figure`, the follow state belongs to the link **group**, not the row: a
group pushes one window to many rows, so the state has to be the group's or the same range
would mean a different window in each member. Use `Figure::add_x_link()`, `link_x(row, group)`,
`set_x_link_range(group, r)` and `set_x_link_follow(group, XFollow::Live)`; a group derives
its window once per frame from the newest x across all its members' traces. `link_x()` refuses
a member whose own axis is `Live`.
+188
View File
@@ -0,0 +1,188 @@
# Styling
Every colour and metric the renderer draws with lives in `render::Style`
(`include/plot/render/style.hpp`). A caller sets fields on `Renderer::style()`; the next frame
picks them up, because nothing here is baked into a shader.
Set colours and metrics on the **renderer**, not on the plot: `Renderer::draw()` copies its
`Style` into the plot and hands it the `ui_scale` *before* it lays out, so the strips, tick
densities and strokes the plot measures with are the values the renderer draws with. A plot
drawn without a renderer lays out with whatever `Plot::set_style()` was given.
## The unit rule: logical versus device
A metric a user perceives as a **size** — a font, a stroke width, a padding, a marker, a
swatch, the minimum spacing between ticks — is written in **logical** units, so a 12 px label
is the same physical size on a 1x and a 2x panel. The host states its display with `ui_scale`
(device pixels per logical unit); every consumer converts exactly once, through `device()`:
```cpp
f32 device(f32 logical) const noexcept {
return ui_scale > 0.0f ? logical * ui_scale : logical; // non-positive scale converts 1:1
}
```
`Plot::layout()` uses it to size the label strips, title strips and legend chrome; the renderer
uses it to convert widths and font px (the font is reset at `device(axis_font_px)`).
The one exception is `dense_threshold_spp`, which is a **density** (samples per *rasterised*
pixel), compared against a ratio computed from the framebuffer's own width. Scaling it would
make the chosen LOD level depend on the display rather than on the data, so it is used raw.
## `Style` fields
Colours (neither logical nor device — nothing to scale):
| field | default | meaning |
|---|---|---|
| `background` | `0x101418` | frame clear colour and legend/readout panel base |
| `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 |
| `measure_fill` | `0xFFB020` at α 0.15 | the measurement band |
| `crosshair` | `0xE6EBF2` | crosshair lines and the readout panel's border (hue-neutral by design; trace markers keep each trace's own colour) |
The scale:
| field | default | unit |
|---|---|---|
| `ui_scale` | `1.0` | device pixels per logical unit; supplied by the host |
Logical metrics (scaled by `device()`):
| field | default | meaning |
|---|---|---|
| `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` | `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) |
| `handle_width_px` | `5.0` | width of a highlighted draggable handle (a hovered/grabbed cursor tab, a region's hot bound) |
| `tick_min_px` | `60.0` | minimum spacing between major ticks |
Crosshair stroke and dash (logical):
| field | default | meaning |
|---|---|---|
| `crosshair_width` | `0.75` | the crosshair's stroke width (deliberately thinner than a trace's `1.5`) |
| `crosshair_dash_on` | `4.0` | ink length of the dash |
| `crosshair_dash_off` | `4.0` | gap length of the dash; **`0` means solid** |
Device metric (never scaled):
| field | default | meaning |
|---|---|---|
| `dense_threshold_spp` | `1.5` | samples per device pixel at or above which a trace is gathered from the LOD pyramid instead of from raw samples |
## The grid
The grid's **positions** come from the tick set alone: `Grid::build()` emits one `GridLine`
per tick, spanning the perpendicular axis' full range, tagged `major` (a labelled tick) or
not. `Style` decides only how each class is drawn — its width and visibility — and the two
colours above.
A class switched off stages **no geometry at all**: the renderer skips it before the first
segment is staged, so a dense plot's minor grid costs nothing rather than being drawn
transparent. `Grid` therefore carries both classes' geometry whatever the style says; a
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
geometry is added. `dash_off == 0` draws a solid line — the value every other line in the
library uses, so the one pipeline serves them unchanged. A single plot may override all three
knobs without touching the global `Style` through `CrosshairStyle` (below).
## `Legend`
`Legend` (`include/plot/legend.hpp`), set with `Plot::set_legend()`. All sizes are **logical**,
converted through `Style::device()` when `Plot::layout()` measures the panel.
| field | default | meaning |
|---|---|---|
| `visible` | `true` | draw the legend at all (free when nothing is named) |
| `place` | `LegendPlace::TopRight` | corner of `axes` the panel is anchored to |
| `frame` | `true` | paint the panel (background at partial alpha plus a 1-px border) |
| `pad` | `6.0` | inset from the panel edge to its first row, and the swatch↔name gutter (one knob for both) |
| `swatch_w` | `14.0` | swatch width |
| `swatch_h` | `8.0` | swatch height; the row height is `max(swatch_h, line height)` |
| `row_gap` | `3.0` | blank space between rows |
| `margin` | `8.0` | inset from the axes edge, *and* the budget the panel must fit in (so it drops entries rather than overhanging) |
There is no "outside the plot" placement: the strips around `axes` belong to the tick labels
and axis titles, and a legend that reserved space would resize the plot the moment a trace was
named. A truncated legend ends with the constant `kLegendEllipsis` (`"..."`).
## `CrosshairStyle`
A per-`Crosshair` override of the three crosshair knobs, in logical pixels
(`include/plot/crosshair.hpp`), set with `Plot::set_crosshair_style()`. Every field defaults to
`-1.0`, which means "not overridden": the field falls back to the matching `Style::crosshair_*`
metric, so a default-constructed `CrosshairStyle` is exactly the global style.
| field | default | meaning |
|---|---|---|
| `width` | `-1.0` | logical stroke width; `< 0` takes `Style::crosshair_width` |
| `dash_on` | `-1.0` | logical ink length; `< 0` takes `Style::crosshair_dash_on` |
| `dash_off` | `-1.0` | logical gap; `< 0` takes `Style::crosshair_dash_off`, and `0` (once resolved) means solid |
## Traces
A trace's own `width` (`TraceDesc::width`, `Trace::width`) is **logical** too — a stroke like
`line_width_px`, converted by the same `device()` call. Its default colour is `0x4FA3FF` and
its default width `1.5`. Region fill/edge colours are the region's own (`Region::fill`
`0xFFB020` at α 0.15, `Region::edge` `0xFFB020`); a cursor's colour is per-cursor
(`0xFFB020` by default).
## Installing a text backend
The font a plot measures with and the renderer draws with is a `render::TextBackend`
(`include/plot/render/text.hpp`): `measure()`, `height()`, `ascent()`, `em_px()`, `glyph()`,
`cell_uv()`, `atlas_pixels()`, `atlas_width()`, `atlas_height()` and `revision()`.
- `Plot::set_text_backend(const render::TextBackend*)` sets the backend a plot lays its labels
out with. It is **borrowed**, not owned, and may be null (then the label areas fall back to
`PlotLayout`'s defaults and no string is measured).
- `Renderer::set_text_backend(const render::TextBackend*)` sets the face the renderer draws
with. It is also borrowed and must outlive the frames.
**Note the interaction:** `Renderer::init()` binds `render::EmbeddedMonoFont`, and every
`Renderer::draw()` installs that binding on the plot it is given — replacing whatever a host
set with `Plot::set_text_backend()` (a debug build names the one it dropped). So installing a
backend on the *plot* only takes effect for a plot no renderer draws. To draw with your own
face (say a `plot::text::TrueTypeFont` from the optional `plot::font_ttf` target), install it
with `Renderer::set_text_backend()`; the swap takes effect on the next frame, which re-reads
the backend's metrics at `style().device(axis_font_px)` and re-uploads its atlas when the
backend's `revision()` moved. Passing `nullptr` restores the embedded face.
@@ -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).
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
// The reference application: the whole library running as a real program, fed by
// a 1 MS/s producer thread through the GLFW adapter.
//
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
// The GTK4 application: the same 1 MS/s stream the other demos draw, as the
// content of a GtkApplicationWindow whose header bar carries its own "Fit"
// button, with GTK drawing the window chrome around a plot the area renders into
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
// The Dear ImGui application: the same stream the GLFW demo draws, embedded as a
// widget inside an ImGui window that also owns a few controls, with ImGui drawing
// its own chrome into the same framebuffer.
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
// The multi-plot application: three plots stacked in one `Figure`, sharing one x
// axis, each carrying its own signals and its own legend.
//
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
// The Qt application: the same 1 MS/s stream the other demos draw, as the central
// widget of a QMainWindow that also carries its own toolbar, with Qt drawing the
// window chrome around a plot the widget renders into its own framebuffer.
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <plot/core/types.hpp>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <plot/core/types.hpp>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <cstddef>
#include <span>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <plot/core/types.hpp>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <cstddef>
#include <cstdint>
+14 -1
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <vector>
@@ -19,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;
@@ -26,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;
@@ -38,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
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#ifdef PLOT_PYRAMID_COUNT_COVERS
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#ifdef PLOT_PYRAMID_COUNT_MERGES
+39 -11
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <deque>
#include <vector>
@@ -16,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
@@ -34,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
@@ -61,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.
@@ -97,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
@@ -116,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 {
+10 -2
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <atomic>
#include <bit>
@@ -9,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.
///
+73 -16
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <vector>
@@ -6,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
@@ -40,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
@@ -72,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
@@ -94,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
+22 -1
View File
@@ -1,7 +1,11 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#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>
@@ -54,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.
@@ -65,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;
@@ -72,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
+11
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <plot/core/types.hpp>
@@ -55,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,
@@ -124,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)) \
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <plot/gl/api.hpp>
+11 -2
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <plot/core/color.hpp>
#include <plot/core/status.hpp>
@@ -33,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 {
@@ -70,7 +76,7 @@ class Handle {
GLuint id_ = 0;
private:
u32 generation_ = 0;
u64 generation_ = 0;
};
/// Vertex buffer object. All fallible operations report through `Status`;
@@ -169,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
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <plot/gl/api.hpp>
+83 -4
View File
@@ -1,5 +1,10 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <vector>
#include <plot/core/rect.hpp>
#include <plot/core/types.hpp>
#include <plot/input/events.hpp>
@@ -44,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
@@ -71,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:
///
@@ -200,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.
@@ -234,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();
@@ -314,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
+34
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <plot/core/types.hpp>
@@ -32,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
@@ -62,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
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <plot/core/types.hpp>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <cmath>
#include <plot/core/types.hpp>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <cmath>
#include <plot/core/types.hpp>
+10
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
/// Umbrella header: pulls in every public component of the plot library, in
@@ -34,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>
@@ -61,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>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <string>
+42 -9
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <string>
#include <vector>
@@ -49,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.
@@ -76,6 +102,13 @@ struct Axis {
/// group stops following or the row leaves it.
bool live_owner = false;
f64 live_owner_newest = 0.0;
/// Set while this plot is a member of a `Figure` x link group (`Figure::link_x()`
/// writes it, `unlink_row()` clears it). The group owns the shared x window *and*
/// its follow state -- `Figure::link_x()` refuses a `Live` member joiner -- so
/// `Plot::set_x_follow(Live)` on this axis is refused after linking too, rather
/// than letting a later call put the member's axis back into a state the group
/// cannot drive (see `Plot::set_x_follow()`).
bool link_member = false;
/// Ticks for the last `refresh()`, ascending, majors and (when asked) minors.
std::vector<Tick> ticks;
+93 -28
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <string>
#include <vector>
@@ -19,12 +22,43 @@ namespace plot {
/// and `dash_off` are independent, so a caller may override only the dash, or only the
/// width, and leave the rest to the style.
struct CrosshairStyle {
f32 width = -1.0f; ///< logical stroke width; `< 0` takes `Style::crosshair_width`.
f32 width = -1.0f; ///< logical stroke width; `< 0` takes `Style::crosshair_width`
///< (the default). Only a *negative* value defers: `0` is used
///< literally and draws an invisible line.
f32 dash_on = -1.0f; ///< logical ink length; `< 0` takes `Style::crosshair_dash_on`.
f32 dash_off = -1.0f; ///< logical gap; `< 0` takes `Style::crosshair_dash_off`, and `0`
///< (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.
///
@@ -39,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
@@ -53,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;
@@ -62,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()`.
@@ -79,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
@@ -99,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
@@ -113,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.
@@ -145,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;
@@ -156,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`.
+23
View File
@@ -1,8 +1,12 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <string>
#include <plot/core/color.hpp>
#include <plot/core/types.hpp>
#include <plot/math/vec2.hpp>
namespace plot {
@@ -83,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
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#ifdef PLOT_COUNT_MEASURE_RECOMPUTES
+29 -1
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <span>
#include <vector>
@@ -123,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.
@@ -140,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.
@@ -309,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;
};
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <vector>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <vector>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <plot/core/types.hpp>
#include <plot/data/series.hpp>
+133 -14
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <vector>
@@ -85,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
@@ -98,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.
@@ -149,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
@@ -218,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
@@ -326,6 +349,11 @@ class Plot {
/// newest sample* rather than a pair of numbers. With no live edge -- no trace on
/// the axis, or none of them streaming -- nothing is recomputed and the axis
/// keeps its stored range, `auto_scale` included, exactly as an `Off` axis does.
///
/// On an axis that is a member of a `Figure` x link group, arming `Live` is
/// **refused** (the state is left as it was): the group owns the shared window and
/// its follow state, exactly as `Figure::link_x()` refuses a `Live` member joiner.
/// See `set_x_link_member()`.
void set_x_follow(u32 axis, XFollow f);
/// The follow state of one x axis. `Off` for an axis that does not exist.
XFollow x_follow(u32 axis) const;
@@ -360,6 +388,14 @@ class Plot {
/// axis no owner has claimed, and for an axis that does not exist.
bool x_live_owner(u32 axis, f64& newest) const noexcept;
/// Publishes -- or clears -- that this axis is a member of a `Figure` x link group.
/// `Figure::link_x()` sets it when a row joins and `unlink_row()` clears it when one
/// leaves; it is what makes `set_x_follow(Live)` refused on a linked axis, because
/// the group owns the shared window and the follow state. A no-op for an axis that
/// does not exist. Not for callers outside the figure: a `Plot` that is not in a
/// figure never has a linked axis.
void set_x_link_member(u32 axis, bool linked) noexcept;
/// Data -> pixels for one axis, built from the axis' range, log flag and the
/// current layout. Always invertible: a collapsed range or a collapsed axes
/// rect is widened by math::Transform, so to_data_*(to_screen_*(v)) == v.
@@ -448,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
@@ -551,9 +627,11 @@ class Plot {
/// A floor on the left gutter `layout()` reserves, in device pixels: the label
/// strip and the title column together are never narrower than this, so `axes`
/// never starts left of `viewport().x + min_y_gutter()`. 0.0 -- the default --
/// imposes nothing and the plot sizes its strip from its own widest tick label,
/// which is what a plot standing on its own has always done.
/// starts no further left than `viewport().x + min_y_gutter()` **up to the cap
/// below** -- a floor wider than half the viewport is clamped exactly as the
/// measured strip is, so the guarantee is `min(min_y_gutter(), y_budget)`. 0.0
/// -- the default -- imposes nothing and the plot sizes its strip from its own
/// widest tick label, which is what a plot standing on its own has always done.
///
/// It exists for `Figure`, which is the only thing that can know the answer: a row
/// cannot see its siblings' tick labels, so the figure lays each row out once to
@@ -567,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_; }
@@ -627,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
@@ -648,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
@@ -755,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.
@@ -765,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_{};
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <string>
+21 -4
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <string>
#include <vector>
@@ -45,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
@@ -65,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
+38 -2
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <plot/core/color.hpp>
#include <plot/core/types.hpp>
@@ -6,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 {
@@ -13,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
@@ -23,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
@@ -32,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; }
+18 -8
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <plot/core/status.hpp>
#include <plot/core/types.hpp>
@@ -32,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;
@@ -43,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
@@ -134,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; }
@@ -162,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.
@@ -183,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(); }
@@ -203,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(); }
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#ifdef PLOT_RING_STATS
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <plot/core/types.hpp>
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <plot/core/types.hpp>
#include <plot/math/vec2.hpp>
+13 -8
View File
@@ -1,4 +1,8 @@
// SPDX-License-Identifier: MIT
// 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>
@@ -57,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
@@ -89,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
@@ -101,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 --
@@ -121,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_{};
+16 -2
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <plot/core/color.hpp>
#include <plot/core/status.hpp>
@@ -32,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_;
@@ -63,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_;
+157 -27
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <span>
#include <vector>
@@ -26,8 +29,18 @@ namespace plot {
struct FrameStats {
u32 raw_traces = 0; ///< traces drawn from raw samples (level 0)
u32 lod_traces = 0; ///< traces drawn from a min/max pyramid level
/// Traces whose geometry was reduced onto the pixel grid: one vertical segment
/// per column, lit from that column's own extremes. Counts a trace from either
/// path above -- the reduction is about the *density* of the gathered points,
/// not about which level produced them.
u32 column_traces = 0;
/// Traces whose columns were graded by dwell rather than drawn as one flat band
/// (`Style::intensity_grading`). A subset of `column_traces`: grading is a way of
/// drawing the reduction, not a third path.
u32 graded_traces = 0;
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
};
@@ -67,10 +80,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
@@ -212,7 +241,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*:
@@ -281,15 +310,14 @@ class Renderer {
/// Writes the framebuffer's viewport region as a binary PPM (P6, 8 bits per
/// channel, rows top-down) so a headless render can be looked at without a
/// window. Reads the framebuffer that is bound now, which is the one the frame
/// was drawn into. Returns false, with `st` filled in, on a bad argument or an
/// unreadable pixel.
///
/// The one entry point here that is not `noexcept`: it sizes its readback hole
/// and its output row, so an allocation failure propagates `std::bad_alloc`
/// rather than terminating a process that could have carried on without a
/// screenshot. A dump is a diagnostic, and no `Status` code could say more than
/// the exception already does.
bool dump_frame_ppm(const char* path, Status* st = nullptr) const;
/// was drawn into. Returns false, with `st` filled in, on a bad argument, an
/// unreadable pixel, or an allocation the readback could not make -- which is
/// reported as `ErrorCode::OutOfMemory`, exactly as `TrueTypeFont::from_file()`
/// reports the same failure. `noexcept` like every other entry point here: the
/// buffers are sized before anything is on disk, so the two failures that can
/// arise from sizing them are a `Status`, not a `std::bad_alloc` across the API
/// boundary and a half-written file.
bool dump_frame_ppm(const char* path, Status* st = nullptr) const noexcept;
private:
/// The backend the plot layer sees, at the size the text pipeline draws it:
@@ -344,12 +372,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.
@@ -368,12 +403,27 @@ 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;
/// The same, for a batch other than the renderer's own `lines_`. The graded trace
/// pass stages one batch per alpha step, and a step has to be a draw call of its
/// own because the line pipeline takes its colour from a uniform -- so it needs to
/// flush a batch it chose rather than the member one. Every other caller flushes
/// `lines_` and uses the overload above.
void flush_lines(render::LineBatch& batch, 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
@@ -406,13 +456,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.
@@ -428,7 +486,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
@@ -457,6 +516,29 @@ class Renderer {
/// The data helpers take it too; they read the axes rect and the transforms it
/// pins.
void draw_traces(const Plot& plot, const PlotLayout& layout);
/// One gathered window reduced onto the pixel grid and staged in framebuffer
/// pixels: for each integer column of `layout.axes`, one exactly vertical
/// segment from the lowest to the highest screen y any gathered point put in
/// it. Returns how many columns were staged (0 stages nothing); the caller
/// flushes with `pixel_mapping()` and `snap = true`.
///
/// `gather_` is the input, read with the same origin `draw_traces` asked the
/// series for. See `draw_traces` for when this path is taken and why.
u32 stage_trace_columns(const Plot& plot, const PlotLayout& layout, const Trace& trace,
f64 x_origin, u32 points);
/// The same reduction, graded by **dwell**: a column is drawn as a stack of runs,
/// each at the alpha step its own share of the column's samples earns, so a flat
/// top is bright and the edge leading to it is faint (`Style::intensity_grading`).
///
/// Stages into `steps_`, one `LineBatch` per alpha step, which the caller flushes
/// in turn -- the line pipeline's colour is a uniform, so a step is a draw call.
/// Returns the segments staged across all steps (0 stages nothing).
///
/// `gather_` is the input and must have been asked for at the *finer* level
/// `Style::intensity_sub_columns` implies; a column's dwell is not recoverable
/// from its own min and max (see `Style::intensity_grading`).
u32 stage_trace_density(const Plot& plot, const PlotLayout& layout, const Trace& trace,
f64 x_origin, u32 points);
/// The cursors, both orientations: one stroke per cursor, placed at the coordinate its own
/// orientation owns (`Plot::cursor_screen_coordinate()`) and spanning the other axis' whole
/// range, in its own colour -- or in the accent at handle width while it is hovered or
@@ -492,6 +574,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
@@ -529,6 +617,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
@@ -547,8 +641,34 @@ 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
/// One (lowest y, highest y) pair per pixel column of the axes, re-used:
/// `stage_trace_columns()`'s working set. Two floats per column, so an 8K-wide
/// axis is 64 kB -- bounded by the *screen*, where `gather_` is bounded by the
/// window.
std::vector<f32> columns_;
/// `stage_trace_density()`'s working set, and deliberately **one column wide**: the
/// gathered points arrive in x order, so a column can be accumulated, read and
/// cleared before the next one starts. A full-axes density image would be
/// `axes.w * axes.h` (33 MB at 4K); this is `axes.h` (a few kB) and stays in L1.
///
/// Each sub-bucket's span is written as a +1 at its first row and a -1 one past
/// its last, so accumulating a span costs two writes whatever its height; the
/// running sum over the column is then how many sub-buckets cover each row, which
/// is the dwell.
std::vector<f32> column_rows_;
/// One batch per alpha step of the grading (`Style::intensity_steps`), re-used
/// across frames: the line pipeline takes its colour from a uniform, so segments
/// at different alphas cannot share a draw call and must be staged apart.
std::vector<render::LineBatch> steps_;
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
@@ -557,6 +677,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;
+23 -6
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
namespace plot::render {
@@ -13,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.
///
@@ -39,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;
+141 -5
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <plot/core/color.hpp>
@@ -35,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);
@@ -50,8 +64,12 @@ struct Style {
/// (`Trace`'s default, 0x40C0FF) that a hue-based reading cannot tell from a cyan crosshair --
/// only lightness would separate them, which no reader can rely on. A near-white can never be a
/// saturated trace of *any* hue, and it is still plainly not `cursor`'s orange, which is what
/// the spec asks for. The 1 px stroke is kept: form (a full-height thin line over a polyline) is
/// already distinct, so no dashes.
/// the spec asks for.
///
/// The stroke is thin and dashed by default -- see `crosshair_width`, `crosshair_dash_on` and
/// `crosshair_dash_off` below for the values: thinner than a trace so the instrument reads as
/// chrome rather than data, and dashed so it is distinguishable by *form* as well as by
/// lightness.
Color crosshair = Color::hex(0xE6EBF2);
/// The crosshair's stroke and dash, in logical pixels (spec §17). The crosshair is a
@@ -68,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 ----------------------------------------------------------
@@ -89,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
@@ -117,6 +177,82 @@ struct Style {
///< sides draw through the one instanced
///< anti-aliased quad path.
// --- intensity grading: counts and ratios, never sizes -------------------
/// Draw a dense trace's pixel columns **graded by how long the signal dwells at
/// each height**, the way a digital-phosphor scope does, instead of as one flat
/// band per column: bright where samples pile up (a flat top, a peak), faint
/// where they only pass through (an edge, a noise excursion).
///
/// Off by default, because it is a change of *look* and not a correction: the
/// flat band is the honest answer to "what did this column reach", and a host
/// that wants that -- or that draws few enough samples never to take the column
/// path at all -- should not have its traces restyled by an upgrade. A scope-like
/// host turns it on.
///
/// It is not derivable from the flat band. A column's min and max are the same
/// whether its samples fill the span or sit piled at its two ends, which is
/// exactly the difference between a ramp and a pulse -- so grading needs the
/// gather asked at a *finer* level than the pixel grid (`intensity_sub_columns`)
/// and reads the distribution from those sub-buckets.
///
/// The measure is *dwell per row*, and the weighting is the whole of it: each
/// sub-column segment deposits **one unit spread over the rows it spans**, so a
/// tall crossing leaves a thin smear and a short one at a turning point leaves a
/// bright mark. Counting a row at +1 per segment that touches it instead -- the
/// obvious thing -- grades a sine's fast middle as brightly as its rails, which
/// is the pattern inverted.
bool intensity_grading = false;
/// Sub-buckets per pixel column the gather is asked for when grading: the level
/// is chosen for `samples_per_pixel / intensity_sub_columns`, so a column's
/// density is read from this many envelopes instead of from one.
///
/// It buys resolution in *dwell*, linearly, for the same factor in gathered
/// points -- and the points stay bounded by the screen, which is the property
/// that matters (8 of them over a 1000 px axis is 16 000 points whatever the
/// window holds). Below about 4 a pulse's flat levels and its edge are too few
/// samples apart to separate.
///
/// It is a *want*, not a promise: the renderer floors the level it asks for at the
/// pyramid's coarsest bucket level, so a window too sparse to hold this many buckets
/// a column is graded from however many it does hold. Without that floor `level_for`
/// answers 0 -- raw samples -- the moment a sub-column falls under one bucket, which
/// turns a frame the flat path draws off the pyramid into an O(visible) gather. So
/// raising this past about 8 costs resolution nothing and buys it nothing either: the
/// bucket size is the limit, not this number.
u32 intensity_sub_columns = 8;
/// Quantisation of the grading, in alpha steps. Each step is one draw call (the
/// line pipeline's colour is a uniform, so a per-segment alpha would mean a
/// per-instance one), and the segments are bounded by the columns, so the cost
/// of a step is a draw call over a few hundred instances.
///
/// Four is enough to read a waveform's structure and few enough that a step is a
/// visible tier rather than a gradient -- the point is to separate "dwelt here"
/// from "passed through", not to measure.
u32 intensity_steps = 4;
/// Alpha the faintest graded step is drawn at, as a fraction of the trace's own.
///
/// Not 0: a column the signal merely passed through is still a column the signal
/// *reached*, and a riser dimmed out of existence is a square wave drawn as two
/// disconnected lines. This is the floor that keeps the trace continuous.
f32 intensity_floor = 0.3f;
/// Exponent the normalised dwell is raised to before it is quantised. Below 1 it
/// lifts the faint end, which is what makes a fast edge visible beside a flat top
/// whose dwell is three orders of magnitude greater.
///
/// The dwell is normalised **against the column's own peak**, not against a scale
/// for the whole frame, so the steps spend their range on the structure *inside*
/// each column. One scale for the frame needs the whole `w * h` density image
/// (33 MB at 4K, which is why the accumulator is one column wide) and reads
/// worse: a column the signal flew straight through holds little dwell at any
/// height, so it would grade uniformly faint -- a trace that fades out exactly
/// where it moves fastest.
f32 intensity_gamma = 0.45f;
/// `logical` in device pixels: the single conversion point for every logical
/// metric above, so the arithmetic appears once per consumer instead of a `*
/// ui_scale` scattered over the renderer and the layout.
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <cmath>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <plot/core/status.hpp>
#include <plot/core/types.hpp>
+9 -5
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <plot/core/status.hpp>
#include <plot/core/types.hpp>
@@ -51,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(); }
@@ -81,7 +85,7 @@ class TextVertexArray {
TextBuffer buffer_;
gl::VertexArray vao_;
u32 instances_ = 0;
u32 epoch_ = 0;
u64 epoch_ = 0;
};
} // namespace plot::render
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <memory>
#include <span>
+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;
}
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/core/status.hpp>
namespace plot {
+36 -9
View File
@@ -1,6 +1,10 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/data/array_series.hpp>
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <utility>
@@ -20,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_; }
@@ -49,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 {
+47 -19
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/data/detail/cover_count.hpp>
#include <plot/data/detail/merge_count.hpp>
#include <plot/data/minmax_pyramid.hpp>
@@ -9,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) {
@@ -69,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);
@@ -93,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);
}
@@ -112,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) {
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);
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;
} 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
@@ -138,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
@@ -149,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);
}
}
@@ -222,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};
@@ -262,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;
+146 -33
View File
@@ -1,6 +1,10 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/data/series.hpp>
#include <algorithm>
#include <cmath>
#include <cstddef>
namespace plot::data {
@@ -10,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 {
@@ -72,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;
@@ -81,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;
}
@@ -95,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;
}
@@ -112,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);
for (u64 bi = first; bi < last; ++bi) {
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(m.min);
out_xy.push_back(f32(x_0 + f64(last_sample) * step));
out_xy.push_back(m.max);
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(shift + f64(first_sample) * step)); // x relative to x_origin
out_xy.push_back(m.min);
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
+20 -4
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/data/stream_series.hpp>
#include <algorithm>
@@ -15,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); }
@@ -57,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_; }
@@ -72,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
+9
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/gl/handle.hpp>
#include <plot/gl/loader.hpp>
@@ -314,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 {
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/gl/loader.hpp>
#include <plot/gl/caps.hpp>
+334 -55
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/input/controller.hpp>
#include <plot/math/transform.hpp>
@@ -36,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
@@ -133,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) {
begin_drag(e);
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) {
@@ -155,31 +179,59 @@ 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();
const f64 factor = zoom_factor(static_cast<f64>(e.dy));
if (!(factor > 0.0)) return;
const bool shift = has_mod(e.mods, Mod::Shift);
const bool ctrl = has_mod(e.mods, Mod::Ctrl);
// Shift alone restricts the zoom to x, ctrl alone to y; neither (or both)
// zooms both axes. The data coordinate under the pointer stays put.
const bool zoom_x = !(ctrl && !shift);
const bool zoom_y = !(shift && !ctrl);
// 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 (zoom_x) {
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
zoom_x_axis(i, x_zoom_anchor(i, e.x, e.y), factor);
if (e.dy != 0.0f) {
const f64 factor = zoom_factor(static_cast<f64>(e.dy));
// 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);
// Shift alone restricts the zoom to x, ctrl alone to y; neither (or both)
// zooms both axes. The data coordinate under the pointer stays put.
const bool zoom_x = !(ctrl && !shift);
const bool zoom_y = !(shift && !ctrl);
if (zoom_x) {
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
zoom_x_axis(i, x_zoom_anchor(i, e.x, e.y), factor);
}
}
}
if (zoom_y) {
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
zoom_y_axis(i, plot_->y_transform(i).to_data_y(static_cast<f64>(e.y)), factor);
if (zoom_y) {
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
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
@@ -194,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);
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);
// 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);
}
}
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);
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);
}
@@ -360,13 +489,33 @@ 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)) {
mode_ = DragMode::BoxZoom;
selection_ = Rect{e.x, e.y, 0.0f, 0.0f};
return;
// 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();
@@ -469,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
@@ -507,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.
@@ -525,17 +782,24 @@ 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.
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)});
// 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)});
}
}
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)});
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)});
}
}
}
@@ -605,12 +869,25 @@ void Controller::axis_zoom(const PointerEvent& e) {
// A pointer drag reports pixels, not wheel notches, so it uses its own
// sensitivity: 200 px is one decade. The gain is a physical distance the hand
// moves, so the device-pixel delta is converted to logical pixels first -- the
// same drag must zoom the same amount on a 2x display. A scale no host should
// ever set (0, or negative) makes the factor non-positive, and the guard below
// turns that into no zoom at all rather than an axis re-centred on infinity.
const f64 logical_dy = static_cast<f64>(e.dy) / static_cast<f64>(plot_->ui_scale());
// same drag must zoom the same amount on a 2x display.
//
// A scale that is not a positive number is not a scale -- a host that has not
// measured its display, a field nobody filled in -- the same state
// `Style::device()` tolerates. Here it means *no zoom*: dividing by it would make
// `logical_dy` infinite and the factor infinite (or NaN), and that factor reaches
// `set_x_range()`/`set_y_range()`, which store it verbatim and clear `auto_scale`
// -- an axis left non-finite until an auto-fit. A negative scale would silently
// invert the zoom direction too, so the whole class is refused here.
const f64 scale = static_cast<f64>(plot_->ui_scale());
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);
@@ -658,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
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/math/transform.hpp>
namespace plot::math {
+20 -3
View File
@@ -1,17 +1,34 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#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
+99 -28
View File
@@ -1,22 +1,43 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/plot/figure.hpp>
#include <algorithm>
#include <cstdio>
#include <cmath>
#include <cstddef>
namespace plot {
namespace {
/// The height a row never drops below, in device pixels: the floor that keeps a
/// plot's chrome from erasing it in a figure with many rows or a small viewport. It
/// yields to the figure -- it is capped at an equal share of the height there is --
/// rather than pushing rows past the viewport.
/// The height a row never drops below, in **logical** pixels -- the same unit as every other
/// chrome size, converted to device pixels by the row's own `ui_scale()` where the floor is
/// applied. The floor that keeps a plot's chrome from erasing it in a figure with many rows
/// or a small viewport. It yields to the figure -- it is capped at an equal share of the
/// height there is -- rather than pushing rows past the viewport.
///
/// Device pixels, like every rect a figure computes: a `Figure` carries no `Style`
/// -- the renderer's is the one its rows are laid out with -- so there is no logical
/// unit here to convert from. A host that wants a taller floor gives the figure a
/// taller viewport, or gives the rows weights.
constexpr f32 kMinRowHeightPx = 24.0f;
/// A `Figure` carries no `Style` -- the renderer's is the one its rows are laid out with --
/// but every row's `Plot` has the `ui_scale` the renderer installed on it, so the conversion
/// is the row's own and the guarantee is the same *physical* height at any DPI. A host that
/// 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
@@ -69,8 +90,12 @@ u32 Figure::x_link(u32 row) const noexcept {
void Figure::unlink_row(u32 row) noexcept {
// A row that leaves its group stops following anything, so the hint a following group
// published to it goes with the membership: leaving `has_live` set behind would leave a
// former member anchoring a zoom on an edge it no longer follows.
if (row < plots_.size()) plots_[row].set_x_live_owner(0u, false, 0.0);
// former member anchoring a zoom on an edge it no longer follows. Its linkage flag goes
// with it too: an unlinked row is an ordinary plot again, free to arm `Live` itself.
if (row < plots_.size()) {
plots_[row].set_x_live_owner(0u, false, 0.0);
plots_[row].set_x_link_member(0u, false);
}
for (XLink& group : links_) {
for (std::size_t i = 0; i < group.rows.size(); ++i) {
if (group.rows[i] != row) continue;
@@ -111,6 +136,10 @@ Status Figure::link_x(u32 row, u32 group) {
const std::size_t index = static_cast<std::size_t>(at - g.rows.begin());
g.rows.insert(at, row);
g.pushed.insert(g.pushed.begin() + static_cast<std::ptrdiff_t>(index), Range{});
// A member's axis 0 is now owned by the group (its window and its follow state), so
// `set_x_follow(Live)` on it is refused from here on -- the same rule the refusal above
// applies at the door.
p.set_x_link_member(0u, true);
if (!g.has_range) {
// The first row into a group is the one that frames it: there is no shared range
// to apply yet, and adopting this row's is what makes the next joiner land on a
@@ -139,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)) {
@@ -149,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);
@@ -237,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;
}
@@ -288,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);
}
}
}
@@ -353,7 +408,15 @@ const FigureLayout& Figure::layout() {
// heights still sum to exactly the usable height, so the rows tile the figure and
// the last one ends on its bottom edge.
const bool equal = row_weights_.empty();
const f32 floor_px = std::min(kMinRowHeightPx, usable / static_cast<f32>(rows));
// The floor is a logical size like every other chrome metric, so it is converted through
// the row's own `ui_scale()` -- the one the renderer installed on every row -- which is
// what makes the guarantee the same *physical* height at any DPI instead of half as strong
// at 2x exactly where the chrome it protects is twice as large. A scale that is not
// positive is not a scale and converts 1:1, exactly as `Style::device()` documents.
const f32 floor_scale = plots_[0].ui_scale();
const f32 floor_device = floor_scale > 0.0f ? kMinRowHeightLogical * floor_scale
: kMinRowHeightLogical;
const f32 floor_px = std::min(floor_device, usable / static_cast<f32>(rows));
const f32 extra = usable - floor_px * static_cast<f32>(rows);
f32 total_w = 0.0f;
for (const f32 w : row_weights_) total_w += w;
@@ -388,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
@@ -415,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();
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/plot/grid.hpp>
#include <algorithm>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/plot/measure.hpp>
#include <cmath>
+323 -94
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/plot/plot.hpp>
#include <plot/plot/detail/measure_count.hpp>
@@ -28,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)) {
return false;
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;
}
} else if (series.x_at(i0) >= lo) {
i1 = i0;
}
const auto x_of = [x0, dx](u64 i) { return x0 + static_cast<f64>(i) * dx; };
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;
}
@@ -225,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);
@@ -269,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
@@ -360,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;
@@ -377,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;
}
@@ -391,6 +494,13 @@ void Plot::set_y_auto_scale(u32 axis, bool on) {
void Plot::set_x_follow(u32 axis, XFollow f) {
if (axis >= x_axes_.size()) return;
// A figure link group owns this axis' shared window *and* its follow state:
// `Figure::link_x()` refuses a `Live` member joiner, and arming `Live` here *after*
// linking would be the same state reached by the back door -- `push_link()` would
// write the group's range into an axis that reinterprets it against its own live
// edge, `adopt_member_changes()` would read it straight back as an external change,
// and the group would track one member instead of the union of them. Refused.
if (f == XFollow::Live && x_axes_[axis].link_member) return;
x_axes_[axis].follow = f;
// Arming a live axis places its window at once, so x_range() reports the window
// the next layout produces rather than the one the axis held while it was Off.
@@ -399,6 +509,11 @@ void Plot::set_x_follow(u32 axis, XFollow f) {
place_live_x_window(axis);
}
void Plot::set_x_link_member(u32 axis, bool linked) noexcept {
if (axis >= x_axes_.size()) return;
x_axes_[axis].link_member = linked;
}
XFollow Plot::x_follow(u32 axis) const {
return axis < x_axes_.size() ? x_axes_[axis].follow : XFollow::Off;
}
@@ -406,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
@@ -474,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;
}
@@ -654,11 +783,26 @@ 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.
if (a <= b) slot.result = Measure::evaluate(*series, a, b + 1);
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;
@@ -703,6 +847,24 @@ void Plot::snap_region(u32 id) {
void Plot::remove_region(u32 id) {
if (id >= regions_.size()) return;
regions_.erase(regions_.begin() + static_cast<std::ptrdiff_t>(id));
// The published hot handles name a region by index and are re-indexed with everything
// else: the removed region's own handle is cleared (nothing is hot there any more, and a
// frame drawn before the next event would otherwise highlight the region that slid into
// its slot), and a handle naming a region above the hole follows it down. The `zone` is
// the grab that went with that index, so it is cleared with it -- a stale zone describing
// a region that is no longer there would colour the next region's bound.
//
// A host must not remove a region *mid-drag*: the controller holds the id it grabbed, and
// this setter cannot re-index a controller's private `drag_region_`. The gesture's release
// would then edit whichever region now carries that id. Remove regions between gestures.
if (hot_.region != kInvalid) {
if (hot_.region == id) {
hot_.region = kInvalid;
hot_.zone = RegionGrab::None;
} else if (hot_.region > id) {
hot_.region -= 1u;
}
}
// The cache is keyed by region index, so the removed region's entries name nothing
// and every entry above the hole names a region one slot further down. The
// surviving regions' own intervals are untouched -- erase keeps their order -- so
@@ -788,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
@@ -850,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
@@ -865,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};
@@ -884,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
@@ -910,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_;
@@ -954,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 {
@@ -1178,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));
@@ -1258,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;
@@ -1269,12 +1473,31 @@ 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]);
out_y = crosshair_interpolate(y_begin, y_end, span.t);
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;
}
@@ -1343,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
@@ -1362,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);
+105 -16
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/plot/ticks.hpp>
#include <cmath>
@@ -95,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;
}
@@ -128,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;
@@ -154,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
@@ -164,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));
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/render/buffer.hpp>
#include <plot/gl/api.hpp>
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff. Show more