Compare commits

...
47 Commits
Author SHA1 Message Date
Martino Ferrari 1d5b286611 crosshair: axis badges, clustered cursor labels, and the shared-y horizontal line
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
- Axis badges: the y value in the y label strip and the x time in the x strip, each a
  full-opacity panel in the crosshair's colour, **centred on the line it names**. The tick
  text is flushed before the fill -- the over-text batch is flushed after every line batch,
  so without that flush the label behind printed through the panel (the "transparent
  backplate" defect).
- Cursor labels: collected across the cursor loop and clustered at its end, so two marks in
  one column share a panel with one line each instead of overprinting, and a label stays on
  its own mark's row.
- Figure: a split row's cells share one y (`set_share_row_y`), so the horizontal line is
  drawn in every cell of the hovered row -- one value, one scale, spanning the row -- and
  still in no other row.

Tests: `figure_crosshair_horizontal_spans_a_split_rows_cells`, plus the render-smoke
additions for the badge placement/flush and the clustered labels.
2026-11-05 17:58:49 +01:00
Martino Ferrari 2cb31cbb91 figure: x axes follow the column structure; split rows keep the right gutter on the last cell only 2026-11-04 07:00:52 +01:00
Martino Ferrari 69f0083fc0 figure: note same-axis-kind assumption in row y fit 2026-11-04 01:13:29 +01:00
Martino FerrariandClaude Opus 4.6 2b4b316ad8 figure: shared-y rows draw one y axis, on the first cell
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-04 00:43:41 +01:00
Martino Ferrari 25c3381551 figure: cells of a row can share one y range 2026-11-04 00:10:47 +01:00
Martino Ferrari 73f610b136 adapters: nearest-cell distance in double 2026-11-03 19:28:24 +01:00
Martino Ferrari e5b7faadec adapters: route pointer events by cell rect 2026-11-03 17:04:16 +01:00
Martino Ferrari 7d8c453232 figure: rows can hold several plots (cells), cell weights and column gap 2026-11-03 16:47:47 +01:00
Martino Ferrari f79a97be9b fix: a density grid is bounded in cells, not by the window's pixels
A grid was the axes rect in device pixels, so dragging a window wide made the
image wider with it -- and the per-frame pass over it is O(cells): the fade
multiplies every cell, a host's ink()/peak() read every cell, and a frame the
fade moved converts and uploads every cell as one byte. At 2x device scale a
1520x850 window's axes rect is 2877 device pixels wide, so four rows of one
trace are 4.5 M cells and 4.9 MB uploaded a frame; measured headless on
llvmpipe with a 2 s window, 12.9 ms of tick and 12.8 ms of draw a frame, every
frame under a time fade -- a cost that follows the window and has no ceiling.

The grid is now the axes rect up to kDensityCellCap (2^18 cells, a mebibyte of
counts), scaled down uniformly past it so cells stay square and the picture
keeps the axes' aspect, and magnified back over the rect with a linear filter:
a cell is a count, so a fragment between two of them is between their two ink
levels, which a point sample cannot give once a cell is several pixels wide.
The same four rows at the cap are 1.05 M cells, 3.9 ms of tick and 5.6 ms of
draw and 1.0 MB uploaded, while a grid under the cap is untouched -- a 640x360
viewport's 573x313 axes is still 179 349 cells, one a device pixel.

The image's footprint is the axes rect rounded up to whole device pixels on
both sides of the pipe, so a grid under the cap keeps the exact
one-texel-a-pixel mapping it has always had (sx/sy come out exactly 1) and only
a capped grid reads its cell index through a scale. Moving the decay itself to
the GPU was considered and left out: the CPU grid is what ink()/peak() read,
what the deposit adds to and what the per-sweep dedupe keys on, so a GPU image
would have to be reconciled with it per frame -- a larger change than this
bound, and not one to half-do.

Tests: a_density_grid_is_capped_whatever_the_window reads the bound off a
3840x2160 viewport (261 504 cells of a 3784x2138 axes, aspect kept),
a_capped_density_is_magnified_over_the_axes_rect reads the screen side of it
(569x460 cells covering all 832 of the rect's columns), and the density cases
now assert their capped figures. 399 cases, 0 failed.
2026-10-16 21:09:07 +02:00
Martino Ferrari 1b8b55d3ee feat: a phosphor density is the trace, one grid per trace in its own colour
A user looked at the display and reported three things at once: "there is clearly a gain offset
between latest acquisition and persistent image", "persistent instead of being brighter + opaque is
just gray/white", and "last acquisition does not seem to be affected, it looks like persistent image
+ last acquisition (so for example last acquisition never fade in time mode)". What they add up to is
that this was not a phosphor yet: it was a **density with a solid trace drawn over it**.

Four changes, and the first is the one the others follow from:

- **The current acquisition is deposited like every other, and no solid trace is drawn over the
  image.** `Renderer::draw_traces()` returns at once while a density is on: the trace ink *is* the
  image, so the newest acquisition is a sweep inside it and fades with it. Drawing the polyline as
  well put a solid, unfading stroke over its own faint ghost -- the reported "persistent image + last
  acquisition", where the last acquisition never faded in time mode.
- **One grid per trace, in that trace's own colour.** `Plot::density_grid(trace)` is one screen-sized
  `f32` grid per trace, deposited from that trace and drawn in `Trace::color`, because a density that
  had one hue for the plot could not say *which* trace put the ink there -- two signals agreeing in a
  cell would be one blended pixel. The display's memory is therefore `traces * columns * rows * 4`
  bytes rather than one plot's worth (a trace with no series, an unbound slot, holds no grid), which
  is the one cost this change adds: four signals in one 800x480 row are four grids, 6 MB.
- **The ceiling is the trace's own brightness, not the fade's steady state.** `full_scale` now
  defaults to **one sweep**: a cell with one acquisition's ink is drawn in its trace's colour at that
  colour's own alpha, and a signal crossing the same place every acquisition settles *at* the colour
  its trace would have drawn (the ceiling clamps) rather than a fraction of a brighter steady state --
  the gain offset the user reported. `Style::density`'s near-white 0.85 tint is gone with it, because
  there is no one tint any more. A caller that wants the fade's ramp on screen instead still sets
  `full_scale` to the sum the fade settles at and accepts the offset; that is a legitimate choice, just
  not the one a phosphor makes.

The reading is a **framebuffer** one, because neither `ink`/`peak` nor a tint can make a colour or a
fade: `a_phosphor_density_is_the_trace_in_its_own_colour` deposits one sweep, checks the saturated
cell is *exactly* the trace's colour (`full=224`, the trace's `0xE0` green, against `bg=25` -- the old
monochrome tint read ~190 red), then fades it one time constant and checks the same pixel is dimmer
(`faded=115`) and still the trace's hue -- which a stroke over the density could not do. It moves the
trace and draws without depositing to prove no polyline is drawn (`moved` equals the no-density frame
at the moved row), and reads a steady state against a quarter of it (`steady=224`, `quarter=138`), in
the signal's colour throughout. `a_density_holds_one_grid_per_trace` reads two traces' grids apart
(`267 472 cells each, ink 584/584`, in two rows). The grid's own cases are unchanged where they should
be (`a_density_halves_per_acquisition_and_keeps_the_newest_brightest` `peak_steady=2.000`,
`a_density_fades_by_its_time_constant` `exp(-dt/tau)`).

Suite: **397 cases, 0 failed**, 4/4 ctest entries.
2026-10-16 20:21:38 +02:00
Martino Ferrari 738205293e fix: the density's brightness ceiling is the fade's steady state, and the ramp is linear
A user looked at the display and reported "the colour fading is not applied, it looks white or
full color, no opacity gradient" -- and the *numbers* were right: ink decayed, `step-time` was
1/e, `step-acquisitions` was 1 + 0.5 x 1. It was the mapping from a cell's value to a pixel.

**Neither the shader nor the blend was wrong** (the answer to the question that was asked): the
fragment write is already the weighted one (`vec4(u_color.rgb, u_color.a * ink)`) with the frame's
straight-alpha blend, and a cell at half ink over a grid line rendered 160 where the arithmetic
predicts 160. What was wrong was the **scale**:

- `full_scale` defaulted to **1.0**, so the *newest* acquisition already sat at the ceiling and
  everything above it clamped to the same pixel -- a display of one sweep and a display of five
  were one white. It is now **1/(1 - 0.5) = 2.0**, the steady state of the default per-acquisition
  fade, so one sweep reads half ink and white is reached only where acquisitions agree. A caller
  whose fade settles elsewhere states its own sum (udpscope's is `1/(1 - factor)`, in its commit).
- `gamma` defaulted to **0.45**, the value `Style::intensity_gamma` wants where the measure is a
  *dwell fraction*; on a *count* it spends most of the ramp on the last sweep (a quarter of the
  scale mapped to 0.54). It is now **1.0 -- linear in sweeps**, because half as many sweeps must
  read as half the ink.

The case that would have caught this is a **framebuffer** reading, not the grid's, and
`a_phosphor_density_is_drawn_under_the_traces` now makes it: it fades the grid to a quarter
through the time rule (`dt = tau ln 4`, asserted at 0.25 off the ink) and reads the same pixel
back, so the ramp is asserted where the eye sees it -- **`empty=21 full=83 quarter=54`** (red
channel), against the 199 / 199 the ceiling and a single sweep both read at the old defaults. A
full cell must be measurably above the background and above a quarter cell, and a quarter cell
above the background: three assertions on pixels, none of which the grid's own numbers can make.

Suite: **396 cases, 0 failed**, 4/4 ctest entries.
2026-10-16 17:38:00 +02:00
Martino Ferrari 6a6915e64e feat: a plot can carry a phosphor density, one image for every acquisition
`Plot::set_density()` gives a plot a **phosphor density**: a screen-sized intensity image behind
its traces, into which each acquisition is deposited and which fades by a time constant or by a
per-acquisition factor. It is the library's and not a host's because the library is the side that
knows a trace's device geometry -- a host would be re-rasterising traces the renderer has already
staged, at a second derivation of the same mapping.

It is an **image and not a set of retained traces**, and that is the whole of its cost: one `f32`
cell per device pixel of the axes rect (`DensityGrid`), a cell's value a *number of sweeps*, so a
display of a thousand acquisitions costs what one costs and there is no depth to get wrong.
Measured on a 640x360 viewport's axes: **573x313 = 179 349 cells, 717 396 bytes** of grid, and one
byte per cell uploaded on the frame the grid moved.

- `Plot::accumulate_density()` deposits the acquisition on screen: every visible trace's window is
  mapped to sample indices and gathered exactly as `Renderer::draw_traces()` maps it, then walked
  column by column -- so the deposit is bounded by the axes' columns and not by the samples behind
  them -- and a cell takes **1.0 once per trace per sweep**, however many of that trace's own
  samples crossed it. Counting the hits instead put a single dense sweep at the ceiling of the
  brightness scale and hid the sweeps behind it, and it made a cell mean "how much the beam moved
  here" rather than "how many acquisitions were here"; the dedupe is a stamp per grid *row* (one
  `u64`, keyed by trace and column) so two sweeps agreeing on a cell still count twice. Dwell is a
  different question the library already answers (`Style::intensity_grading`).
- `decay_density(dt_s)` fades by the clock, `clear_density()` and switching the density off drop
  the image at once. `DensityConfig` carries `full_scale` and `gamma`, applied on the CPU when the
  grid changed: the value a host reads back through `ink()`/`peak()` and the picture it sees are
  one derivation, and a frame whose grid did not move skips the conversion and the upload entirely
  (`FrameStats::density_cells`/`density_upload_bytes`, the latter the *frame's* total over every
  row, which one row's assignment used to overwrite).
- The renderer draws it as one attribute-less quad over the axes rect (`kDensityVS`/`kDensityFS`,
  `DensityProgram`), over the grid and the invalid-span bands and **under the traces**, inside the
  axes' clip: it reserves no layout space and nothing on it can be picked, exactly as
  `Plot::set_invalid_spans()` does. `Style::density` is its tint.
- The two fades are the two questions a persistence display can be asked -- "still visible a second
  later" and "the N-th back is gone" -- so the choice is the caller's.

Six cases, all in the suite: four plot-level ones (the grid's resolution and memory, the
per-acquisition schedule **584 -> 876 -> 1022** ink with `peak_steady=2.000`, `exp(-dt/tau)` off
the ink, and the deposit's own geometry including a NaN as a gap) and one on the real driver
(`a_phosphor_density_is_drawn_under_the_traces`: `cells=179349 uploaded=179349` on the moved frame
and `uploaded=0` on the identical one after it, with the trace's stroke keeping its own colour over
the ink). The suite is **396 cases, 0 failed** and all four ctest entries pass.
2026-10-16 17:25:39 +02:00
Martino FerrariandClaude Opus 4.6 3ad02e5120 feat: a cleared ring can resume its absolute count at a given origin
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 derived ring that is emptied and refilled from its source's retained
window has to come back numbered as the source is. `clear()` restarts it
at 0, which leaves every absolute index -- `base()`, `produced()`,
`copy_from()` -- short by that origin for a whole window's worth of data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-15 16:30:05 +02:00
Martino Ferrari 9790becb33 chore: ignore an in-tree configure's output
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
`build/` only covers the directory the presets use, so `cmake -S . -B .` left
CMakeFiles/, Makefile, cmake_install.cmake and the generated include/plot/version.hpp
untracked in the repository root -- which shows up as dirty content in the parent
project's status whenever the submodule is checked out.
2026-10-12 16:49:06 +02:00
Martino Ferrari 2f7cdccdde docs: name and link the project this library is built for
The README listed the four host adapters and never said what any of them was for. The GTK4
one exists for udpscope, whose URL is now here, and the library's interaction layer --
cursors, measurement marks, invalid-span shading -- was shaped by what that host needed.
The source and test notes that say "in udpscope's case" have a pointer to it now.
2026-10-12 16:45:23 +02:00
Martino Ferrari be1f31a04f feat: an open box zoom draws the frame the release will apply
The controller has always tracked its selection; nothing drew it, so a Ctrl or
Shift drag framed a range with no feedback until the release.

The preview is the area the release will change, not what the pointer swept:
Ctrl frames the x range only, so a band across the plot is what it will zoom,
where the sliver a straight horizontal drag sweeps is only the gesture. The
plot carries the rect a host hands it -- it learns nothing about pointers --
and the renderer draws a low-alpha fill under a light border over everything
else inside the axes.
2026-10-11 11:07:55 +02:00
Martino Ferrari 1fc97d818f feat: a repaired invalid span is shaded faintly, not solidly 2026-10-08 22:59:07 +02:00
Martino Ferrari ebc67bd318 feat: a span declared invalid is shaded behind the data it is missing from 2026-10-08 20:11:16 +02:00
Martino Ferrari 1ae72d4903 feat: a retained slot can be backfilled without pushing 2026-10-08 19:32:47 +02:00
Martino Ferrari 2ebf2be571 feat: a non-finite aggregate can be amended additively at every level 2026-10-08 19:18:51 +02:00
Martino FerrariandClaude Opus 4.6 37e414bcc5 feat: grade a sparse window's polyline by slope, so phosphor does not switch off on zoom
Intensity grading was gated on the column reduction, which is gated on a
density whose only job is killing the moire beat. Dwell has nothing to do
with that, so narrowing a window past the threshold turned the whole
phosphor model off and a discontinuity went from a third of a flat top's
ink to all of it in one pixel of zoom.

stage_trace_slopes() grades each segment by its own slope instead, with a
measure that agrees with the dense path at the boundary, and reports
itself as FrameStats::slope_traces -- disjoint from column_traces, so a
host asserting graded_traces == column_traces reads the same as before.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-08 16:56:10 +02:00
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
87 changed files with 14315 additions and 1015 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
+12
View File
@@ -3,3 +3,15 @@ build/
# Not this project's and not generated by it: a Solarus game-engine message that
# happens to sit in the repo root.
error.txt
# An *in-tree* configure writes these beside the sources. `build/` above only covers the
# directory the presets use, and `cmake -S . -B .` leaves the whole of CMake's output in the
# repository root -- which then reads as an untracked submodule to whoever has it checked out.
CMakeFiles/
CMakeCache.txt
cmake_install.cmake
Makefile
CTestTestfile.cmake
CPackConfig.cmake
# Configured from `include/plot/version.hpp.in`, so it is output and not a source file.
include/plot/version.hpp
+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()
+108 -17
View File
@@ -14,9 +14,20 @@ built on top of it.
| Qt6 | `plot::qt6` | `adapters/qt6/` |
| GTK4 | `plot::gtk4` | `adapters/gtk4/` |
The GTK4 adapter exists for, and is exercised by, a real consumer: **udpscope**, an oscilloscope-style viewer
for a UDPStreamer source, at <https://git.ferrarinas.it/martino/udpscope>. It embeds the core through
`plot::gtk4`, and the library's interaction layer — cursors, measurement marks, the shading of a span a host
has declared invalid — has been shaped by what that host needed. The notes in `src/`, `docs/` and `tests/`
that say "in udpscope's case" are those places.
The visible x range is mapped onto sample indices through the series' own x
(`Series::neighbours()`, a binary search over the real stamps), never through the
`(x - x0()) / dx()` grid: `dx()` is a *nominal* spacing, and a series whose x is a streamed
timestamp would otherwise have its draw window land inside the visible range and lose the two
segments that enter and leave the frame — the empty left and right margins of a zoomed-in view.
Per-frame rendering cost is O(visible pixels + samples produced since the last frame),
not O(samples): a trace denser than one sample per pixel is drawn from a min/max LOD
pyramid rather than from raw samples. The `plot_multiplot_demo` run stages **18,651 GPU
pyramid rather than from raw samples. The `plot_multiplot_demo` run stages **20,901 GPU
segments** to draw **~4.5 million visible samples** across six traces — the frame is
bounded by the pixels it lights, not by the sample count behind them. That figure is from
a `linux-release` build under `xvfb-run` on **llvmpipe** (Mesa's software rasteriser); no
@@ -24,15 +35,76 @@ run touched a GPU, so read it as an upper bound and a record of the software pat
## Features
- a trace whose series answers a *clamped* stamp for its oldest slots -- a ring whose published
window is pinned while it slides -- is drawn from its longest increasing *suffix*, so an unreadable
front cannot draw a segment from the newest sample back to the oldest (a line across the whole
panel);
- a following group's window stays on its members' data: while the members retain at least a
window's worth, the scroll offset is bounded by `retained - span`, so a pan or a zoom-in near an
edge cannot leave the window's own span over data the scope never had (a window *wider* than the
data keeps the older placement -- right edge on the newest sample);
- 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. Below the density at
which columns are reduced the same switch grades the **polyline** by each segment's slope
(`FrameStats::slope_traces`), so the look does not change as a window narrows past the
threshold;
- a **phosphor density** a host can turn on for a plot (`Plot::set_density()`, an image rather
than a set of retained traces): **one grid per trace**, in that trace's own colour, the axes
rect in device pixels, drawn *in place of* the traces -- a host may not also draw them over it --
into which each acquisition is deposited, 1.0 per cell a trace crosses, so brightness is how
often it passed. It fades either by a **time constant** in stream seconds or by a
**per-acquisition factor**, and the newest acquisition is deposited like every other, so it
fades too. The resolution is bounded (`kDensityCellCap`, 262 144 cells -- a mebibyte of counts --
a grid, past which the rect is scaled down uniformly and the image magnified back over it with a
linear filter), because the per-frame pass is `O(cells)` and the window is not bounded at all:
memory is the cap times the traces on screen times 4 bytes, whatever the window (a 640x360
viewport's axes is 179 349 cells per trace, 717 kB, under it, while four rows of one trace in a
3040x1700 one used to be 4.5 M cells and 18 MB between them). The deposit walks the axes'
columns rather than the samples, and a frame whose grids did not move costs one draw call per
trace (`FrameStats::density_cells`/`density_upload_bytes`); see `docs/data-and-performance.md`;
- sample **markers** when a window is sparse enough that its samples are the shape
(`Style::marker_below_samples`, 50 by default, `marker_px` across, in the trace's own colour);
- grid with independent major/minor line classes (`Style::grid_major_visible`,
`grid_minor_visible`);
`grid_minor_visible`), each with its own colour, width and (for the minors) dash — the
subdivisions default to thinner, dashed and alpha-blended, so they read as texture under the
labelled grid rather than as a second set of ticks;
- x and y axes, with a vertical (bottom-to-top) y-axis title;
- cursors in both orientations — vertical markers and horizontal rulers;
- draggable regions and measurement bands with exact interval statistics;
- a signal legend anchored to an axes corner;
- a hover crosshair with a per-signal, interpolated readout;
- a hover crosshair with a per-signal readout, quoting the nearest sample and marking it
(`CrosshairValue::NearestSample`, the default) or interpolating between two
(`CrosshairValue::Interpolated`);
- a multi-plot `Figure` of stacked rows sharing one synchronised x axis;
- live follow (`XFollow::Live`) with relative (multiplicative) zoom and mouse panning;
- XY series for irregularly sampled data (`plot/data/xy_series.hpp`): sorted-ascending x
with binary-searched crosshair, measurement and index snapping;
- per-trace draw modes: dash patterns (`Trace::dash_on`/`dash_off`, logical px, 0 = solid)
and point markers (`Trace::marker`: cross, circle, square) with a manual
`marker_every` stride or an automatic one; `width == 0` draws the markers alone
(scatter);
- three downsample policies per trace (`Trace::mode`, `GatherMode`): `Envelope`
(min/max pairs — the default), `Nearest` (one raw sample per bucket) and `Mean`
(one averaged point per bucket);
- readout text colour: crosshair value rows render in their row's trace colour, labels in
the axis text colour;
- input parity across hosts: pinch-zoom and pan gestures on Qt6 and GTK4 drive the same
controller paths as mouse and wheel (touch parity), with keyboard panning
(arrow keys), Escape cancelling a drag or box-zoom, double-click re-fitting the axes,
and modifier-masked box zoom (Shift = both axes, Ctrl = x only, Alt = y only), the area being
chosen framed as the drag runs -- a low-alpha fill under a light border, widened along the axis
the release will *not* zoom, so Ctrl's frame is the band it is about to apply rather than the
sliver a straight horizontal drag sweeps;
- DPI-consistent sizing: metrics are logical units converted once through
`Style::ui_scale`;
- an anti-aliased embedded monospaced font, plus an optional runtime TrueType backend
@@ -49,20 +121,32 @@ cmake --build build/release
cmake --install build/release --prefix /where/you/want
```
Install is on by default (`PLOT_INSTALL=ON`) and installs the `plot_core` archive, the
public headers under `include/plot/`, and a CMake package config under `lib/cmake/plot/`.
A consumer then needs only:
Install is on by default (`PLOT_INSTALL=ON`) and, via `GNUInstallDirs`, installs the
`plot_core` archive under `lib/`, the public headers under `include/plot/` — including the
generated `plot/version.hpp` — a CMake package config under `lib/cmake/plot/`, a pkg-config
file under `lib/pkgconfig/plot.pc`, and the licences under `share/licenses/plot/`. A
consumer then needs only:
```cmake
find_package(plot 0.1 REQUIRED)
target_link_libraries(my_app PRIVATE plot::core)
```
**Versioning.** The package config carries **SameMinorVersion** compatibility: at 0.x the
major component promises nothing and the minor is what moves when the surface breaks, so
`find_package(plot 0.1 REQUIRED)` accepts 0.1.x and refuses anything newer. A shared build
(`BUILD_SHARED_LIBS=ON`) names its ABI on the file itself — `libplot_core.so.0`, the
SOVERSION being the major component. What a header set *is* is in its generated
`<plot/version.hpp>`: `plot::version::major`/`minor`/`patch`/`string`, or the
`PLOT_VERSION_*` macros for a preprocessor test. The full layout is tabled in
[docs/building-and-testing.md](docs/building-and-testing.md).
## Embedding
A host owns the window, the GL context and the event loop; `plot` owns none of them. This
is the whole of a minimal GLFW program — the adapter opens the window, runs the loop and
feeds the controller, and the callback is where live data would be pushed:
feeds the controller, and the callback is where live data would be pushed. It is committed
at the repository root as `readme_main.cpp`:
```cpp
#include <glfw/glfw_host.hpp> // the optional GLFW adapter
@@ -90,10 +174,12 @@ int main() {
```
It compiles and links against a built tree (see [docs/getting-started.md](docs/getting-started.md)
for the same program written against `plot::core` directly, with no adapter):
for the same program written against `plot::core` directly, with no adapter). The one
non-obvious flag is `-I build/debug/include`: that is where CMake generates
`plot/version.hpp`, which the umbrella pulls in:
```sh
g++ -std=c++20 -I include -I adapters readme_main.cpp \
g++ -std=c++20 -I include -I adapters -I build/debug/include readme_main.cpp \
build/debug/adapters/glfw/libplot_glfw.a build/debug/libplot_core.a \
$(pkg-config --libs glfw3) -ldl -lpthread -o plot_readme
```
@@ -124,18 +210,18 @@ documentation.
```sh
cmake --preset linux-debug
cmake --build build/debug
ctest --test-dir build/debug --output-on-failure # 283 cases, 0 failed
ctest --test-dir build/debug --output-on-failure # 396 cases, 0 failed
tools/coverage.sh # configure+build+run+capture+floor check
```
The `linux-debug` suite runs **283 cases**; the `linux-coverage` preset builds **272** of
them, because its adapters are off and 11 cases need an adapter target (8 in
`test_imgui_widget.cpp`, 3 in `test_glfw_host.cpp`). Coverage is enforced by
The `linux-debug` suite runs **396 cases**; the `linux-coverage` preset builds **379** of
them, because its adapters are off and 17 cases need an adapter target (11 in
`test_imgui_widget.cpp`, 6 in `test_glfw_host.cpp`). Coverage is enforced by
`tools/coverage.sh` against `tools/coverage_floor.py`: **80%** per file under `src/` and
**90%** aggregate. The current measurement is an aggregate **95.3% (3284/3447 lines)** over
23 files, every file at or above 80%; the per-file table is in
[docs/coverage.md](docs/coverage.md), which the command regenerates.
**90%** aggregate. The measured aggregate and per-file table live in
[docs/coverage.md](docs/coverage.md), which the command regenerates — it is the single
source for those numbers, so this page carries none.
## Licence and third-party provenance
@@ -164,7 +250,12 @@ them, because its adapters are off and 11 cases need an adapter target (8 in
- **macOS and Windows are untested.** Development and CI are Linux/EGL + X11 only.
- **Fractional-scale compositing and touchpad scroll calibration are unverified** — the
`ui_scale` sources are correct per host, but no run exercised a real fractional DPI or a
calibrated scroll device.
calibrated scroll device. The new gesture paths (Qt6 and GTK4 pinch, the keyboard and
wheel paths behind the parity list above) are tested at the toolkit-free seams and
headless; no run has touched real touch hardware.
- **Figure row rects are one frame stale during a resize.** The hosts cache
`Figure::layout()`'s rows and refresh them once per frame, so an event arriving mid-resize
routes against the previous frame's rects (see [docs/embedding.md](docs/embedding.md)).
- **Untrusted font files are out of scope** for the runtime `plot::font_ttf` backend:
stb_truetype does no range checking, so `from_file()` must only be handed files the host
trusts.
+78 -39
View File
@@ -111,6 +111,10 @@ void Host::close() noexcept {
// hands each row its rect every frame). Dropping them would silently stop routing
// gestures to the rows on the second run.
have_pointer_ = false;
// The cached rows described a figure's geometry; the next run() re-reads them from
// scratch, and until then `controller_at()` answers the single-plot controller
// rather than rects from a window that no longer exists.
row_rects_.clear();
// A closed host reports no size: a second run() must not see the previous
// window's framebuffer size before its first refresh_sizes().
fb_width_ = 0;
@@ -210,13 +214,12 @@ bool Host::run(const RunConfig& cfg, const FrameFn& frame, Status* st) {
// that hands each row its rect (and so its own `Plot` its viewport). The single
// controller is untouched -- each row has its own, see controller_at().
figure_->set_viewport(viewport);
// Every row's controller gets that row's rect, so a gesture -- routed by
// pointer_target() or injected by a scripted run -- is hit-tested against the
// geometry the last frame drew, and the two readers of a row's geometry cannot
// disagree about it. The rows come off the one recomputing layout of this frame --
// calling `layout()` again here, or in `controller_at()`, would lay every row out a
// second and a third time for the same frame.
const std::vector<Rect>& rows = figure_->layout().rows;
// The frame's one layout of the figure, into the cache every event handler
// between now and the next frame reads (R7): the rows below, and the routing of
// whatever the next `glfwPollEvents()` batch delivers, all take *these* rects
// rather than each paying for another layout.
refresh_row_rects();
const std::vector<Rect>& rows = row_rects_;
for (u32 i = 0; i < row_controllers_.size(); ++i) {
if (i < rows.size()) row_controllers_[i]->set_viewport(rows[i]);
}
@@ -353,6 +356,7 @@ void Host::on_cursor_pos(f64 wx, f64 wy) noexcept {
have_pointer_ = true;
e.mods = mods_now(window_);
e.down = any_button_down(window_);
e.time_s = glfwGetTime(); // GLFW's monotonic clock; only differences are read
feed_pointer_event(e);
}
@@ -380,6 +384,7 @@ void Host::on_mouse_button(int button, int action, int mods) noexcept {
e.pressed = action == GLFW_PRESS;
e.released = action == GLFW_RELEASE;
e.down = any_button_down(window_);
e.time_s = glfwGetTime(); // the double-click detector reads differences of these
feed_pointer_event(e);
}
@@ -406,9 +411,16 @@ void Host::on_key(int key, int action, int mods) noexcept {
case GLFW_KEY_MINUS:
case GLFW_KEY_KP_SUBTRACT: mapped = input::kKeyZoomOut; break;
case GLFW_KEY_F: mapped = input::kKeyFit; break;
case GLFW_KEY_ESCAPE:
if (action == GLFW_PRESS) glfwSetWindowShouldClose(window_, GLFW_TRUE);
return;
// Arrows pan (10% of the span per press, repeats included) and Escape cancels
// the active drag: translated, exactly like the ASCII set above, and the
// controller decides what each key does. Escape no longer closes the window --
// the window manager's close button and Alt+F4 are the way to quit, and a key
// the controller has a gesture for must reach it.
case GLFW_KEY_ESCAPE: mapped = input::kKeyEscape; break;
case GLFW_KEY_LEFT: mapped = input::kKeyLeft; break;
case GLFW_KEY_RIGHT: mapped = input::kKeyRight; break;
case GLFW_KEY_UP: mapped = input::kKeyUp; break;
case GLFW_KEY_DOWN: mapped = input::kKeyDown; break;
default: return;
}
input::KeyEvent e;
@@ -438,12 +450,31 @@ void Host::set_figure(Figure* figure) noexcept {
spread_y_ = 0.0;
captured_ = false;
captured_row_ = 0u;
if (figure_ == nullptr) return;
const u32 rows = figure_->row_count();
row_controllers_.reserve(rows);
for (u32 i = 0; i < rows; ++i) {
captured_button_ = input::Button::Left;
if (figure_ == nullptr) {
row_rects_.clear(); // no figure, no rows: the handlers fall back to `controller_`
return;
}
// One controller per **plot** (a row of several cells has one for each), indexed by the
// flattened plot index the figure uses.
const u32 plots = figure_->plot_count();
row_controllers_.reserve(plots);
for (u32 i = 0; i < plots; ++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). The rects
// are the figure's **cells** (one per plot, flattened), which for a figure with one
// cell per row are exactly its row rects.
row_rects_ = figure_ != nullptr ? figure_->layout().cells : std::vector<Rect>{};
}
input::Controller* Host::row_controller(u32 row) noexcept {
@@ -452,13 +483,14 @@ 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);
const u32 row = row_in(rows, x, y);
last_row_ = row;
input::Controller& controller = *row_controllers_[row];
// The row's own viewport, so the geometry this controller hit-tests is the geometry
@@ -472,13 +504,13 @@ void Host::feed_pointer_event(const input::PointerEvent& e) noexcept {
controller_.on_pointer(e);
return;
}
pointer_target(e.x, e.y, e.pressed, e.released).on_pointer(e);
pointer_target(e.x, e.y, e.button, e.pressed, e.released).on_pointer(e);
}
input::Controller& Host::pointer_target(f32 x, f32 y, bool pressed, bool released) noexcept {
(void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in()
input::Controller& Host::pointer_target(f32 x, f32 y, input::Button button, bool pressed,
bool released) noexcept {
if (figure_ == nullptr || row_controllers_.empty()) return controller_;
const std::vector<Rect>& rows = figure_->layout().rows;
const std::vector<Rect>& rows = row_rects_;
if (rows.empty() || rows.size() != row_controllers_.size()) return controller_;
u32 row = 0u;
@@ -488,12 +520,16 @@ input::Controller& Host::pointer_target(f32 x, f32 y, bool pressed, bool release
// to a controller that never saw the press, which would leave the owner's drag open
// for ever (and let a later gesture resume it from the old anchor).
row = captured_row_;
if (released) captured_ = false;
// Only the owning button's release ends the capture (R5): a second button's
// release -- pressed meanwhile as a chord -- must not strand the first button's
// drag, and its press must not steal the capture to the row it landed in.
if (released && button == captured_button_) captured_ = false;
} else {
row = row_in(rows, y);
row = row_in(rows, x, 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
@@ -507,27 +543,30 @@ input::Controller& Host::pointer_target(f32 x, f32 y, bool pressed, bool release
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_);
return row_in(rows, last_pointer_x_, last_pointer_y_);
}
u32 Host::row_in(const std::vector<Rect>& rows, f32 y) noexcept {
u32 Host::row_in(const std::vector<Rect>& rows, f32 x, 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
// host has asked for with `Figure::set_row_gap()` or the figure's own margins -- the
// row whose rect is nearest. Selection by distance rather than by "the first that
// matches" is what makes a pixel in a gap belong to the row it is *near*: the rows
// tile the figure at the default gap of 0, so this is unreachable until a host asks
// for one, and a gesture in a visible gap must not be routed to row 0 by default.
// The cell the pixel is in, or -- when it is in none of them, which is the row gap or
// column gap a host has asked for with `Figure::set_row_gap()`/`set_col_gap()` or the
// figure's own margins -- the cell whose rect is nearest (squared distance from the
// pixel to the rect). Selection by distance rather than by "the first that matches" is
// what makes a pixel in a gap belong to the cell it is *near*, and a gesture in a
// visible gap must not be routed to plot 0 by default. For a figure with one cell per
// row every cell spans the figure's width, so this reduces to the nearest row by y.
u32 row = 0u;
f32 best = std::numeric_limits<f32>::infinity();
double best = std::numeric_limits<double>::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);
const Rect& r = rows[i];
// In double: in f32 the dy^2 term can be swallowed by a large dx^2 (pointer far outside).
const double dx = x < r.x ? double(r.x) - x : (x >= r.right() ? double(x) - r.right() : 0.0);
const double dy = y < r.y ? double(r.y) - y : (y >= r.bottom() ? double(y) - r.bottom() : 0.0);
const double distance = dx * dx + dy * dy;
if (distance < best) {
best = distance;
row = i;
if (distance == 0.0f) break; // inside this row: nothing can be nearer
if (distance == 0.0) break; // inside this cell: nothing can be nearer
}
}
return row;
+67 -24
View File
@@ -121,17 +121,21 @@ class Host {
/// `plot_` is then untouched by any of this.
void set_figure(Figure* figure) noexcept;
Figure* figure() 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
/// One figure plot's controller -- the one a gesture on that plot reaches -- or null
/// while no figure is set or it does not exist. `row` is the **flattened plot index**
/// (`Figure::plot_count()` of them: the row count when no row holds several cells). A host reads a drag
/// mode or a hover out of it (`controller()` is the single-plot loop's, and is
/// not the one figure gestures go to).
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()`,
/// or a margin -- is the *nearest* row rather than dropped or defaulted, so a drag
/// plot (cell) that pixel lands in otherwise -- x and y both pick it, as a row may hold
/// several cells side by side. The cell 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 cell -- the row or column gap a host asked for with
/// `Figure::set_row_gap()`/`set_col_gap()`, or a margin -- is the *nearest* cell 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
@@ -147,13 +151,38 @@ class Host {
///
/// The routing is the row the event lands in, with one exception that is the whole
/// point of it: a press **captures** the row it landed in, and every motion and the
/// release until that button comes up go to the captured row. A drag that crosses
/// release until *that button* comes up go to the captured row. A drag that crosses
/// into a neighbouring figure row is therefore still the drag it began as -- the
/// owning controller sees the motion and the release that end it -- rather than
/// being handed to a controller whose `mode_ == None`, which would leave the owner's
/// drag open for ever and let a later gesture resume it from the old anchor.
///
/// Single-gesture policy: a figure routes one button-down gesture at a time. The
/// first button to press claims the capture; a second button pressed meanwhile --
/// a chord, or a trackpad that reports a second finger as a button -- neither
/// steals the capture to another row nor ends it, and only the owning button's
/// release ends it. Whatever the second button's events do to the controllers is
/// the controllers' business; the host only guarantees they all arrive at the row
/// the first press claimed.
void feed_pointer_event(const input::PointerEvent& e) noexcept;
/// Re-reads the figure's row rects into the cache every event handler below
/// reads -- the one `Figure::layout()` the routing costs between two frames. The
/// run loop calls this once per frame, right after the figure's viewport is set,
/// so the rects the handlers answer from are the rects that frame draws; the
/// handlers themselves never lay the figure out.
///
/// A driver that injects events without running the loop -- a scripted run, the
/// headless tests -- calls this after re-configuring the figure (a new viewport,
/// a new row gap, `set_figure()`): it is the scripted equivalent of the frame the
/// run loop would have staged. Until it runs, the handlers answer from the last
/// refreshed rects, which is also what happens across a window resize: GLFW
/// delivers the resize and any pointer events queued behind it in one batch, the
/// events before the resize see the rows the previous frame drew, and the loop's
/// next `refresh_row_rects()` brings the cache onto the new geometry -- rows are
/// at most one frame stale, never wrong by a second layout.
void refresh_row_rects() noexcept;
/// The gesture controller the host feeds. Valid for the host's whole life.
input::Controller& controller() noexcept { return controller_; }
@@ -191,23 +220,25 @@ class Host {
void on_scroll(f64 dx, f64 dy) noexcept;
void on_key(int key, int action, int mods) noexcept;
/// The index of the row of `rows` a framebuffer y belongs to: the row it is in, or the nearest
/// row when it is in none (a row gap, a margin outside the figure). One rule, shared by the event
/// routing and the crosshair sync -- and taking the rects rather than reading them, so a caller
/// 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 index of the cell of `rows` (the figure's flattened cell rects) a framebuffer pixel
/// belongs to: the cell it is in, or the nearest cell when it is in none (a row or column gap,
/// a margin outside the figure). One rule, shared by the event routing and the crosshair sync
/// -- and taking the rects rather than reading them, so a caller 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 x, f32 y) noexcept;
/// The controller a pointer event at (x, y) belongs to, applying the button capture:
/// a press claims the row it landed in (`captured_row_`) and every motion and the
/// release until the button comes up are routed to it; `controller_at()` is the pure
/// query that never captures. Also hands the target its row's viewport, so the
/// hit-test geometry is the last frame's.
input::Controller& pointer_target(f32 x, f32 y, bool pressed, bool released) noexcept;
/// a press claims the row it landed in (`captured_row_` + `captured_button_`) and
/// every motion and the release until *that button* comes up are routed to it;
/// `controller_at()` is the pure query that never captures. Also hands the target
/// its row's viewport, so the hit-test geometry is the last frame's.
input::Controller& pointer_target(f32 x, f32 y, input::Button button, bool pressed,
bool released) noexcept;
/// The row the pointer's events currently go to: the captured row while a drag owns
/// the button, the row under the pointer otherwise. The run loop uses it to clear
/// every *other* row's hot handles, so a row the pointer left cannot keep drawing a
/// hover or a grab it last published.
/// The plot (flattened index) the pointer's events currently go to: the captured one
/// while a drag owns the button, the one under the pointer otherwise. The run loop uses
/// it to clear every *other* plot's hot handles, so one 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).
@@ -254,11 +285,23 @@ class Host {
f64 spread_y_ = 0.0;
bool spread_active_ = false;
/// The row a button-down drag has captured: the row its press landed in, which
/// owns its motions and its release until the button comes up. `captured_` is
/// false between gestures, and cleared when another figure is set.
/// The row a button-down drag has captured, and the button that owns the capture:
/// the row its press landed in owns that button's motions and its release until
/// the button comes up. `captured_` is false between gestures, and cleared when
/// another figure is set. The button is recorded because only the owning button's
/// release ends the capture (R5): a second button pressed while the drag runs is
/// routed to the same row and must neither steal the capture nor end it, or its
/// release would strand the first button's drag.
bool captured_ = false;
u32 captured_row_ = 0u;
input::Button captured_button_ = input::Button::Left;
/// The figure's cell rects (one per plot, flattened; the row rects when no row is split) as of the last `refresh_row_rects()` -- the cache the
/// event routing reads instead of laying the figure out per event (R7). Filled by
/// `set_figure()` and once per frame by the run loop; empty while no figure is
/// set, which is the state `controller_at()`/`pointer_target()` fall back to the
/// single-plot controller on.
std::vector<Rect> row_rects_;
u32 fb_width_ = 0; ///< framebuffer size of the last refresh_sizes()
u32 fb_height_ = 0;
+111
View File
@@ -229,4 +229,115 @@ inline f64 scroll_notches(f64 dy, ScrollUnit unit) noexcept {
return -dy;
}
/// The keyvals this adapter maps, as GDK's own numbers, mirrored here so the
/// mapping below -- and a test that drives it -- needs no GTK include directory.
/// `gtk_plot_area.cpp` static-asserts each against GDK's own macro.
///
/// These are the *function* keyvals (the `0xFF00` page), fixed by X11's keysym
/// convention GDK inherits; a keypad arrow (a keypad key with Num Lock off) is a
/// different keyval and deliberately unmapped, exactly as the GLFW adapter maps
/// the main-row arrows and not `GLFW_KEY_KP_4`.
constexpr u32 kGdkKeyEscape = 0xFF1Bu;
constexpr u32 kGdkKeyLeft = 0xFF51u;
constexpr u32 kGdkKeyUp = 0xFF52u;
constexpr u32 kGdkKeyRight = 0xFF53u;
constexpr u32 kGdkKeyDown = 0xFF54u;
/// One key-press translation, by keyval first and Unicode fallback second.
///
/// GTK names a key by its keyval, which is layout- and level-dependent: `+` is
/// usually shift+`=` and arrives as a different keyval, but both decode to the
/// same Unicode code point, which is why the printable keys go through
/// `gdk_keyval_to_unicode()`. The function keys decode to no code point at all,
/// so they are matched on the keyval itself, before that fallback. `0` means "no
/// plot key": the caller returns the event to GTK unhandled.
///
/// The keypad's plus and minus decode to the same code points as the main row's,
/// so they come along for free -- the same set the GLFW adapter maps with
/// `GLFW_KEY_KP_ADD`/`GLFW_KEY_KP_SUBTRACT`.
inline int key_from(u32 keyval, u32 unicode) noexcept {
switch (keyval) {
case kGdkKeyEscape: return input::kKeyEscape;
case kGdkKeyLeft: return input::kKeyLeft;
case kGdkKeyRight: return input::kKeyRight;
case kGdkKeyUp: return input::kKeyUp;
case kGdkKeyDown: return input::kKeyDown;
default: break;
}
switch (unicode) {
case static_cast<u32>(input::kKeyZoomIn):
case static_cast<u32>('='): return input::kKeyZoomIn;
case static_cast<u32>(input::kKeyZoomOut):
case static_cast<u32>('_'): return input::kKeyZoomOut;
case static_cast<u32>(input::kKeyFit):
case static_cast<u32>(input::kKeyFitUpper): return input::kKeyFit;
default: return 0;
}
}
/// The state one `GtkGestureZoom` is folded into `input::GestureEvent`s from.
///
/// `GtkGestureZoom` reports a **cumulative** scale (`gtk_gesture_zoom_get_scale_delta()`
/// and the `scale-changed` argument are the zoom since the gesture began, 1.0 at
/// the start) and knows the focal point only as the bounding box of the pointer
/// the gesture holds (`gtk_gesture_bounding_box_center()`), also cumulative in the
/// sense that it is where the fingers are *now*. The controller wants the
/// increments -- `GestureEvent::scale` is this frame's zoom factor about the focal
/// point, 1.0 = no change, and `(dx, dy)` the focal point's movement since the
/// previous gesture event -- so the area owns one of these and folds each
/// `scale-changed` exactly like the Qt adapter folds its pinch (`PinchTracker`).
///
/// The focal point is cached in **logical** widget pixels, like `DragAnchor` above,
/// for the same reason: the mapping's factor is re-derived on every `::resize`,
/// and a cache in device pixels would compare two points taken at different
/// factors. The difference of two logical points, scaled by the factor in force
/// now, is the movement the fingers actually made.
///
/// A plain value with no toolkit dependency, like every other type here: the area
/// owns one, and the tests drive it directly.
struct ZoomAnchor {
bool active = false; ///< true between the gesture's begin and its end
f32 last_scale = 1.0f; ///< the cumulative scale the previous update folded
f32 x = 0.0f; ///< last focal point, logical widget pixels
f32 y = 0.0f;
/// Starts a gesture at its first cumulative `scale` and logical focal point.
/// Records only: a zoom that just began has zoomed and moved nothing yet.
void begin(f32 scale, Vec2 logical_center) noexcept {
active = true;
last_scale = scale;
x = logical_center.x;
y = logical_center.y;
}
/// Folds one `scale-changed` into an incremental `GestureEvent`, in device
/// pixels at `mapping`: the zoom about the focal point, and the focal point's
/// movement since the previous update. A no-op before `begin()`, so a
/// `scale-changed` that arrives without a begin -- a host that missed the start
/// -- zooms nothing rather than zooming from a made-up anchor.
input::GestureEvent update(f32 scale, Vec2 logical_center,
const DeviceMapping& mapping) noexcept {
input::GestureEvent g;
if (!active) return g;
// This frame's zoom: the cumulative ratio over the one last folded. The first
// update after begin() divides the scale by itself -- 1.0, no zoom, as it
// should be when the fingers have not moved apart yet.
g.scale = last_scale != 0.0f ? scale / last_scale : 1.0f;
const Vec2 device = mapping.to_device(logical_center.x, logical_center.y);
g.x = device.x;
g.y = device.y;
const Vec2 moved{logical_center.x - x, logical_center.y - y};
const Vec2 device_moved = mapping.to_device(moved.x, moved.y);
g.dx = device_moved.x;
g.dy = device_moved.y;
last_scale = scale;
x = logical_center.x;
y = logical_center.y;
return g;
}
/// Ends the gesture: a straggler update after this zooms nothing.
void end() noexcept { active = false; }
};
} // namespace plot::gtk4
+345 -31
View File
@@ -18,6 +18,8 @@
#include <cstdio>
#include <cstring>
#include <dlfcn.h>
#include <limits>
#include <memory>
// The mirrored constants in gtk_input.hpp are pinned to GDK's here: a GDK that
// renumbered the scroll units or moved a modifier bit fails to compile rather
@@ -31,6 +33,11 @@ static_assert((1u << 2) == GDK_CONTROL_MASK, "mods_from's control bit must match
static_assert((1u << 3) == GDK_ALT_MASK, "mods_from's alt bit must match GDK's");
static_assert((1u << 26) == GDK_SUPER_MASK, "mods_from's super bit must match GDK's");
static_assert((1u << 28) == GDK_META_MASK, "mods_from's meta bit must match GDK's");
static_assert(plot::gtk4::kGdkKeyEscape == GDK_KEY_Escape, "the mirrored Escape keyval must match GDK's");
static_assert(plot::gtk4::kGdkKeyLeft == GDK_KEY_Left, "the mirrored Left keyval must match GDK's");
static_assert(plot::gtk4::kGdkKeyUp == GDK_KEY_Up, "the mirrored Up keyval must match GDK's");
static_assert(plot::gtk4::kGdkKeyRight == GDK_KEY_Right, "the mirrored Right keyval must match GDK's");
static_assert(plot::gtk4::kGdkKeyDown == GDK_KEY_Down, "the mirrored Down keyval must match GDK's");
namespace plot::gtk4 {
namespace {
@@ -158,6 +165,18 @@ PlotArea* PlotArea::create(Plot& plot) noexcept {
g_signal_connect(drag, "drag-end", G_CALLBACK(&PlotArea::on_drag_end), self);
gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(drag));
// The two-finger pinch: `scale-changed` carries the cumulative zoom since the
// gesture began, and the gesture's bounding-box centre is the focal point -- the
// pair `ZoomAnchor` folds into the incremental `GestureEvent` the controller
// takes (zoom about the focal point, focal movement as the pan). No button:
// unlike the click/drag pair this gesture is touch-only by nature, and a mouse
// never produces one.
GtkGesture* zoom = gtk_gesture_zoom_new();
g_signal_connect(zoom, "begin", G_CALLBACK(&PlotArea::on_zoom_begin), self);
g_signal_connect(zoom, "scale-changed", G_CALLBACK(&PlotArea::on_zoom_scale_changed), self);
g_signal_connect(zoom, "end", G_CALLBACK(&PlotArea::on_zoom_end), self);
gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(zoom));
// Vertical only, as the plan asks: a purely horizontal scroll is not a zoom, and
// because this controller never sees one it cannot be mistaken for one. GTK
// reports the unit per event, so a discrete wheel arrives in notches and a
@@ -200,9 +219,208 @@ 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;
}
// One controller per **plot** (a row of several cells has one for each), indexed by the
// flattened plot index the figure uses.
const u32 plots = figure_->plot_count();
row_controllers_.reserve(plots);
for (u32 i = 0; i < plots; ++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. The rects are
// the figure's **cells** (one per plot, flattened), which for a figure with one cell
// per row are exactly its row rects.
row_rects_ = figure_ != nullptr ? figure_->layout().cells : 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_;
const u32 row = row_in(row_rects_, x, 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;
const Vec2 p = to_device(x, y);
return row_in(row_rects_, p.x, p.y);
}
input::Controller& PlotArea::target_controller(f32 x, 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_, x, 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 x, f32 y) noexcept {
if (rows.empty()) return 0;
// The cell the pixel is in, or -- when it is in none of them, which is the row or
// column gap a host asked for with `Figure::set_row_gap()`/`set_col_gap()` or the
// figure's own margins -- the cell whose rect is nearest (squared distance from the
// pixel to the rect). By distance rather than by "the first that matches": a gesture
// in a visible gap must not default to plot 0. For a figure with one cell per row
// every cell spans the figure's width, so this reduces to the nearest row by y.
u32 row = 0;
double best = std::numeric_limits<double>::infinity();
for (u32 i = 0; i < rows.size(); ++i) {
const Rect& r = rows[i];
// In double: in f32 the dy^2 term can be swallowed by a large dx^2 (pointer far outside).
const double dx = x < r.x ? double(r.x) - x : (x >= r.right() ? double(x) - r.right() : 0.0);
const double dy = y < r.y ? double(r.y) - y : (y >= r.bottom() ? double(y) - r.bottom() : 0.0);
const double distance = dx * dx + dy * dy;
if (distance < best) {
best = distance;
row = i;
if (distance == 0.0) break; // inside this cell: 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_.x, pointer_.y);
}
void PlotArea::sync_selection_frames() noexcept {
const auto frame_for = [](input::Controller& c) -> Rect {
return c.drag_mode() == input::DragMode::BoxZoom ? c.selection_preview() : Rect{};
};
if (figure_ != nullptr) {
for (u32 i = 0; i < row_controllers_.size(); ++i) {
// An empty rect on the rows that are not choosing: `Rect` has no equality to compare
// with, and a store of the same value is what keeps this path free of anything else.
figure_->plot(i).set_selection_rect(frame_for(*row_controllers_[i]));
}
return;
}
plot_->set_selection_rect(frame_for(controller_));
}
void PlotArea::sync_figure_crosshair() noexcept {
if (row_rects_.empty() || row_controllers_.empty()) return;
const u32 row_count = static_cast<u32>(row_rects_.size());
// Which row's crosshair is the hover. With a pointer of this area's own it is the row
// that pointer's events go to -- the same rule the routing uses, so the hover follows
// the gesture it began. A headless probe injects its events straight into a row
// controller and the area never sees a pointer, so there the hover is the row whose
// crosshair is *not* the one last spread: the figure writes one value to every row,
// so a row holding anything else was changed from outside. Nothing differing means no
// new hover, and the figure keeps the one it has.
u32 hovered = row_count;
if (have_pointer_) {
hovered = pointer_row();
} else {
for (u32 i = 0; i < row_count; ++i) {
const Crosshair& candidate = figure_->plot(i).crosshair();
// A row differs when its active state, its x, or the y of *its own* horizontal
// line differs from what was spread: a pointer moving to another row at the same
// x is a new hover and must move the one horizontal line, not leave the old one.
const bool y_differs = candidate.horizontal && candidate.y != spread_y_;
if (candidate.active != spread_active_ ||
(candidate.active && (candidate.x != spread_x_ || y_differs))) {
hovered = i;
break;
}
}
if (hovered >= row_count) return;
}
// One x to every row, and the hovered row's **own** y only to that row (spec §17):
// the rows of a figure share x and not y -- row 0 may be volts and row 1 degrees --
// so a horizontal line spread across the figure would mean a different value in each.
// A row whose controller has *no* crosshair (the pointer left its axes, the only
// thing that clears one) clears the figure's, so the rows cannot be left showing a
// hover the pointer has abandoned.
const Plot& row = figure_->plot(hovered);
if (row.crosshair().active) {
if (row.crosshair().horizontal) {
figure_->set_crosshair(row.crosshair().x, hovered, row.crosshair().y, true);
} else {
// Vertical-only: a caller placed it through the single-coordinate
// `Figure::set_crosshair()`. The four-argument overload would turn the horizontal
// line on at the stored (meaningless) y == 0, clamped to the axes edge and made
// permanent, so only the vertical line is promoted.
figure_->set_crosshair(row.crosshair().x, true);
}
spread_x_ = row.crosshair().x;
spread_y_ = row.crosshair().y;
spread_active_ = true;
} else {
figure_->clear_crosshair();
spread_active_ = false;
}
}
// --- the GL signals -------------------------------------------------------
void PlotArea::realize() noexcept {
@@ -279,6 +497,7 @@ void PlotArea::unrealize() noexcept {
renderer_.reset();
press_open_ = false;
drag_.have = false;
zoom_.end();
}
void PlotArea::render() noexcept {
@@ -299,12 +518,36 @@ void PlotArea::render() noexcept {
// chrome out with it) and refreshes the layout, so every frame is laid out for
// the geometry of *this* frame. A resize changes the layout; it never scales a
// stale one.
if (figure_ != nullptr) {
// The figure's one layout for this frame: it hands every row its rect (and so
// every row's `Plot` its viewport) and fills the rect cache the routing of
// whatever GTK delivers before the next frame answers from.
lay_figure_out(viewport);
// One hover, every row: the crosshair the row under the pointer placed is applied
// to the whole figure here, after this frame's events have been handled and before
// anything lays the rows out again. Here rather than in a handler, so a headless
// probe that injects straight into a row controller is synced too.
sync_figure_crosshair();
// Only the row the pointer's events go to republishes its own hot handles, so a row
// the pointer has left would keep the `HotHandles` it last published and go on
// drawing a hovered marker or cursor grab as if the pointer were still on it. Clear
// every *other* row here, where that row is known.
if (have_pointer_ && !row_rects_.empty()) {
const u32 active = pointer_row();
for (u32 i = 0; i < row_rects_.size() && i < row_controllers_.size(); ++i) {
if (i != active) figure_->plot(i).set_hot_handles(HotHandles{});
}
}
} else {
controller_.set_viewport(viewport);
}
const f64 started = now_s();
const f64 dt = last_render_s_ >= 0.0 ? started - last_render_s_ : 0.0;
last_render_s_ = started;
if (frame_) frame_(*plot_, dt);
// A figure's rows take the pointer, but the frame callback is still given a `Plot&`:
// row 0 is the one it gets, so the callback argument and `figure()->plot(0)` agree.
if (frame_) frame_(figure_ != nullptr ? figure_->plot(0u) : *plot_, dt);
// The mapping's factor is the display's scale, so the renderer draws its logical
// style metrics at the same ratio the viewport and every forwarded coordinate are
@@ -312,8 +555,15 @@ void PlotArea::render() noexcept {
// `::resize` ratio, not `gtk_widget_get_scale_factor()` -- the latter is the
// ceiling under fractional scaling and would over-scale the chrome by up to 60%.
renderer_->style().ui_scale = mapping_.factor;
// After the scale and before the draw: the preview reads the row's layout, and a layout measured
// with the previous frame's scale is not the one being drawn.
sync_selection_frames();
renderer_->begin_frame(static_cast<u32>(viewport.w), static_cast<u32>(viewport.h), viewport);
if (figure_ != nullptr) {
renderer_->draw(*figure_);
} else {
renderer_->draw(*plot_);
}
renderer_->end_frame();
const f32 frame_ms = static_cast<f32>((now_s() - started) * 1.0e3);
@@ -364,13 +614,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.x, p.y) : 0;
press_open_ = true;
press_button_ = button;
press_mods_ = mods;
const Vec2 p = to_device(x, y);
pointer_ = p;
have_pointer_ = true;
@@ -383,12 +639,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.x, 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.x, position.y);
press_open_ = false;
input::PointerEvent e;
@@ -400,7 +661,8 @@ void PlotArea::pointer_release(Vec2 position) noexcept {
e.mods = press_mods_;
e.down = false;
e.released = true;
controller_.on_pointer(e);
e.time_s = pointer_time_s(time_ms);
target.on_pointer(e);
gtk_gl_area_queue_render(GTK_GL_AREA(widget_));
}
@@ -412,7 +674,8 @@ void PlotArea::on_click_pressed(GtkGestureClick* gesture, int, double x, double
if (!button) return; // a button the controller has no gesture for
// The modifiers come off the *event*, not the gesture: GTK reports the state as
// it was before the press, which is what the controller's gesture table reads.
area->pointer_press(*button, gesture_mods(GTK_EVENT_CONTROLLER(gesture)), x, y);
area->pointer_press(*button, gesture_mods(GTK_EVENT_CONTROLLER(gesture)), x, y,
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture)));
}
void PlotArea::on_click_released(GtkGestureClick* gesture, int, double x, double y,
@@ -421,11 +684,11 @@ void PlotArea::on_click_released(GtkGestureClick* gesture, int, double x, double
auto* area = static_cast<PlotArea*>(self);
// The first release closes the gesture; a drag's own `drag-end` may already have
// done it (GTK emits both, `drag-end` first), and this is then a no-op.
area->pointer_release(area->to_device(x, y));
area->pointer_release(area->to_device(x, y),
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture)));
}
void PlotArea::on_click_stopped(GtkGestureClick* gesture, void* self) noexcept {
(void)gesture;
auto* area = static_cast<PlotArea*>(self);
// A sequence can be cancelled rather than released -- GTK stops the click
// gesture as soon as the drag gesture claims the same sequence, which happens on
@@ -434,7 +697,11 @@ void PlotArea::on_click_stopped(GtkGestureClick* gesture, void* self) noexcept {
// drag in progress (an exotic cancellation by another widget or a device) closes
// the press, which it has to, or the controller would stay in whatever mode the
// press began.
if (area->press_open_ && !area->drag_.have) area->pointer_release(area->pointer_);
if (area->press_open_ && !area->drag_.have) {
area->pointer_release(
area->pointer_,
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture)));
}
}
void PlotArea::on_drag_begin(GtkGestureDrag* gesture, double start_x, double start_y,
@@ -453,7 +720,8 @@ void PlotArea::on_drag_begin(GtkGestureDrag* gesture, double start_x, double sta
const std::optional<input::Button> button =
button_from(gtk_gesture_single_get_current_button(GTK_GESTURE_SINGLE(gesture)));
if (button) {
area->pointer_press(*button, gesture_mods(GTK_EVENT_CONTROLLER(gesture)), start_x, start_y);
area->pointer_press(*button, gesture_mods(GTK_EVENT_CONTROLLER(gesture)), start_x, start_y,
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture)));
}
}
@@ -482,13 +750,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.x, sample.position.y).on_pointer(e);
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
}
void PlotArea::on_drag_end(GtkGestureDrag* gesture, double offset_x, double offset_y,
void* self) noexcept {
(void)gesture;
auto* area = static_cast<PlotArea*>(self);
// The release carries the authoritative end of the gesture: the controller reads
// its position to finish a box zoom's selection, so this is the press point plus
@@ -496,7 +767,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.x, g.y).on_gesture(g);
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
}
void PlotArea::on_zoom_end(GtkGestureZoom*, GdkEventSequence*, void* self) noexcept {
// No trailing event: the last `scale-changed` carried the gesture's final zoom,
// and `end` only closes the folding so a straggler update zooms nothing.
static_cast<PlotArea*>(self)->zoom_.end();
}
int PlotArea::on_scroll(GtkEventControllerScroll* scroll, double dx, double dy,
@@ -528,7 +840,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.x, at.y).on_wheel(e);
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
return TRUE; // consumed: the plot zoomed
}
@@ -550,7 +862,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.x, p.y).on_pointer(e);
}
// --- keyboard -------------------------------------------------------------
@@ -560,20 +874,14 @@ int PlotArea::on_key_pressed(GtkEventControllerKey* keys, unsigned int keyval,
(void)keycode;
(void)keys;
auto* area = static_cast<PlotArea*>(self);
// The keys are named by their *symbol*: GTK reports a layout's keyval, and the
// keypad's plus and minus are the same symbol as the main row's. `'='` is mapped
// with `'+'` so both spellings of "zoom in" work (on most layouts `+` *is*
// shift+`=`), the pair the GLFW and Qt adapters map.
int key = 0;
switch (gdk_keyval_to_unicode(keyval)) {
case static_cast<guint32>(input::kKeyZoomIn):
case static_cast<guint32>('='): key = input::kKeyZoomIn; break;
case static_cast<guint32>(input::kKeyZoomOut):
case static_cast<guint32>('_'): key = input::kKeyZoomOut; break;
case static_cast<guint32>(input::kKeyFit):
case static_cast<guint32>(input::kKeyFitUpper): key = input::kKeyFit; break;
default: return FALSE; // not a plot key: GTK and the window keep it
}
// The whole translation lives in gtk_input.hpp (see `key_from`): the function
// keys -- arrows, Escape -- are matched on the *keyval*, which is layout- and
// level-independent for them, and the printable ones on the Unicode they decode
// to, which is what makes `+` and shift+`=` (usually the same physical key) both
// zoom in, and the keypad's plus and minus come along for free. `0` is "no plot
// key": GTK and the window keep it.
const int key = key_from(static_cast<u32>(keyval), static_cast<u32>(gdk_keyval_to_unicode(keyval)));
if (key == 0) return FALSE;
input::KeyEvent e;
e.key = key;
@@ -581,7 +889,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
}
+179 -4
View File
@@ -4,14 +4,17 @@
#pragma once
#include <functional>
#include <memory>
#include <optional>
#include <utility>
#include <vector>
#include <plot/core/rect.hpp>
#include <plot/core/status.hpp>
#include <plot/core/types.hpp>
#include <plot/input/controller.hpp>
#include <plot/math/vec2.hpp>
#include <plot/plot/figure.hpp>
#include <plot/plot/plot.hpp>
#include <plot/render/renderer.hpp>
@@ -32,10 +35,12 @@ typedef struct _GtkGLArea GtkGLArea;
typedef struct _GtkGesture GtkGesture;
typedef struct _GtkGestureClick GtkGestureClick;
typedef struct _GtkGestureDrag GtkGestureDrag;
typedef struct _GtkGestureZoom GtkGestureZoom;
typedef struct _GtkEventControllerScroll GtkEventControllerScroll;
typedef struct _GtkEventControllerKey GtkEventControllerKey;
typedef struct _GtkEventControllerMotion GtkEventControllerMotion;
typedef struct _GdkGLContext GdkGLContext;
typedef struct _GdkEventSequence GdkEventSequence;
namespace plot::gtk4 {
@@ -134,6 +139,10 @@ using FrameFn = std::function<void(Plot&, f64 dt)>;
/// release, with the button and the modifier state, exactly as the Qt
/// widget's `mousePressEvent`/`mouseReleaseEvent` do. It is also what begins
/// a cursor move: a ctrl+press on a cursor opens `DragMode::MoveCursor` there.
/// * `GtkGestureZoom` supplies the two-finger pinch: each `scale-changed` is
/// folded by `ZoomAnchor` (`gtk_input.hpp`) into one `GestureEvent` -- this
/// frame's zoom about the fingers' centre, and the centre's movement, which is
/// the two-finger pan a trackpad produces alongside it.
/// * `GtkEventControllerMotion` supplies hover (the cursor hit test reads the
/// last position even with no button down). Its positions are forwarded with
/// `down == false` on purpose: a motion event that claimed a button was down
@@ -165,6 +174,42 @@ using FrameFn = std::function<void(Plot&, f64 dt)>;
/// visible without waiting for the host's next frame, and the frame rate stays the
/// host's: the demo pumps `g_timeout_add(16, gtk_gl_area_queue_render)`.
///
/// ## Figure mode
///
/// `set_figure()` makes the area draw a whole `Figure` -- several rows sharing one
/// x axis -- instead of its own `Plot`, exactly as `glfw::Host::set_figure()` does
/// for the reference host, and for the same reason: the multi-row shape is one
/// `Figure`, not N widgets, because the x link groups, the shared y gutter and the
/// one shared x axis strip all live inside it and cannot be reassembled from
/// separate areas. The GTK plumbing above is unchanged by it; what changes is where
/// an event goes and what the renderer is handed.
///
/// A `Controller` is bound to a plot at construction, so figure mode holds **one
/// controller per row**, created by `set_figure()`, and routes each event to the row
/// it lands in (`controller_at()`). The row rects come from a cache
/// (`refresh_row_rects()`) that the render callback fills once per frame, so a burst
/// of pointer motion costs no layouts; between a resize and the next frame the cache
/// is one frame stale, which is the documented worst case.
///
/// A gesture belongs to the row its **press** landed in for its whole life: the
/// press claims the row, and every `drag-update`, pinch, wheel and the release that
/// follow while that press is open go there, however far the pointer travels. A drag
/// that crosses into the row below is therefore still the drag it began as, instead
/// of being handed to a controller that never saw the press -- which would leave the
/// first row's drag open for ever and let a later gesture resume it from the stale
/// anchor. The single open press `press_open_` already enforces (see the input
/// section) is that capture; this adapter needs no separate button bookkeeping the
/// way the GLFW host does, because GTK's two gestures share one sequence.
///
/// The **crosshair** is one crosshair over the figure and not one per row: the row
/// under the pointer places it, and `sync_figure_crosshair()` reads it back and
/// applies it to every row, so each row's readout lists its own traces at the one x
/// the pointer is over. Reading it back rather than deriving a second coordinate
/// here is what keeps the row's hover and the figure's the same number.
///
/// A key event has no position, so it goes to the row the last event was routed to,
/// which is the row the user is looking at.
///
/// ## Headless use
///
/// A `GtkGLArea` renders into its own FBO, so a screenshot of the *plot* is a
@@ -196,10 +241,58 @@ 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 plot's controller -- the one a gesture on that plot reaches -- or null
/// while no figure is set or it does not exist. `row` is the **flattened plot index**
/// (`Figure::plot_count()` of them: the row count when no row holds several cells). 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 plot (cell)
/// that pixel lands in -- x and y both pick it, as a row may hold several cells side
/// by side. A pixel inside no cell (a `Figure::set_row_gap()`/`set_col_gap()` gap, a
/// margin) is the *nearest* cell 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 flattened plot index 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 plot a pointer is over without converting anything. The
/// same nearest-cell rule (x and y) as `controller_at()`, and 0 while no figure is set.
u32 row_at(double x, double y) const noexcept;
/// Re-reads the figure's row rects into the cache the event routing answers from --
/// the one `Figure::layout()` the routing costs between two frames. The render
/// callback runs it once per frame, right after the figure's viewport is set, so
/// the rects the handlers use are the rects that frame draws. A driver that injects
/// events without rendering (a headless probe) calls it after re-configuring the
/// figure; a no-op with no figure set.
void refresh_row_rects() noexcept;
/// The renderer, or null until a successful `::realize`. Valid from the first
/// render onwards, and after the widget's context exists.
Renderer* renderer() noexcept { return renderer_ ? &*renderer_ : nullptr; }
@@ -271,13 +364,58 @@ 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 (x, 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 (x and y pick the cell). 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 x, f32 y) noexcept;
/// The cell of `row_rects_` (the figure's flattened cell rects) a device pixel belongs
/// to: the cell it is in, or the nearest one when it is in none (a row or column 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 cell it is *near*
/// instead of to plot 0.
static u32 row_in(const std::vector<Rect>& rows, f32 x, 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;
/// Hands each row's plot the box-zoom frame its controller is showing, or takes it off
/// the ones whose gesture is over. Run once per frame from the render callback, like
/// the crosshair's spread and for a related reason: a frame has to go away when a drag
/// ends by *any* route -- the release, Escape, a row rebuilt mid-gesture -- and a
/// per-event hook would have to be repeated at every one of them, which is how a frame
/// ends up stuck on screen after a cancelled zoom.
///
/// Reads `selection_preview()`, which needs the row's layout, only for a controller
/// actually in `BoxZoom`: the cost is paid while a range is being chosen and never
/// otherwise.
void sync_selection_frames() noexcept;
/// `figure_->set_viewport(px)`, one `layout()` into the rect cache, and each row's
/// own rect to its controller. The figure-mode half of `refresh_viewport()` and of
/// `render()`, which must not drift apart.
void lay_figure_out(Rect px) noexcept;
static void on_realize(GtkGLArea* area, void* self) noexcept;
static void on_unrealize(GtkGLArea* area, void* self) noexcept;
/// `gboolean` is `int`; a GtkGLArea render handler ignores the context.
@@ -289,6 +427,15 @@ class PlotArea {
static void on_click_released(GtkGestureClick* gesture, int n_press, double x, double y,
void* self) noexcept;
static void on_click_stopped(GtkGestureClick* gesture, void* self) noexcept;
/// The pinch: `begin` anchors the folding, `scale-changed` folds one cumulative
/// scale into an incremental `GestureEvent`, and `end` closes it. `begin`/`end`
/// carry the sequence, which a one-active-pinch adapter never needs.
static void on_zoom_begin(GtkGestureZoom* gesture, GdkEventSequence* sequence,
void* self) noexcept;
static void on_zoom_scale_changed(GtkGestureZoom* gesture, double scale,
void* self) noexcept;
static void on_zoom_end(GtkGestureZoom* gesture, GdkEventSequence* sequence,
void* self) noexcept;
static void on_drag_begin(GtkGestureDrag* gesture, double start_x, double start_y,
void* self) noexcept;
static void on_drag_update(GtkGestureDrag* gesture, double offset_x, double offset_y,
@@ -307,6 +454,27 @@ 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 cell rects (one per plot, flattened; the row rects when no row is split)
/// as of the last `refresh_row_rects()`: the cache the event
/// routing reads instead of laying the figure out per event. Empty while no figure
/// is set, which is the state the routing falls back to `controller_` on.
std::vector<Rect> row_rects_;
/// The row the last event was routed to, which is where a key event goes: a key has
/// no position of its own.
u32 last_row_ = 0;
/// The crosshair last spread over the figure (see `sync_figure_crosshair()`): what a
/// row is compared against to find the one something outside changed, since the
/// figure writes one value to every row. `spread_active_` false means "spread
/// nothing" -- the state before the first sync, and the one a clear leaves.
f64 spread_x_ = 0.0;
f64 spread_y_ = 0.0;
bool spread_active_ = false;
GtkWidget* widget_ = nullptr;
std::optional<Renderer> renderer_;
FrameFn frame_;
@@ -318,12 +486,19 @@ class PlotArea {
bool press_open_ = false;
input::Button press_button_ = input::Button::Left;
u32 press_mods_ = 0;
/// The figure row the open press landed in -- the gesture's capture (see
/// `target_controller()`). Meaningless while `press_open_` is false and while no
/// figure is set.
u32 press_row_ = 0;
/// The drag gesture's anchor: its press point and last offset, in *logical*
/// pixels. Logical on purpose: a factor change between two samples of a drag
/// cannot then turn into a phantom movement (see `DragAnchor`). The offset is
/// cached rather than re-read per event, so a `drag-update` reports the movement
/// since the previous one, and `have` is whether a drag is recognised.
DragAnchor drag_;
/// The pinch gesture's folding state (see `ZoomAnchor`): one per area, alive
/// between `::begin` and `::end` of the one zoom the gesture recognises.
ZoomAnchor zoom_;
/// The last pointer position seen, in device pixels: the wheel's anchor when a
/// scroll event carries no position of its own.
Vec2 pointer_{};
+23 -9
View File
@@ -201,6 +201,11 @@ void PlotWidget::forward_input(bool hovered) {
e.down = any_down;
e.pressed = pressed;
e.released = released;
// ImGui's own clock: monotonic seconds since the context was created. The
// double-click detector differences two presses' stamps, so only the unit
// matters -- and ImGui's backend-driven time keeps running while the pointer
// is off-window, which is exactly what a wall clock should do.
e.time_s = ImGui::GetTime();
controller_->on_pointer(e);
};
@@ -231,6 +236,15 @@ void PlotWidget::forward_input(bool hovered) {
// Keyboard gestures, on the same hover gate as the pointer: a key pressed
// while the plot is under the pointer is aimed at the plot. `+` on most
// layouts is shift+`=`, and either spelling zooms in, as in the GLFW adapter.
//
// Every mapped key is asked with repeat = true. This is the adapter's whole key
// -repeat policy, and the odd one out among the hosts: GLFW, Qt and GTK each
// deliver the OS's auto-repeat as extra press events, while ImGui edge-detects
// presses once and only re-fires `IsKeyPressed` while the key is *held* if it is
// asked to. Holding `+` or an arrow on a plot is a zoom/pan the user expects to
// keep going (docs/embedding.md's shared policy: a held key repeats in every
// adapter), so the repeat is requested here. Escape repeats harmlessly --
// cancelling an already-cancelled drag is a no-op.
if (hovered && !io.WantTextInput) {
const auto key = [&](int code) {
input::KeyEvent e;
@@ -239,15 +253,15 @@ void PlotWidget::forward_input(bool hovered) {
e.down = true; // ImGui already edge-detected the press
controller_->on_key(e);
};
if (ImGui::IsKeyPressed(ImGuiKey_Equal, false) ||
ImGui::IsKeyPressed(ImGuiKey_KeypadAdd, false)) {
key(input::kKeyZoomIn);
}
if (ImGui::IsKeyPressed(ImGuiKey_Minus, false) ||
ImGui::IsKeyPressed(ImGuiKey_KeypadSubtract, false)) {
key(input::kKeyZoomOut);
}
if (ImGui::IsKeyPressed(ImGuiKey_F, false)) key(input::kKeyFit);
const auto pressed = [](ImGuiKey k) { return ImGui::IsKeyPressed(k, true); };
if (pressed(ImGuiKey_Equal) || pressed(ImGuiKey_KeypadAdd)) key(input::kKeyZoomIn);
if (pressed(ImGuiKey_Minus) || pressed(ImGuiKey_KeypadSubtract)) key(input::kKeyZoomOut);
if (pressed(ImGuiKey_F)) key(input::kKeyFit);
if (pressed(ImGuiKey_LeftArrow)) key(input::kKeyLeft);
if (pressed(ImGuiKey_RightArrow)) key(input::kKeyRight);
if (pressed(ImGuiKey_UpArrow)) key(input::kKeyUp);
if (pressed(ImGuiKey_DownArrow)) key(input::kKeyDown);
if (pressed(ImGuiKey_Escape)) key(input::kKeyEscape);
}
prev_down_[0] = down[0];
+105 -11
View File
@@ -11,6 +11,9 @@
#include <QOpenGLContext>
#include <QPointF>
#include <QWheelEvent>
#include <QEvent>
#include <QGestureEvent>
#include <QPinchGesture>
#include <chrono>
#include <cmath>
@@ -93,6 +96,10 @@ PlotWidget::PlotWidget(Plot& plot, QWidget* parent)
// The controller's keys (+, - and f) are input to the plot, and a widget without
// a focus policy never receives a key event.
setFocusPolicy(Qt::StrongFocus);
// Two-finger zoom/pan arrives as one gesture, which no mouse-event handler sees:
// grabbing it routes QEvent::Gesture here, and `event()` folds it into the
// controller's `GestureEvent`s (zoom about the focal point, focal pan).
grabGesture(Qt::PinchGesture);
}
PlotWidget::~PlotWidget() {
@@ -243,6 +250,10 @@ void PlotWidget::forward_pointer(QMouseEvent* event, bool pressed, bool released
e.down = any_plot_button(event->buttons());
e.pressed = pressed;
e.released = released;
// Qt's input-event stamp is milliseconds since an arbitrary epoch (the system
// monotonic clock, in practice); the controller only ever differences them, for
// the double-click detector, so the epoch does not matter -- the unit does.
e.time_s = static_cast<f64>(event->timestamp()) * 1.0e-3;
controller_.on_pointer(e);
event->accept();
update();
@@ -256,16 +267,26 @@ void PlotWidget::mouseReleaseEvent(QMouseEvent* event) { forward_pointer(event,
void PlotWidget::mouseDoubleClickEvent(QMouseEvent* event) { forward_pointer(event, true, false); }
void PlotWidget::wheelEvent(QWheelEvent* event) {
// Qt reports eighths of a degree, 120 to a notch; the controller takes notches,
// positive = zoom in, which is Qt's positive angleDelta().y() as well (the wheel
// pushed away from the user). Passing the raw delta would zoom 120x too far.
const f32 notches = static_cast<f32>(event->angleDelta().y()) / 120.0f;
if (notches == 0.0f) {
// A horizontal-only wheel is not a zoom, and it must not reach the controller:
// a zero-notch zoom changes no range but still runs the range setters, which
// clear auto scale -- so a stray horizontal scroll would silently stop a live
// axis from following its stream. Ignored, so an enclosing scroll area may
// still act on it.
// Two units arrive, one per axis: Qt's `angleDelta()` is in eighths of a degree,
// 120 to a notch, while `pixelDelta()` is in (high-resolution) scroll pixels and
// is the only non-zero delta a device that reports pixels produces. A pixel
// delta has no notch size Qt defines, so the divisor is this adapter's
// **calibration**, not a conversion: 40 scroll pixels are taken to be one notch,
// the order of a deliberate one- or two-finger scroll gesture, so such a gesture
// zooms about one detent instead of a fraction of one (the same reasoning, and
// the same role, as the GTK adapter's kSurfacePixelsPerNotch; a value measured
// on real hardware should replace it).
const QPoint angle = event->angleDelta();
const QPoint pixels = event->pixelDelta();
const bool pixel_scrolled = angle == QPoint(0, 0) && !pixels.isNull();
const f32 notches = pixel_scrolled ? static_cast<f32>(pixels.y()) / 40.0f
: static_cast<f32>(angle.y()) / 120.0f;
const f32 notches_x = pixel_scrolled ? static_cast<f32>(pixels.x()) / 40.0f
: static_cast<f32>(angle.x()) / 120.0f;
if (notches == 0.0f && notches_x == 0.0f) {
// No zoom and no pan: the event moves nothing, so it must not reach the
// controller -- a zero-delta event would still run the range setters and clear
// auto scale. Ignored, so an enclosing scroll area may still act on it.
event->ignore();
return;
}
@@ -273,7 +294,10 @@ void PlotWidget::wheelEvent(QWheelEvent* event) {
input::WheelEvent e;
e.x = p.x;
e.y = p.y;
e.dx = static_cast<f32>(event->angleDelta().x()) / 120.0f;
// The horizontal delta is forwarded: the controller pans x by it (5% of the span
// a notch) instead of zooming, which is what a sideways scroll on a trackpad
// means -- and what every other adapter's events already let it do.
e.dx = notches_x;
e.dy = notches;
e.mods = mods_from(event->modifiers());
controller_.on_wheel(e);
@@ -292,6 +316,16 @@ void PlotWidget::keyPressEvent(QKeyEvent* event) {
case Qt::Key_Minus:
case Qt::Key_Underscore: mapped = input::kKeyZoomOut; break;
case Qt::Key_F: mapped = input::kKeyFit; break;
// Arrows pan (10% of the span per press, repeats included) and Escape cancels
// the active drag -- translated, like the ASCII set above, and the controller
// decides what each does. Not offered to the parent widget when handled: an
// Escape that reaches a dialog's reject button would cancel the *window*, not
// the plot's drag, because the widget has focus.
case Qt::Key_Escape: mapped = input::kKeyEscape; break;
case Qt::Key_Left: mapped = input::kKeyLeft; break;
case Qt::Key_Right: mapped = input::kKeyRight; break;
case Qt::Key_Up: mapped = input::kKeyUp; break;
case Qt::Key_Down: mapped = input::kKeyDown; break;
default:
QOpenGLWidget::keyPressEvent(event); // not a plot key: nobody else wants it
return;
@@ -307,4 +341,64 @@ void PlotWidget::keyPressEvent(QKeyEvent* event) {
update();
}
bool PlotWidget::event(QEvent* event) {
if (event->type() == QEvent::DevicePixelRatioChange) {
// The window moved to a display with another scale factor (or the scale of the
// screen it is on changed). Everything the ratio decides -- the device
// viewport the controller hit-tests against, and the `ui_scale` the next
// frame's paint re-reads -- is refreshed here, before any event queued behind
// this one maps a gesture through the old geometry. `paintGL()` would catch up
// one frame later; a drag mid-gesture would pan by the wrong distance in
// exactly that frame.
refresh_viewport();
}
if (event->type() == QEvent::Gesture) {
auto* gesture_event = static_cast<QGestureEvent*>(event);
// The widget grabbed Qt::PinchGesture, so its pinch events arrive here; a
// gesture this widget does not know is left for whoever else may want it.
if (QPinchGesture* pinch =
static_cast<QPinchGesture*>(gesture_event->gesture(Qt::PinchGesture));
pinch != nullptr) {
forward_pinch(pinch, gesture_event);
}
}
return QOpenGLWidget::event(event);
}
void PlotWidget::forward_pinch(QPinchGesture* gesture, QGestureEvent* event) {
// The gesture reports cumulative values; `PinchTracker` folds them into the
// incremental zoom and focal movement the controller's `GestureEvent` wants.
// Positions are logical widget pixels here, exactly like a mouse event's, and
// go through the same `devicePixelRatioF()` mapping `to_device()` applies.
const QPointF logical_center = gesture->centerPoint();
const f64 dpr = static_cast<f64>(devicePixelRatioF());
const Vec2 logical{static_cast<f32>(logical_center.x()), static_cast<f32>(logical_center.y())};
switch (gesture->state()) {
case Qt::GestureStarted:
// The begin records the anchor only: a pinch that just started has zoomed
// and moved nothing yet.
pinch_.begin(static_cast<f32>(gesture->totalScaleFactor()), logical);
break;
case Qt::GestureUpdated:
case Qt::GestureFinished: {
// The finished state's last update is the gesture's final zoom: folded like
// an update, so nothing the fingers did is dropped for ending.
const input::GestureEvent g =
pinch_.update(static_cast<f32>(gesture->totalScaleFactor()), logical, dpr);
controller_.on_gesture(g);
break;
}
default:
// Canceled: the fingers left the surface mid-gesture. No event -- whatever
// zoom the updates delivered stands, and the next pinch begins fresh.
break;
}
if (gesture->state() == Qt::GestureFinished || gesture->state() == Qt::GestureCanceled) {
pinch_.end();
}
event->accept(gesture); // consumed: the plot zoomed/panned, no parent gets it too
update();
}
} // namespace plot::qt
+25 -4
View File
@@ -34,6 +34,9 @@ class QKeyEvent;
class QMouseEvent;
class QPointF;
class QWheelEvent;
class QEvent;
class QGestureEvent;
class QPinchGesture;
namespace plot::qt {
@@ -106,10 +109,15 @@ namespace plot::qt {
/// reinterpretation rather than the edge synthesis the ImGui adapter needs.
/// Wheel input is the one unit conversion: Qt's `angleDelta()` is in eighths of a
/// degree, 120 to a notch, while `Controller::on_wheel()` takes notches, positive
/// = zoom in -- which is Qt's positive `angleDelta().y()` too. A wheel event with
/// no vertical component is refused before it reaches the controller, because a
/// zero-notch zoom changes no range but still runs the range setters, which clear
/// auto scale: a horizontal scroll must not silently stop a live axis following.
/// = zoom in -- which is Qt's positive `angleDelta().y()` too. A wheel event whose
/// deltas both vanish -- no angle and no pixels -- is refused before it reaches the
/// controller, because a zero-notch, zero-pan event changes nothing and must not
/// clear auto scale; anything with a vertical *or* horizontal component is
/// forwarded, and the controller decides what each does (zoom on `dy`, pan on
/// `dx`). The widget is also a pinch source: `Qt::PinchGesture` is grabbed in the
/// constructor and folded into `input::GestureEvent`s by `PinchTracker`
/// (`qt_pointer.hpp`), so a two-finger zoom-about-focal-point and a two-finger pan
/// reach the controller like the equivalent wheel + drag pair would.
///
/// ## Threading
///
@@ -170,6 +178,12 @@ class PlotWidget : public QOpenGLWidget, protected QOpenGLFunctions {
void initializeGL() override;
void resizeGL(int w, int h) override;
void paintGL() override;
/// The one virtual that sees what no dedicated handler does: the device pixel
/// ratio change (`QEvent::DevicePixelRatioChange`) and the grabbed pinch
/// (`QEvent::Gesture`). Both refresh or feed the controller before the next
/// paint, so a gesture arriving between the change and the frame maps against
/// the geometry the ratio now implies.
bool event(QEvent* event) override;
void mousePressEvent(QMouseEvent* event) override;
void mouseMoveEvent(QMouseEvent* event) override;
@@ -196,6 +210,10 @@ class PlotWidget : public QOpenGLWidget, protected QOpenGLFunctions {
/// Sets the plot's viewport to the current device rect (through the controller,
/// which refreshes the layout with it).
void refresh_viewport();
/// Folds one pinch update into a `GestureEvent` through `PinchTracker` and hands
/// it to the controller. `event` is accepted for the pinch gesture, so Qt does
/// not offer it to parents too.
void forward_pinch(QPinchGesture* gesture, QGestureEvent* event);
Plot* plot_ = nullptr; ///< declared first: the controller is built over it
input::Controller controller_;
@@ -207,6 +225,9 @@ class PlotWidget : public QOpenGLWidget, protected QOpenGLFunctions {
/// reports no delta of its own, and the ratio can change between two events
/// (see `PointerAnchor` for why the anchor must not be device pixels).
PointerAnchor anchor_;
/// The pinch gesture's folding state (see `PinchTracker`): one per widget, alive
/// between `Qt::GestureStarted` and `Finished`/`Canceled`.
PinchTracker pinch_;
u64 frames_ = 0;
f32 last_frame_ms_ = 0.0f;
+66
View File
@@ -4,6 +4,7 @@
#pragma once
#include <plot/core/types.hpp>
#include <plot/input/events.hpp>
#include <plot/math/vec2.hpp>
namespace plot::qt {
@@ -49,4 +50,69 @@ struct PointerAnchor {
}
};
/// The state one pinch gesture is folded into `input::GestureEvent`s from.
///
/// Qt reports a pinch as **cumulative** values with no delta of its own:
/// `QPinchGesture::totalScaleFactor()` is the zoom since the gesture began, and
/// `centerPoint()` the focal point now, both absolute in the gesture's life. The
/// controller wants the opposite of both -- `GestureEvent::scale` is *this frame's*
/// zoom factor (1.0 = no change) and `(dx, dy)` the focal point's movement since the
/// previous gesture event -- so the widget owns one of these and folds each update:
/// the zoom arrives as the ratio of the two cumulative scales, the pan as the
/// difference of two focal points.
///
/// The focal point is cached in **logical** widget pixels, like `PointerAnchor`
/// above, for the same reason: `devicePixelRatioF()` can change mid-gesture (the
/// window dragged onto another display), and a cache in device pixels would compare
/// a point taken at the old ratio with one taken at the new one -- a phantom zoom
/// anchor movement, delivered to the controller as a pan. The difference of two
/// logical points, scaled by the ratio in force now, is the movement the fingers
/// actually made.
///
/// A plain value with no toolkit dependency: the widget owns one, and this is the
/// part of the adapter's pinch translation a test can drive directly (see
/// tests/test_qt_pointer.cpp).
struct PinchTracker {
bool active = false; ///< true between the gesture's begin and its end/cancel
f32 last_scale = 1.0f; ///< the cumulative scale the previous update folded
f32 x = 0.0f; ///< last focal point, logical widget pixels
f32 y = 0.0f;
/// Starts a gesture at Qt's first cumulative `scale` and logical focal point.
/// The first event must carry no zoom and no movement -- a pinch that begins has
/// zoomed nothing yet -- so this only records where the folding starts from.
void begin(f32 scale, Vec2 logical_center) noexcept {
active = true;
last_scale = scale;
x = logical_center.x;
y = logical_center.y;
}
/// Folds one update into an incremental `GestureEvent`, in device pixels at
/// `dpr`: the zoom about the focal point, and the focal point's movement since
/// the previous update. Zero zoom and zero movement before `begin()`, so an
/// update that arrives without a begin -- a host that missed the start -- is a
/// no-op rather than a zoom from a made-up anchor.
input::GestureEvent update(f32 scale, Vec2 logical_center, f64 dpr) noexcept {
input::GestureEvent g;
if (!active) return g;
// The frame's zoom factor: Qt's cumulative ratio over the one this tracker
// last folded. The first update after begin() divides the scale by itself,
// which is 1.0 -- a pinch that has not moved has zoomed nothing.
g.scale = last_scale != 0.0f ? scale / last_scale : 1.0f;
g.x = static_cast<f32>(static_cast<f64>(logical_center.x) * dpr);
g.y = static_cast<f32>(static_cast<f64>(logical_center.y) * dpr);
g.dx = static_cast<f32>((static_cast<f64>(logical_center.x) - static_cast<f64>(x)) * dpr);
g.dy = static_cast<f32>((static_cast<f64>(logical_center.y) - static_cast<f64>(y)) * dpr);
last_scale = scale;
x = logical_center.x;
y = logical_center.y;
return g;
}
/// Ends the gesture: an update after this (a straggler `GestureUpdated` Qt
/// delivers out of order, a cancel that still reports) is again a no-op.
void end() noexcept { active = false; }
};
} // namespace plot::qt
+39 -2
View File
@@ -16,6 +16,30 @@ option(PLOT_BUILD_GLFW "Build the GLFW adapter and demo" OFF)
option(PLOT_BUILD_IMGUI "Build the Dear ImGui adapter and demo" OFF)
option(PLOT_BUILD_QT6 "Build the Qt6 adapter and demo" OFF)
option(PLOT_BUILD_GTK4 "Build the GTK4 adapter and demo" OFF)
# The optional runtime-font backend: plot::text::TrueTypeFont, which bakes a real
# TrueType/OpenType file into a fixed-cell atlas at run time (see
# include/plot/text/true_type_font.hpp). It is the one target that compiles the
# vendored third_party/stb_truetype.h, so it is opt-in and separable -- the core
# library, its public headers and its tests build without it -- and its default
# follows the vendored header's presence: a tree that has the file wants the
# feature, and one that does not cannot have it. Asking for it explicitly without
# the header is an error rather than a silent downgrade, because the option is
# then the only thing that would have said so. The top-level CMakeLists.txt adds
# the target itself; this file only decides whether it exists.
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/third_party/stb_truetype.h")
set(PLOT_VENDORED_STB_TRUETYPE TRUE)
else()
set(PLOT_VENDORED_STB_TRUETYPE FALSE)
endif()
option(PLOT_BUILD_TTF_FONT
"Build the optional plot::text::TrueTypeFont backend (vendored stb_truetype)"
${PLOT_VENDORED_STB_TRUETYPE})
if(PLOT_BUILD_TTF_FONT AND NOT PLOT_VENDORED_STB_TRUETYPE)
message(FATAL_ERROR
"plot: PLOT_BUILD_TTF_FONT=ON needs third_party/stb_truetype.h, which this tree does "
"not have. Restore it -- include/plot/text/true_type_font.hpp records the version and "
"the source URL it came from -- or configure with -DPLOT_BUILD_TTF_FONT=OFF.")
endif()
option(PLOT_INSTALL "Generate install and export rules (find_package(plot))" ON)
set(PLOT_SANITIZE "" CACHE STRING
"Sanitizers to build with, as the list a -fsanitize= flag takes (e.g. address); empty for none")
@@ -27,9 +51,22 @@ set(PLOT_SANITIZE "" CACHE STRING
# presets leave it off, so their target lists, configure time and build product
# are exactly what they were before this option existed.
option(PLOT_COVERAGE "Add the `coverage` target (needs a --coverage build tree)" OFF)
if(PLOT_COVERAGE AND NOT CMAKE_CXX_FLAGS MATCHES "--coverage")
# The instrumented tree is one whose *effective* flags carry --coverage, and the
# base CMAKE_CXX_FLAGS is only part of those: the active configuration's own
# profile (CMAKE_CXX_FLAGS_<CONFIG>) is applied on top, and --coverage is
# equally at home in either. The linux-coverage preset sets the base; a user who
# sets it on the Debug profile instead gets exactly the same instrumented tree,
# which the old base-only string match rejected. Matching the two together is
# still just an option check on the configuration profile -- the guard does not
# try to read the compiler's mind, only the flags this build will pass it.
string(TOUPPER "${CMAKE_BUILD_TYPE}" plot_coverage_config)
set(plot_coverage_flags
"${CMAKE_CXX_FLAGS} ${CMAKE_CXX_FLAGS_${plot_coverage_config}}")
if(PLOT_COVERAGE AND NOT plot_coverage_flags MATCHES "--coverage")
message(FATAL_ERROR
"plot: PLOT_COVERAGE=ON needs a --coverage build (CMAKE_CXX_FLAGS is \"${CMAKE_CXX_FLAGS}\"). "
"plot: PLOT_COVERAGE=ON needs a --coverage build (CMAKE_CXX_FLAGS is "
"\"${CMAKE_CXX_FLAGS}\" and CMAKE_CXX_FLAGS_${plot_coverage_config} is "
"\"${CMAKE_CXX_FLAGS_${plot_coverage_config}}\"). "
"Configure with --preset linux-coverage, or drop -DPLOT_COVERAGE=ON.")
endif()
if(PLOT_COVERAGE AND NOT PLOT_BUILD_TESTS)
+41
View File
@@ -32,6 +32,21 @@
# to the list below; a caller may override it, and
# emptying it is how a test proves the exemption is what
# keeps a header out of the "missing" report.
# PLOT_EXTRA_PUBLIC_HEADERS generated public headers, relative to
# PLOT_INCLUDE_DIR, that the glob below cannot see
# because configure_file() writes them into the build
# tree (plot/version.hpp). They are checked like any
# other public header: the umbrella has to name them,
# and the dangling-include check cannot demand them
# under PLOT_INCLUDE_DIR, because their existence is
# pinned by the configure that generated them. Defaults
# to the project's own list, on the same terms as the
# optional list: exact paths, because a hole should be
# a list of files.
# PLOT_GENERATED_INCLUDE_DIR the build-tree directory those headers live in,
# consulted when one of them is not under
# PLOT_INCLUDE_DIR. Unset in a tree with no generated
# headers.
if(NOT DEFINED PLOT_OPTIONAL_HEADERS)
set(PLOT_OPTIONAL_HEADERS
@@ -42,6 +57,18 @@ if(NOT DEFINED PLOT_OPTIONAL_HEADERS)
"plot/text/true_type_font.hpp")
endif()
if(NOT DEFINED PLOT_EXTRA_PUBLIC_HEADERS)
set(PLOT_EXTRA_PUBLIC_HEADERS
# <plot/version.hpp>, generated by configure_file() from
# include/plot/version.hpp.in into the build tree and installed with the
# public headers. The glob cannot see it -- it is not under the source
# include root -- and neither can the dangling-include check, so the
# default list declares it: the umbrella must still carry its include, and
# a script run without the caller's -D inputs (the exemption test, a bare
# -P invocation) judges the umbrella exactly as the build does.
"plot/version.hpp")
endif()
if(NOT DEFINED PLOT_INCLUDE_DIR)
message(FATAL_ERROR "plot: PLOT_INCLUDE_DIR is not set")
endif()
@@ -52,6 +79,9 @@ endif()
file(GLOB_RECURSE plot_umbrella_headers
RELATIVE "${PLOT_INCLUDE_DIR}"
"${PLOT_INCLUDE_DIR}/plot/*.hpp")
if(PLOT_EXTRA_PUBLIC_HEADERS)
list(APPEND plot_umbrella_headers ${PLOT_EXTRA_PUBLIC_HEADERS})
endif()
list(SORT plot_umbrella_headers)
file(READ "${PLOT_UMBRELLA}" plot_umbrella_text)
@@ -100,9 +130,20 @@ string(REGEX MATCHALL "#include <plot/[^>]+>" plot_umbrella_includes "${plot_umb
set(plot_umbrella_dangling "")
foreach(plot_umbrella_include IN LISTS plot_umbrella_includes)
string(REGEX REPLACE "#include <([^>]+)>" "\\1" plot_umbrella_header "${plot_umbrella_include}")
# A declared generated header is exempt from the existence demand: it lives
# in the build tree by construction -- the configure that produced this
# umbrella also produced the header -- so a run without
# PLOT_GENERATED_INCLUDE_DIR set (a bare -P invocation) judges it on the
# umbrella naming it, which the required-set loop above already checked.
if(plot_umbrella_header IN_LIST PLOT_EXTRA_PUBLIC_HEADERS)
continue()
endif()
if(NOT EXISTS "${PLOT_INCLUDE_DIR}/${plot_umbrella_header}")
if(NOT DEFINED PLOT_GENERATED_INCLUDE_DIR OR
NOT EXISTS "${PLOT_GENERATED_INCLUDE_DIR}/${plot_umbrella_header}")
list(APPEND plot_umbrella_dangling "${plot_umbrella_header}")
endif()
endif()
endforeach()
if(plot_umbrella_dangling)
+38 -13
View File
@@ -41,9 +41,34 @@ ctest --test-dir build/debug --output-on-failure
ctest registers four tests: `plot_tests` (the test binary), the two header/umbrella build
checks (`plot_headers_self_contained`, `plot_umbrella_covers_public_headers`) and
`plot_umbrella_check`. The `plot_tests` binary itself runs **283 cases** in the `linux-debug`
preset; its harness prints one line per case and a final `283 cases, 0 failed` summary line.
The case counts per file are the `PLOT_TEST(...)` declarations under `tests/`.
`plot_umbrella_check`. The `plot_tests` binary itself runs **396 cases** in the `linux-debug`
preset; its harness prints one line per case and a final `396 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.
`PLOT_TEST_FILTER=<substring>` runs only the cases whose name contains that substring, and
is for working *on* a case rather than for checking the library: most of the render cases
create their own EGL context and read pixels back, so the whole binary is far slower than
the one case being edited and its one line of measured output scrolls past 376 others. The
summary line still counts the whole suite, so a filtered run says `396 cases, 0 failed`
having run one — it is never evidence that the suite passes. Unset, every case runs.
## Install layout
`cmake --install` follows `GNUInstallDirs`, so every path honours the standard cache
variables (`CMAKE_INSTALL_LIBDIR`, `CMAKE_INSTALL_INCLUDEDIR`, …) and their distribution
defaults — plain `lib`, `include` and `share` unless a packager overrides them:
| prefix-relative path | what |
|---|---|
| `lib/` | the `plot_core` archive (and `libplot_core.so` with its version and SONAME symlinks when built with `BUILD_SHARED_LIBS=ON`) |
| `include/plot/` | every public header, including the generated `plot/version.hpp` (`plot::version::major/minor/patch/string` and the `PLOT_VERSION_*` macros) |
| `lib/cmake/plot/` | the CMake package: `plotConfig.cmake`, `plotConfigVersion.cmake` (Compatibility **SameMinorVersion**, so `find_package(plot 0.1 …)` accepts 0.1.x only), `plotTargets.cmake` |
| `lib/pkgconfig/plot.pc` | pkg-config metadata for consumers whose build is not CMake |
| `share/licenses/plot/` | `LICENSE` (MIT) and `licences/OFL-1.1.txt` (the embedded font) |
Adapters that were built and exported install themselves alongside the core (their headers
under `include/<adapter>/`, their targets in the same package).
## Headless demo modes
@@ -95,15 +120,15 @@ The floors are enforced by `tools/coverage_floor.py`:
| every file under `src/` | **80%** of its instrumented lines |
| `src/` as a whole | **90%** of instrumented lines |
Branch coverage is deliberately off. The current measurement (from `build/cov/coverage.info`,
re-read with `python3 tools/coverage_floor.py build/cov/coverage.info`) is an **aggregate
95.3% (3284/3447 lines)** over 23 files, every file at or above 80% — the floor is satisfied.
The method, the full per-file table and the reproducibility notes are in
[docs/coverage.md](coverage.md); re-run the command for the live numbers.
Branch coverage is deliberately off. This page carries no measured totals — a number written
here would be stale the moment `src/` moved, and the report is reproducible: the method, the
per-file table and the floor verdict live in [docs/coverage.md](coverage.md), which
`tools/coverage.sh` rewrites from the very run that measured them. Re-run the command for the
live numbers.
**One thing to know about the coverage preset:** it builds **272 of the 283 cases**. The 11
that are missing are the adapter cases that need an adapter target to exist — 8 in
`test_imgui_widget.cpp` and 3 in `test_glfw_host.cpp` — and the coverage preset turns the
adapters off. The toolkit-free adapter seams still run there: `test_qt_pointer.cpp` (2 cases)
and `test_gtk_input.cpp` (8 cases) compile without Qt or GTK. So the coverage number measures
**One thing to know about the coverage preset:** it builds **379 of the 396 cases**. The 17
that are missing are the adapter cases that need an adapter target to exist — 11 in
`test_imgui_widget.cpp` and 6 in `test_glfw_host.cpp` — and the coverage preset turns the
adapters off. The toolkit-free adapter seams still run there: `test_qt_pointer.cpp` (5 cases)
and `test_gtk_input.cpp` (11 cases) compile without Qt or GTK. So the coverage number measures
the library plus its unit suite, not the adapter bodies (which the headless demo runs cover).
+15 -14
View File
@@ -43,35 +43,36 @@ when one is missing.
<!-- BEGIN GENERATED: tools/coverage.sh -->
```
coverage_floor: ok -- 23 files under src/, aggregate 95.2% lines (3302/3469), every file at or above 80%, aggregate target 90%
coverage_floor: ok -- 24 files under src/, aggregate 93.9% lines (3730/3971), every file at or above 80%, aggregate target 90%
```
| File | Lines | Line rate | Functions |
| --- | ---: | ---: | ---: |
| `src/data/array_series.cpp` | 43/43 | 100.0% | 100.0% |
| `src/data/minmax_pyramid.cpp` | 163/163 | 100.0% | 100.0% |
| `src/data/series.cpp` | 105/107 | 98.1% | 100.0% |
| `src/data/stream_series.cpp` | 39/39 | 100.0% | 100.0% |
| `src/gl/handle.cpp` | 256/256 | 100.0% | 100.0% |
| `src/data/array_series.cpp` | 56/56 | 100.0% | 100.0% |
| `src/data/minmax_pyramid.cpp` | 170/171 | 99.4% | 96.4% |
| `src/data/series.cpp` | 161/164 | 98.2% | 100.0% |
| `src/data/stream_series.cpp` | 45/45 | 100.0% | 100.0% |
| `src/data/xy_series.cpp` | 102/120 | 85.0% | 100.0% |
| `src/gl/handle.cpp` | 259/259 | 100.0% | 100.0% |
| `src/gl/loader.cpp` | 81/81 | 100.0% | 100.0% |
| `src/input/controller.cpp` | 329/343 | 95.9% | 100.0% |
| `src/input/controller.cpp` | 430/465 | 92.5% | 97.4% |
| `src/math/transform.cpp` | 28/28 | 100.0% | 100.0% |
| `src/plot/axis.cpp` | 12/12 | 100.0% | 100.0% |
| `src/plot/figure.cpp` | 218/223 | 97.8% | 95.5% |
| `src/plot/figure.cpp` | 229/234 | 97.9% | 95.8% |
| `src/plot/grid.cpp` | 15/16 | 93.8% | 100.0% |
| `src/plot/measure.cpp` | 21/21 | 100.0% | 100.0% |
| `src/plot/plot.cpp` | 743/763 | 97.4% | 98.9% |
| `src/plot/ticks.cpp` | 93/99 | 93.9% | 100.0% |
| `src/plot/plot.cpp` | 767/793 | 96.7% | 98.9% |
| `src/plot/ticks.cpp` | 105/111 | 94.6% | 100.0% |
| `src/render/buffer.cpp` | 88/98 | 89.8% | 100.0% |
| `src/render/embedded_mono_font.cpp` | 45/45 | 100.0% | 100.0% |
| `src/render/primitives.cpp` | 71/75 | 94.7% | 100.0% |
| `src/render/primitives.cpp` | 75/79 | 94.9% | 100.0% |
| `src/render/program.cpp` | 26/27 | 96.3% | 100.0% |
| `src/render/renderer.cpp` | 530/609 | 87.0% | 93.7% |
| `src/render/renderer.cpp` | 616/722 | 85.3% | 93.9% |
| `src/render/text.cpp` | 27/30 | 90.0% | 100.0% |
| `src/render/text_atlas.cpp` | 40/41 | 97.6% | 100.0% |
| `src/render/vertex_array.cpp` | 59/59 | 100.0% | 100.0% |
| `src/render/vertex_array.cpp` | 62/62 | 100.0% | 100.0% |
| `src/text/true_type_font.cpp` | 270/291 | 92.8% | 96.8% |
| **src/ aggregate** | **3302/3469** | **95.2%** | **98.3%** |
| **src/ aggregate** | **3730/3971** | **93.9%** | **98.0%** |
<!-- END GENERATED: tools/coverage.sh -->
+365 -24
View File
@@ -2,20 +2,27 @@
## The sample stores
All three series derive from `plot::data::Series` (`include/plot/data/series.hpp`), which
describes a **uniform-grid** series: sample `i` lives at `x = x0() + i * dx()`.
All series derive from `plot::data::Series` (`include/plot/data/series.hpp`), which
describes the sample-indexing contract; a **uniform-grid** series additionally has
sample `i` at `x = x0() + i * dx()` (`ArraySeries`, `StreamSeries`), while
**`XYSeries`** carries its own sorted, ascending x array for data that is not on a
uniform grid.
```cpp
class Series {
public:
virtual u64 size() const;
virtual f64 x0() const; // x of sample 0 (the oldest retained sample)
virtual f64 dx() const;
virtual f64 dx() const; // 0 for XYSeries, which owns its x array
virtual Range x_bounds() const;
virtual Range y_bounds() const;
virtual const MinMaxPyramid& pyramid() const;
virtual bool streaming() const noexcept; // default false
virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const;
virtual f64 x_at(u64 i) const; // x0() + i*dx(), or x()[i]
virtual bool neighbours(f64 x, u64& i0, u64& i1) const; // bracketing samples
virtual u64 snap_index(f64 x) const; // nearest sample
virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
GatherMode mode = GatherMode::Envelope) const;
Range reduce(u64 begin, u64 end) const; // exact min/max over [begin, end)
f64 sum(u64 begin, u64 end) const; // exact sum over [begin, end)
@@ -29,11 +36,65 @@ Indices are the series' **own window**: index 0 is the oldest sample it currentl
**`ArraySeries`** (`array_series.hpp`) owns a `std::vector<f32>` of y samples plus an
`x0`/`dx` grid. Construct with `ArraySeries(std::vector<f32> y, f64 x0, f64 dx)`. The vector
is taken by value and owned, so the samples cannot change behind the series' back; the store
changes only through `reset(y, x0, dx)` (a whole new vector, then a rebuild) or `commit()`,
which rebuilds the pyramid and cached bounds from the current `y`.
changes only through `reset(y, x0, dx)` (a whole new vector, then a rebuild), `commit()`,
which rebuilds the pyramid and cached bounds from the current `y`, or `push(y)`, which
appends one sample incrementally — the pyramid folds it in amortised O(1) and the bounds
extend in place, NaN-aware, instead of rebuilding.
**`StreamSeries`** (`stream_series.hpp`) is the single-producer / single-consumer streaming
store: `StreamSeries(u64 capacity, f64 x0, f64 dx)`.
store: `StreamSeries(u64 capacity, f64 x0, f64 dx)`. `capacity` is rounded up to a power of
two by the ring, `size()` saturates at that rounded `capacity()` (a `StreamSeries(100)`
retains up to 128 samples), and the pyramid is reserved for the rounded value up front.
`freeze()` copies the window published by the last `acquire()` into a fresh `ArraySeries`
(consumer thread only): a stable snapshot that no later production can move.
## Non-finite samples
The library-wide NaN policy: **a non-finite sample is excluded from every aggregate** —
min, max, sum, sum of squares and the pyramid's per-bucket finite count — and from the
cached bounds. A window with no finite samples at all aggregates to a **zero-span
`Range` / zero sums**, never to NaN. The policy is applied everywhere an aggregate is
built: `MinMaxPyramid::append` (a NaN occupies its absolute position, so bucket
boundaries stay positional, but folds nothing), the edge folds in `Series::reduce`,
`sum` and `sumsq`, `ArraySeries::commit` and `push` bounds, and
`StreamSeries::acquire` bounds. A bucket whose samples were all non-finite is stored as
an *empty* aggregate (count 0, zero sums) that every reader skips — it is absent, not
zero-valued. `gather()` follows suit: Envelope and Mean emit nothing for such a bucket,
and Nearest draws its centre sample raw. Hosts that want gaps in the data should draw
them with NaN samples and will get exactly that: excluded from statistics, present in
raw geometry.
## Gather x is relative, and what each mode emits
**`Series::gather` emits x relative to `x0()`** — exactly `i * dx` for a uniform
series, `x_at(i) - x0()` for `XYSeries` — never the absolute coordinate. y is
unchanged. The renderer reconstructs absolute data x in f64 (`x0() + emitted`) before
transforming, so no precision is lost at the f32 narrowing: the staged value is
O(visible pixels), not O(epoch). (Staging an absolute epoch x as f32 would quantise it
to a grid coarser than the pixel — f32 carries 24 bits, about 1 s at 1.7e9 — while the
relative value is small and exact.) The emitted geometry always spans
`[begin, end)` exactly and never reaches outside it: at level ≥ 1 the bucket edges are
selected ceil/floor (`first` = first bucket starting at or after `begin`, `last` = one
past the last bucket ending at or before `end`) and the two partial edge spans are
drawn from raw samples, which is what keeps an unaligned window envelope-exact
(previously the buckets straddling the range leaked samples from outside it into the
geometry). `level` is clamped to `kMaxPyramidLevel` (57 — the deepest level whose
bucket span fits a `u64`), so any `u32` level is safe.
The `GatherMode` decides what each bucket contributes, at the same level the renderer
already picked:
- **`Envelope`** (the default): one (min, max) pair per bucket, min drawn at the
bucket's first sample and max at its last. The dense, extreme-reading geometry a
time-series trace wants.
- **`Nearest`**: one raw sample per bucket — the centre sample, the pick
`pyramid().snap()` would make — drawn at its own x. Thinner geometry for traces
whose *shape* matters more than their extremes.
- **`Mean`**: one point per bucket, y = the bucket's finite samples' mean (the exact
`bucket_sum()` the pyramid stores divided by its finite count), x at the bucket
centre. A smoothed trace for noisy data.
Level 0 is the raw samples for every mode.
## The SPSC contract
@@ -82,11 +143,12 @@ the visible pixels rather than on the sample count.
and, when it completes, pushes it and walks up, stopping at the first level that gains no
entry: an append costs **amortised O(1)**. `drop_before(u)` slides the window's leading
edge, bounding the memory by the retained window rather than by everything ever appended.
- An entry is 24 bytes (`MinMax` plus the exact `sum`/`sumsq` the exact range queries fold
over), so level 0 costs `0.375n` bytes and the geometric tail brings the whole pyramid to
about **`0.75n` bytes — roughly 19% of the `4n` bytes of raw samples** (`f32`). A
min/max-only index would be `0.25n` (~6%); the two doubles are the price of answering
mean/RMS/integral from whole buckets instead of raw samples.
- An entry is 32 bytes (`MinMax` plus the exact `sum`/`sumsq` the exact range queries fold
over and the finite-sample `count` the mean folds by), so level 0 costs `0.5n` bytes and
the geometric tail brings the whole pyramid to about **`1n` bytes — roughly 25% of the
`4n` bytes of raw samples** (`f32`). A min/max-only index would be `0.25n` (~6%); the
extra words are the price of answering mean/RMS/integral from whole buckets instead of
raw samples.
Queries on the pyramid are **bucket-grained** and named `*_buckets` (`reduce_buckets`,
`sum_buckets`, `sumsq_buckets`): a pyramid stores aggregates, so it can only answer exactly
@@ -104,10 +166,281 @@ series therefore stages O(visible pixels) vertices, not O(samples), and `FrameSt
which path each trace took. Below the threshold the raw samples are drawn instead; both
sides go through the same instanced anti-aliased quad pipeline.
## Exact versus interpolated
## Dense traces are reduced onto the pixel grid
The library deliberately holds two conventions next to each other, and they do **not**
agree:
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. The floor only holds if the riser's rows
**reach** a tier: the "lit at all" cutoff that discards a column's empty rows is
`min(0.01 × peak, 0.5 × w_min)` over the smallest deposit any of that column's own segments
made, and not the first term alone. A column holding a flat level *and* a riser has its peak set
by the level — one unit in one row — while the riser spreads a unit over 300, which is three
orders of magnitude under `0.01 × peak`, so the riser was discarded before the floor could apply
to it. Measured on a 2098-sample square wave at 105 samples a column: **2 916 of the ungraded
path's 35 884 riser rows**, 106 of 116 riser columns gutted entirely, with a 311-row dark run in
a 312-row axis; the ten survivors were the phase that put a riser in a column of its own, which
is why it read as a riser *flickering* under a pan rather than as a missing edge. With the second
term it is 35 572 rows and 115 of 116 columns, for 48 extra segments
(`a_graded_column_keeps_a_riser_it_shares_with_a_flat_level`). 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.
## A sparse polyline is graded by slope
The column reduction stops at `dense_threshold_spp` (1.5 samples a device pixel), below which the
trace is a polyline. Grading used to stop there with it, because it was staged *inside* the
reduction — and that coupling was a defect, not a design. The reduction's gate is a density, and
its only purpose is removing the moiré beat between the bucket pitch and the pixel pitch. Dwell
has nothing to do with that beat, so zooming past the threshold turned the phosphor model off in
one step: the same square-wave edge measured **71 of 191** at 3.4 samples a pixel and **191 of
191** at 1.0, a 2.7× jump across one pixel of zoom, which reads as a discontinuity *lighting up*
the moment you look at it closely. The thing a reader most needs told apart from a real signal
excursion is a transition, and it was being drawn at full ink exactly when it filled the screen.
`Renderer::stage_trace_slopes()` grades the polyline on the same principle, differing only where
it must:
- **The measure is the segment's own slope**, `1 / (1 + |Δy_screen|)`, which is dwell per row for
a single segment. The `1 +` is not a guard against a division: a flat segment — span 0, the
commonest case in a sparse window — must be the *brightest* tier, and this makes the measure
dimensionless and **absolute**. There is no per-column peak to normalise against down here, and
that is an improvement: a sparse frame's tiers mean the same thing from frame to frame, so
panning does not reshade the trace.
- **No gather mode is excluded.** The reduction is `Envelope`-only, because turning a bucket's
min/max into a band answers a different question from the one a `Nearest` or `Mean` host asked.
A segment's slope is a property of the polyline itself, so `grade` is not reused as the gate —
only `intensity_grading` and `intensity_steps > 1` are.
- **The geometry is bit-for-bit the ungraded polyline's**, staged exactly as `stage_xy()` stages
it, with the same `snap = false`; only the batch a segment lands in differs. A segment with one
non-finite end is dropped rather than clamped, which is the conclusion `add_polyline_xy()`
reaches too.
- **`FrameStats::slope_traces` counts it, and it is disjoint from `column_traces`.** A window is
either dense enough to reduce onto the pixel grid or it is drawn as a polyline, never both — so
a host asserting `graded_traces == column_traces` keeps that reading unchanged.
`a_sparse_trace_is_graded_by_slope_like_a_dense_one` is the guard: one window either side of the
threshold over the same transition at the same place. The level reads **191 either way** (a flat
segment dwells in its row, so it is the top tier and full ink) and the riser reads **71 either
way**, against the 191 the sparse frame gave before. Across the whole sweep from 101 down to 0.1
samples a pixel the riser holds 71 and the level holds 191, so there is no step at the threshold
left to see.
## A phosphor density is the trace, one grid per trace
`Plot::set_density()` gives a plot a **phosphor density**: an image of how often its traces have
passed, drawn *in place of them* -- one grid per trace, in that trace's own colour -- fading by a
time constant or by an acquisition count. It is the library's because the library is the side that
knows a trace's device geometry; a host that built one itself would be re-rasterising traces the
renderer has already staged.
The split from the grading above is what it costs: grading measures *one* frame's dwell and
throws it away, while the density **keeps** what it measured — that is the whole point of it, and
also why it cannot be a per-frame accumulator.
- **The grid is the screen's, not the data's — bounded — and there is one per trace.** The axes
rect in device pixels (`DensityGrid`, `plot/plot/density.hpp`), one `f32` cell each, **up to
`kDensityCellCap` = 262 144 cells** (2^18, a mebibyte of counts): under the cap the grid *is* the
rect — the axes of a 640x360 viewport is a **573x313** grid of **179 349 cells** and **717 396
bytes**, and a thousand acquisitions through it cost the same — and past it the rect is scaled
down *uniformly*, so a cell stays square and the picture keeps the axes' aspect. A plot holds one
of these for **each trace it draws**, so the display's memory is that figure times the traces on
screen; the bound is per *grid*, so four traces hold at most 1 MiB each whatever the window is.
The colour is the reason for the per-trace split: a density that had one hue for the plot could
not say *which* trace put the ink there, and two signals agreeing in a cell would read as one
blended pixel. `Plot::density_grid(trace)` returns a trace's image; a trace with no series (an
unbound slot) holds no grid at all.
The cap is what keeps the resolution from following the window, and the window is not a fixed
thing: a 1520x850-logical window at 2x device scale has a 2877-pixel-wide axes rect, and four
rows of one trace are **4.5 M cells** — 18 MB of counts, every one of them walked per frame. The
pass is `O(cells)` in all three places it happens, so an unbounded image makes the *frame* cost a
function of the window: measured on llvmpipe (four traces, one a row, a 2 s window, a 3040x1700
framebuffer) the grid pass alone was **12.9 ms of tick and 12.8 ms of draw a frame** and 4.9 MB
uploaded, against **3.9 ms / 5.6 ms** and 1.0 MB at the cap. The renderer magnifies the capped
image back over the rect with a **linear** filter, because a cell is a *count* — a continuous
quantity — and a fragment between two of them is between their two ink levels, which is what a
linear tap returns and a point sample cannot (it would show the cell grid as blocks once a cell
is several pixels wide). A one-cell-tall feature is thereby spread over its neighbours, which is
what any magnification of an image does.
- **A cell's value is a number of sweeps**, and a trace covering it during one
`Plot::accumulate_density()` leaves 1.0, **once however many of its own samples crossed it**.
Counting the hits instead puts a single dense sweep at the ceiling of the brightness scale and
hides the sweeps behind it, and a cell would then mean "how much the beam moved here" rather
than "how many acquisitions were here". The dedupe is a stamp per grid *row* (one `u64`, a few
kB, shared by every trace's deposit), keyed by (trace, column), so two sweeps agreeing on a cell
still count twice and the two segments that share a column boundary do not.
- **The deposit is one pass per cell, not one per sample.** A trace's visible window is mapped to
sample indices and gathered exactly as `Renderer::draw_traces()` maps it (`neighbours()`, the
same 64-spp gate for the LOD level), then walked as a polyline: for each grid column the
segment covers, the rows it spans in that column get +1 each. A window of millions of samples
over hundreds of columns therefore costs *columns*, plus one row of ink per column the trace
crossed — the deposit is bounded by the screen, not by the data, which is what makes an
accumulation affordable at the trigger's own rate.
- **The newest acquisition is deposited like every other, and no trace is drawn over the image.**
`Renderer::draw_traces()` returns at once while a density is on: the trace ink *is* the image, so
the acquisition just drawn is a sweep *inside* it and fades with the rest. Drawing the polyline
on top as well would put a solid, unfading stroke over its own faint ghost — the "persistent
image + last acquisition" a user reported, where the newest acquisition never faded.
- **The brightness ceiling is the trace's own brightness, and the ramp is linear in sweeps.**
`DensityConfig::full_scale` defaults to **one sweep**, so a cell with one acquisition's ink is
drawn in its trace's colour at that colour's own alpha, and a signal crossing the same place
every acquisition settles *exactly* at the colour its trace would have drawn — the settlement is
a clamp, not a gain. `gamma` defaults to 1.0, because a cell's value is a count and half as many
sweeps must read as half the ink. A ceiling at the *fade's steady state* (`1/(1-0.5)=2`) was the
first default and is a legitimate alternative: it makes the newest sweep a fraction of the
settled brightness so the fade's ramp is visible, at the cost of a gain offset between the newest
sweep and the image behind it. The case that reads either one off the *screen* rather than the
grid is `a_phosphor_density_is_the_trace_in_its_own_colour`.
- **The scale is applied once, on the CPU.** `DensityConfig::full_scale` and `gamma` map a cell's
count to a luminance byte when the grid changed, and the shaders (`kDensityVS`/`kDensityFS`)
only tint that byte with the trace's colour. The one derivation is deliberate: a value a host
reads back through `DensityGrid::ink()`/`peak()` and the picture it sees come from the same
numbers.
- **Per frame, it is one texture upload and one draw call *per trace* — or one draw call each.**
The upload is one byte per cell (**179 kB** for the axes above, and at most 256 kB a trace at
`kDensityCellCap`, whatever the window); the one texture is re-filled per trace, and a frame whose
grids did not move skips the conversion and the uploads entirely and pays the quads alone
(`FrameStats::density_upload_bytes` reads 0 for it). The decay is what makes the grids move every
frame under `TimeConstant`, and what makes them stand still between acquisitions under
`PerAcquisition`.
A `TimeConstant` fade therefore does pay the conversion and the upload every frame, whatever the
bound — a *bounded* bill rather than a free one. Applying the fade on the GPU instead was
considered and is a **larger** change than this bound and was deliberately not bundled with it:
the image would have to live in a ping-pong target the decay multiplies in place, while the
*count* stays on the CPU, because that is what a host reads back through `ink()`/`peak()`, what
the deposit adds whole swaths to, and what the per-sweep dedupe keys on — so the two would have
to be reconciled per frame anyway. The bound is what makes that reconciliation a fixed cost.
`a_density_grid_is_the_axes_in_device_pixels` reads the resolution and the memory back
(**578x453 = 261 834 cells, 1 047 336 bytes** on its 640x480 viewport, which is past the cap, and
the 320x240 leg it ends on is the axes rect *exactly*, one cell a device pixel —
`ink=578 peak=1` for one flat sweep, the ink of one sweep *is* the columns it covers),
`a_density_grid_is_capped_whatever_the_window` reads the bound itself off a 3840x2160 viewport
(**681x384 = 261 504 cells = 1 046 016 bytes of a 3784x2138 axes**: the cells never exceed
`kDensityCellCap`, the grid is within 1 % of the whole budget and its aspect is the axes' — the
uniform scaling), and `a_capped_density_is_magnified_over_the_axes_rect` reads the *screen* side of
it (**569x460 = 261 740 cells of an 832x673 axes**: the image covers all **832** of the rect's
columns rather than the 569 cells it holds — the assertion a quad drawn at the grid's own size
fails — the sweep stays on the row its transform maps it to, and its ink is most of the trace's own
colour, `green=195` against a background of `65`, the rest being the magnification's blend),
`a_density_halves_per_acquisition_and_keeps_the_newest_brightest` reads the count schedule
(578 → 867 → 1012 on a 578-column grid, i.e. `1 + 1/2 + 1/4` of the width, `peak_steady=2.000`),
`a_density_fades_by_its_time_constant` reads `exp(-dt/tau)` off the ink,
`a_density_holds_one_grid_per_trace` reads two traces' grids apart (**261 834 cells each,
ink 578/578**, in two different rows), and
`a_phosphor_density_is_the_trace_in_its_own_colour` reads the frame: `cells=179349 uploaded=179349`
on the frame the grid moved and `uploaded=0` on the identical one after it; a saturated cell is the
trace's own colour exactly (`full=224`, the trace's `0xE0` green, against `bg=25`, where the old
near-white tint read ~190 red); one time constant dims it (`faded=115`); and a steady state
(`steady=224`) against a quarter of it (`quarter=138`) keeps the trace's hue, red near the
background's.
## 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
@@ -117,15 +450,23 @@ agree:
and no query allocates. `Measure::evaluate` and `Plot::region_stats` are built on these,
never on the `*_buckets` forms, which would silently report wrong extremes for an
unaligned interval.
- **The crosshair readout is interpolated, and says so.** `crosshair_span(series, x)`
returns the two samples `x` lies between (`begin`, `end`, `t`), and
`crosshair_interpolate(y_begin, y_end, t)` does a linear interpolation: it answers "what
is the signal *at* this instant", which is generally between two samples — a different
question from an interval statistic. The lookup is O(1) (one division and a floor) and
allocation-free; `Plot::crosshair_value(trace, x, out_y)` exposes the same number to a
host. A row whose trace has no sample at the crosshair's x shows `kCrosshairNoValue`
(an em dash, `"—"`), and an x outside the series window yields no value rather than the
nearest sample's.
- **The crosshair readout is a single sample — the nearest one — by default.**
`crosshair_span(series, x)` returns the two samples `x` lies between (`begin`, `end`, `t`),
and `CrosshairValue::NearestSample` takes whichever of the two is nearer: `t <= 0.5` the
earlier, otherwise the later. It answers "which datum is under the pointer", not "what is
the signal *at* this instant" — a linearly interpolated y is a number the series does not
contain, and on a source whose samples are already averaged or decimated at ingest it is a
claim about a signal nobody measured. That is why it is the default; a host whose x is a
quantity it is willing to interpolate states `CrosshairValue::Interpolated` and gets
`crosshair_interpolate(y_begin, y_end, t)` instead, at the crosshair's own x. The lookup is
O(1) and allocation-free either way, and it reads level 0, so the value is a stored sample
(or a blend of two) and never an LOD bucket's aggregate. `Plot::crosshair_point(trace, x,
out_x, out_y)` exposes **both** coordinates to a host and `crosshair_value()` the y alone;
`CrosshairRow::x`/`y` and its `marker` are placed from that same pair, so in
`NearestSample` the mark snaps onto the quoted sample and the dot and the number cannot
disagree. A row whose trace has no sample at the crosshair's x shows `kCrosshairNoValue`
(an em dash, `"—"`), and an x outside the series window yields no value rather than
clamping to the first or last sample.
`Measurement` (from `Plot::measurement()`) documents `integral` as a **left-Riemann sum**,
`sum(begin, end) * dx` — the samples taken as the value at the left edge of each step. That
+98 -13
View File
@@ -41,6 +41,48 @@ scale:
| Qt6 | `devicePixelRatioF()` | re-read every `paintGL()` |
| GTK4 | `mapping_.factor` = device size / logical allocation | the two sizes `::resize` reports, not `gtk_widget_get_scale_factor()` |
**Keys, key repeat, timestamps and touch parity.** The controller's keys and gestures
arrive through the adapters translated from each toolkit's own events, and four
conventions hold across all four hosts:
* **The key set is the same everywhere**: `+`/`-` zoom (with the `=`/`_` spellings a
shifted layout produces), `f`/`F` re-fits, the four arrows pan (10% of the span per
press), and Escape cancels the active drag. Each host translates its native key codes
to `input::kKey*` before calling the controller.
* **A held key repeats in every adapter.** GLFW, Qt and GTK deliver the OS auto-repeat
as extra press events and forward each one. ImGui edge-detects a press exactly once,
so its widget asks `IsKeyPressed(..., repeat = true)` for every key it maps and
ImGui's own repeat timer supplies what the OS would have.
* **`PointerEvent::time_s` is the host's monotonic clock, in seconds.** Only
differences are read: the double-click (two presses of the same button within 0.35 s
and 4 px re-fits every axis) compares two stamps, never an epoch. A host that leaves
the field 0 never produces a double-click, by contract.
| host | key repeat | `time_s` source | touch / pinch |
|---|---|---|---|
| GLFW | the OS auto-repeat, forwarded as repeated presses | `glfwGetTime()` | none — GLFW has no touch input |
| ImGui | `IsKeyPressed(..., repeat = true)` on every mapped key | `ImGui::GetTime()` | none — no touch input to forward |
| Qt6 | Qt repeats a held key as press events | `QInputEvent::timestamp()` / 1000 | `Qt::PinchGesture` → `GestureEvent`, folded by `PinchTracker` (`qt_pointer.hpp`) |
| GTK4 | GTK repeats a held key as press events | the GDK event's time / 1000 | `GtkGestureZoom` → `GestureEvent`, folded by `ZoomAnchor` (`gtk_input.hpp`) |
The two pinch folds are the same fold in two namespaces, and both live in
toolkit-free headers precisely so the headless tests can drive them: a toolkit
reports a pinch as **cumulative** values (total scale since the gesture began, the
focal point now), while `GestureEvent` wants the increments (this frame's zoom factor
about the focal point, 1.0 = no change, and the focal point's movement as `dx`/`dy`).
The folds also keep their focal anchor in *logical* pixels, like every other adapter
anchor, so a window dragged onto a display with another scale factor mid-gesture
cannot read the ratio change as a phantom pan.
**DPR / display changes.** The scale each host states to the renderer is re-read
whenever it can change, not latched: GLFW re-derives it in `refresh_sizes()` (every
frame, and from the resize callback), ImGui every frame from
`io.DisplayFramebufferScale`, Qt every `paintGL()` **and** on
`QEvent::DevicePixelRatioChange` — which also refreshes the controller's viewport
before any event queued behind the change maps a gesture through the old geometry —
and GTK in `::resize` through `DeviceMapping`. An input coordinate is always scaled by
the ratio in force at that event, never by one cached at startup.
**Headless verification.** Every demo supports `PLOT_DEMO_EXIT_AFTER_MS=<ms>` (stop the
loop after that long) and `PLOT_DEMO_SCREENSHOT=<path>` (write the final frame as a PPM).
This is how the project runs its own GUI demos in CI under Xvfb, with no display access
@@ -93,16 +135,39 @@ What `run()` does, in order:
Pointer translation: `on_cursor_pos()` multiplies the GLFW window coordinates by
`fb_scale_x_`/`fb_scale_y_` (framebuffer ÷ window) and synthesises `dx`/`dy` itself (GLFW
reports no delta); `down` comes from `glfwGetMouseButton`, and the modifier mask is read
from the key state, because GLFW's pointer and scroll callbacks carry no mask. Wheel
notches are forwarded as `WheelEvent`, and keys as `KeyEvent`.
reports no delta); `down` comes from `glfwGetMouseButton`, the modifier mask is read
from the key state, because GLFW's pointer and scroll callbacks carry no mask, and
`time_s` is `glfwGetTime()`. Wheel notches are forwarded as `WheelEvent` unchanged
(zero deltas included — the controller owns that case), and keys as `KeyEvent`: the
arrows pan, Escape cancels the active drag, and neither closes the window (the window
manager's close button and Alt+F4 are the way to quit).
Figure mode: `host.set_figure(&figure)` makes the loop lay the figure over the framebuffer,
create **one `Controller` per row**, route each pointer/wheel/key event to the row it lands
in (`controller_at(x, y)`), and draw with `Renderer::draw(Figure&)`. The host also spreads
in (`controller_at(x, y)`), and draw it with `Renderer::draw(Figure&)`. The host also spreads
one hover over every row (`sync_figure_crosshair`), since only the host knows which row the
pointer is in.
Routing has two rules worth knowing from the outside:
* **One gesture at a time (button-aware capture).** A press captures the row it landed
in *and the button that pressed*, and every motion and the release of *that button* go
to the captured row until it comes up — so a drag that crosses into a neighbouring row
still ends where it began. A second button pressed meanwhile (a chord) neither steals
the capture to another row nor ends it; only the owning button's release ends it. What
the second button's events do inside the controller is the controller's business; the
host guarantees they all arrive at the row the first press claimed.
* **Row rects are cached, refreshed once per frame.** The events never call
`Figure::layout()` — a non-const `layout()` recomputes, and a burst of pointer moves
would re-lay every row out per move. The run loop calls `refresh_row_rects()` once per
frame (right after the figure's viewport is set), `set_figure()` fills the cache
immediately so a scripted run routes against the figure it was just handed, and every
handler reads the cache. The consequence across a resize: GLFW delivers the resize and
any events queued behind it in one batch, so events before the resize see the rows the
previous frame drew — at most one frame stale — and the loop's next refresh brings the
cache onto the new geometry. A driver that injects events without running the loop
calls `refresh_row_rects()` itself; it is the scripted equivalent of the frame.
`run()` leaves the window open on success so a producer thread can be joined before the GL
resources go away; `close()` (or the destructor) releases the renderer, context and window
on the calling thread. A run with `PLOT_DEMO_EXIT_AFTER_MS` set creates the window hidden.
@@ -131,7 +196,11 @@ Because ImGui reports button *levels* and the controller wants *edges*, the widg
remembers the previous frame's buttons and synthesises `pressed`/`released`; a button
pressed over the child keeps being delivered until every button is up, so a drag that
leaves the region still ends. ImGui's `(-FLT_MAX, -FLT_MAX)` "no pointer" sentinel is
treated as no position update (no movement, buttons still flow).
treated as no position update (no movement, buttons still flow). Every pointer event is
stamped with `ImGui::GetTime()`, which is what makes the controller's double-click
(re-fit) exist on this host; and every key the widget maps is asked with
`IsKeyPressed(..., repeat = true)`, because ImGui does not auto-repeat on its own —
the shared "a held key repeats" policy is opt-in here (see the conventions above).
Coordinate assumption: the points→pixels mapping is the whole framebuffer's only when the
host's GL viewport is the whole framebuffer at origin `(0,0)` and the bound target is the
@@ -168,12 +237,22 @@ viewport on the controller, runs the frame callback, sets `ui_scale` to
Input translation: Qt reports press/release edges directly, so `mousePressEvent` /
`mouseMoveEvent` / `mouseReleaseEvent` / `mouseDoubleClickEvent` / `wheelEvent` /
`keyPressEvent` become `PointerEvent`/`WheelEvent`/`KeyEvent`. Every position is multiplied
by `devicePixelRatioF()`; the *anchor* a move's delta is measured from is kept in logical
`keyPressEvent` become `PointerEvent`/`WheelEvent`/`KeyEvent`, each stamped with
`QInputEvent::timestamp()` in seconds. Every position is multiplied by
`devicePixelRatioF()`; the *anchor* a move's delta is measured from is kept in logical
pixels, so a drag crossing onto a display with another scale factor pans by the distance
moved rather than a cross-scale jump (`PointerAnchor`, `qt_pointer.hpp`). Qt's
`angleDelta()` is in eighths of a degree (120 per notch) and is converted; a wheel with no
vertical component is refused before it reaches the controller.
moved rather than a cross-scale jump (`PointerAnchor`, `qt_pointer.hpp`). The wheel is
converted from eighths of a degree, 120 to a notch; when a device reports
`pixelDelta()` instead (and the angle deltas are both zero), 40 scroll pixels are taken
to be one notch — a calibration, not a conversion, the same role the GTK adapter's
`kSurfacePixelsPerNotch` plays. A wheel event with neither a vertical nor a horizontal
component is refused before it reaches the controller; anything with one is forwarded,
and the controller pans x by the horizontal component. The widget also grabs
`Qt::PinchGesture`: each pinch update is folded by `PinchTracker` (`qt_pointer.hpp`)
into an incremental `GestureEvent` — this frame's zoom about the focal point, the focal
point's movement as the pan — and a `QEvent::DevicePixelRatioChange` refreshes the
controller's viewport (and, one frame later at the latest, the `ui_scale`) before any
event queued behind it.
Include-order note: this header includes `plot/gl/api.hpp` before `<QOpenGLWidget>`,
because Qt reaches `<GL/gl.h>` whose macros would rewrite `plot::gl`'s enumerators. A
@@ -202,10 +281,16 @@ and the device size GTK reports, and refreshes the viewport; `::render` sets the
the controller, runs the frame callback, sets `ui_scale` to the mapping's factor, and draws.
Input translation uses GTK's gesture recognisers: `GtkGestureDrag` supplies motion,
`GtkGestureClick` supplies the press/release edges, `GtkEventControllerMotion` supplies
`GtkGestureClick` supplies the press/release edges (stamped with the GDK event's time,
in seconds, like every pointer event this adapter forwards), `GtkGestureZoom` supplies
the two-finger pinch — each `scale-changed` folded by `ZoomAnchor` (`gtk_input.hpp`)
into an incremental `GestureEvent`: this frame's zoom about the fingers' bounding-box
centre, the centre's movement as the pan — `GtkEventControllerMotion` supplies
hover (forwarded with `down == false`, so a drag is not applied twice), and
`GtkEventControllerScroll` (vertical) and `GtkEventControllerKey` supply the wheel and the
`+`/`-`/`f` keys. GTK emits `drag-begin` before the click's `pressed`, so the adapter opens
`GtkEventControllerScroll` (vertical) and `GtkEventControllerKey` supply the wheel and
the keys (`+`/`-`/`f`, the arrows, Escape — the full shared key set, mapped in
`gtk_input.hpp`'s `key_from()` where the headless tests drive it). GTK emits
`drag-begin` before the click's `pressed`, so the adapter opens
its gesture from the first press and closes it from the first release. The area queues a
render after every input event that can change the picture (not on hover).
+26 -2
View File
@@ -51,6 +51,29 @@ directory as its public interface and nothing else. The exported adapters are na
`plot::imgui` is a build-tree target only, because ImGui is supplied by `FetchContent` and
there is no ImGui package for an installed config file to point at.
The version you ask for follows the package's own compatibility rule, **SameMinorVersion**:
`find_package(plot 0.1 REQUIRED)` accepts 0.1.x and refuses 0.2+, because at 0.x the minor
component is what moves when the surface breaks. What the surface *is* — the version this
header set was generated from — is in `<plot/version.hpp>`, installed with the other public
headers: `plot::version::major`, `::minor`, `::patch` and `::string` (plus the
`PLOT_VERSION_*` macros) for a compile-time answer, no `pkg-config --modversion` round-trip
needed:
```cpp
#include <plot/version.hpp>
static_assert(plot::version::major == 0, "plot 0.x only");
```
A build that is not CMake can use the installed pkg-config file instead:
```sh
pkg-config --cflags --libs plot # -I<includedir> -L<libdir> -lplot_core
```
Both grants travel with the install: `LICENSE` (MIT) and the embedded font's
`licences/OFL-1.1.txt` (SIL OFL 1.1) are under `share/licenses/plot/` in the prefix.
Every public component is reachable through the umbrella header:
```cpp
@@ -68,10 +91,11 @@ and draws one trace. It uses the GLFW toolkit for the window and `plot::core` di
it does not use the shipped `plot::glfw::Host` adapter, so it also shows the pieces the
adapter hides (window, context, loader, renderer, controller, frame loop).
It was compiled and linked as part of writing this page:
It was compiled and linked as part of writing this page (`-I build/debug/include` is where
CMake generates `plot/version.hpp`, which the umbrella pulls in):
```sh
g++ -std=c++20 -I include main.cpp build/debug/libplot_core.a \
g++ -std=c++20 -I include -I build/debug/include main.cpp build/debug/libplot_core.a \
$(pkg-config --libs glfw3) -ldl -lpthread -o plot_quickstart
```
+44 -3
View File
@@ -106,6 +106,7 @@ one per plot.
- `set_crosshair_readout(bool)` switches the panel off — or back on — without moving the
crosshair. `set_crosshair_style(const CrosshairStyle&)` overrides this plot's stroke and
dash.
- `set_crosshair_value_mode(CrosshairValue)` chooses what a readout *is* — see below.
In a `Figure`, the crosshair is applied to **every row** (one shared `x`): each row's readout
lists its own traces at that instant. `Figure::set_crosshair(x, active)` is vertical-only
@@ -113,9 +114,39 @@ across the figure, and `Figure::set_crosshair(x, hovered_row, y_in_row, active)`
horizontal line **only in the row the pointer is in** — a figure's rows share x and not y, so
a figure-wide horizontal line would be a value that means something different in each row.
Readout values are **interpolated**, not interval statistics (see
[data-and-performance.md](data-and-performance.md)); a row whose trace has no sample at the
crosshair's x is listed with the em dash `"—"` and no marker, rather than a fabricated value.
### What a readout is: the two value modes
`Crosshair::value_mode` (`set_crosshair_value_mode()`) answers two different questions, and
which one a caller is asking decides the mode:
| mode | the value | the marker | answers |
|---|---|---|---|
| `CrosshairValue::NearestSample` (default) | the nearer bracketing sample's y (a tie reads the earlier) | **snapped to that sample's own x** | *which datum is under the pointer?* |
| `CrosshairValue::Interpolated` | a linear blend of the two bracketing samples | at the crosshair's own x | *what is the signal at this instant?* |
The default is the sample, because a readout that quotes a number the data does not contain is
a second estimate laid over the first — and once the view is zoomed past the sample spacing the
polyline between two samples is a line the *renderer* drew, so reading a value off it reports
the drawing rather than the signal. A host whose x is a continuous quantity it is willing to
interpolate (a smooth analytic curve, a resampled series) states `Interpolated` and gets the
blend, exactly at the pointer. Either way the value is a **stored** sample or a blend of two of
them, never an LOD bucket's aggregate (see
[data-and-performance.md](data-and-performance.md)).
`CrosshairRow` carries both halves: `x` and `y` are the data coordinate the value belongs to —
the quoted sample's own in `NearestSample`, the crosshair's x in `Interpolated` — and `marker`
is that point in screen space. `Plot::crosshair_point(trace, x, out_x, out_y)` is the same
lookup for a host that draws instruments of its own; `Plot::crosshair_value()` is it with the x
discarded.
The mark is drawn `Style::crosshair_marker_px` across (9 logical px) at
`crosshair_marker_width`, both deliberately **larger than a trace's own sample dots**
(`marker_px`, 3): in `NearestSample` the mark lands *among* those dots, and at the same size it
would read as one more sample instead of as the one the panel is quoting. `0` across draws no
mark.
A row whose trace has no sample at the crosshair's x is listed with the em dash `"—"` and no
marker, rather than a fabricated value.
## The gesture table (exactly as implemented)
@@ -161,6 +192,16 @@ without knowing what a pointer is. `hovered_cursor()`, `grabbed_cursor()`, `hove
`grabbed_region()` and `region_grab(u32)` expose the same answers to a host (a resize cursor, a
tooltip, a status line).
The frame an open box zoom is drawing is published the same way and for the same reason
(`Plot::set_selection_rect()`), and the plot still learns nothing about pointers: the host reads the
rect off the controller -- `Controller::selection_preview()` -- on every frame of the drag and takes it
off the plot when the gesture ends. The preview is deliberately not what the pointer swept. Shift
frames both axes and its preview is the swept box; Ctrl frames x only, so its preview is widened to the
full height of the axes, and Alt's to the full width. The swept sliver is the *gesture*; the widened
rect is what the *release* will apply, which is the one worth showing while it is still only a
gesture. Nothing is previewed until the drag has swept every axis the release will act on: an empty
rect widened is a band the width of the plot, which would claim a range that does not exist yet.
## Live follow
An x axis has a follow state, `XFollow::Off` (the default) or `XFollow::Live`
+32 -3
View File
@@ -36,12 +36,14 @@ Colours (neither logical nor device — nothing to scale):
| field | default | meaning |
|---|---|---|
| `background` | `0x101418` | frame clear colour and legend/readout panel base |
| `grid_major` | `0x2A3038` | major (labelled-tick) grid lines |
| `grid_minor` | `0x1C2126` | minor (subdivision) grid lines |
| `grid_major` | `0x39414C` | major (labelled-tick) grid lines |
| `grid_minor` | `0x1C2126` at 50% alpha | minor (subdivision) grid lines |
| `axis_line` | `0x8A94A6` | axis lines |
| `axis_text` | `0xC8D0DC` | tick labels and axis titles |
| `cursor` | `0xFFB020` | cursor markers; also the hovered/grab highlight |
| `measure_fill` | `0xFFB020` at α 0.15 | the measurement band |
| `invalid_fill` | `0x8A94A6` at α 0.18 | a span the host declared invalid (`Plot::set_invalid_spans()`) |
| `invalid_repaired_fill` | `0x8A94A6` at α 0.08 | the same span once the host has marked it repaired |
| `crosshair` | `0xE6EBF2` | crosshair lines and the readout panel's border (hue-neutral by design; trace markers keep each trace's own colour) |
The scale:
@@ -56,8 +58,12 @@ Logical metrics (scaled by `device()`):
|---|---|---|
| `axis_font_px` | `12.0` | logical em the axis text is drawn at |
| `grid_label_pad` | `4.0` | gap between a tick label and the axes, and the inset inside a title strip |
| `marker_below_samples` | `50` | a dot per raw sample when the window holds at most this many (`0` = never) |
| `marker_px` | `3.0` | logical size of that dot |
| `grid_major_width` | `1.0` | width of a major grid line |
| `grid_minor_width` | `1.0` | width of a minor grid line |
| `grid_minor_width` | `0.75` | width of a minor grid line (sub-pixel on purpose) |
| `grid_minor_dash_on` | `3.0` | minor grid dash: ink length in logical pixels |
| `grid_minor_dash_off` | `3.0` | minor grid dash: gap; `0` draws the minor grid solid |
| `grid_major_visible` | `true` | stage the major grid at all |
| `grid_minor_visible` | `true` | stage the minor grid at all |
| `line_width_px` | `1.5` | width of the strokes framing the data (axis lines, cursor markers) |
@@ -91,6 +97,29 @@ transparent. `Grid` therefore carries both classes' geometry whatever the style
caller that draws the grid itself applies the same two switches. `Grid::vertical` and
`Grid::horizontal` (both `true`) control which directions emit lines at all.
The labelled class is the **structure** and the subdivisions are **texture**, and the minor
grid's three defaults are what makes that read: it is thinner than the major hairline
(`grid_minor_width`, a sub-pixel width that at 1x is expressed as coverage and on a HiDPI
display as a narrower line), its colour carries an **alpha** so it blends into the
background instead of sitting on it, and it is **dashed** (`grid_minor_dash_on`/`_off`) so an
unlabelled line at an unlabelled position is never mistaken for a labelled one. Together
those put a minor line at an order of magnitude less ink than a major one — at full opacity and the
same width a dense minor grid reads as a second set of ticks. The dash is a fragment-shader
discard, not extra geometry: the vertices are the line either way. Set
`grid_minor_dash_off = 0` for the old solid subdivision.
## A cursor's stroke
`set_cursor_color()` gives a cursor its own colour and `set_cursor_stroke()` its own width and
dash (`Cursor::width`, `Cursor::dash`). Per cursor rather than per plot, because a host uses
cursors for two different jobs: the instruments that frame a reading — a marker at an edge, a pull
handle — and the *readout marks* a computation places on the data (a measured level, a peak, a
pulse's edges). Those want different weights. `width <= 0` means the style's default for that
cursor (`handle_width_px` while it is hovered or grabbed, `line_width_px` otherwise), and a hover
can only make the stroke *thicker*: the cue that the pointer has hold of something must not be the
thing that hides it. `dash.off <= 0` (the default) is solid — the same convention as a trace's,
the crosshair's and the grid's.
## The crosshair's dash
`crosshair_dash_on`/`crosshair_dash_off` are the fragment shader's discard pattern, so no
@@ -0,0 +1,305 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
# Hardening and features v0.2 — shared contracts
Design record for the bug-fix + robustness + feature wave. **This file is the
interface bible for the five parallel streams.** File ownership is exclusive:
a stream edits only the files listed under it. Everything not listed is the
integrator's.
## Bug list (B1–B7) and robustness list (R1–R9) being fixed
- **B1** f32 staging of large-magnitude x destroys geometry (epoch axes).
- **B2** tick major/minor classification ULP-fragile (`ticks.cpp:176`).
- **B3** LOD gather emits buckets straddling `[begin,end)` (`series.cpp:168`).
- **B4** `XFollow::Live` on log x holds linear span (`plot.cpp:494`, `figure.cpp`).
- **B5** zero-notch wheel disarms auto_scale; `factor==inf` unguarded
(`controller.cpp:158`, `glfw` forwards dy=0).
- **B6** single text flush paints legend text over the crosshair readout panel.
- **B7** NaN policy incoherent (min/max drop unless seeding; sum propagates).
- **R1** `std::clamp` UB on negative viewport (`plot.cpp:886`).
- **R2** `set_x_range`/`set_y_range` store non-finite verbatim (`plot.cpp:377-384`).
- **R3** `kBucket << level` UB for level >= 58 via public `gather`.
- **R4** ring capacity rounds up but docs/reserve assume raw capacity.
- **R5** GLFW figure capture cleared by any button release.
- **R6** renderer residue: blend func, UNPACK_ALIGNMENT, no GL shutdown,
epoch() wrap, annotation caption overflow.
- **R7** GLFW host re-lays out the figure on every event.
- **R8** measure accepts horizontal rulers, silently measures [0,0].
- **R9** minor-tick cap truncates the far end of the axis, majors included.
## Cross-cutting decisions
- **NaN policy (library-wide, documented):** a non-finite sample is *excluded*
from every aggregate (min, max, sum, sumsq) and from cached bounds. A window
with no finite samples aggregates to a zero-span Range / zero sums. Applies
in `MinMaxPyramid::append`, `Series::reduce/sum/sumsq` edge folds,
`ArraySeries::commit`, `StreamSeries::acquire` bounds. `docs/data-and-performance.md`
gets a "Non-finite samples" paragraph.
- **Gather x contract (breaking, B1):** `Series::gather` now emits x
**relative to `x0()`** (exactly `i*dx` for uniform series, `x_at(i)-x0()` for
XY), never absolute x. y is unchanged. The renderer reconstructs absolute
data x in f64 (`x0() + rel`) before transforming, so no precision is lost at
the f32 narrowing: the staged f32 is `to_space(x_abs) - to_space(origin)`,
which is O(visible pixels), not O(epoch).
- **Live follow on log axes (B4):** `Axis::offset` and the live-window span
live in **axis space** (`log10` when `log`), matching how the controller's
gestures already work (`to_space`/`from_space`). `place_live_x_window` and
the figure link-group copies hold the log-space span constant.
- **Key-repeat policy (library-wide):** a held key repeats in every adapter
(GLFW/Qt/GTK already do; ImGui switches to `repeat=true`).
- **Modifiers for box zoom:** Shift+Left = both axes (unchanged, default);
Ctrl+Left = x-only; Alt+Left = y-only. Other modifier combos = both.
## Shared-header changes (made by the integrator before the fan-out)
- `include/plot/input/events.hpp`
- `PointerEvent` gains `f64 time_s = 0.0;` (host-supplied monotonic seconds;
powers double-click). Hosts that leave 0 simply never double-click.
- New `GestureEvent { f32 x, y; f32 scale = 1.0f; f32 dx = 0.0f, dy = 0.0f; }`
— pinch: zoom about (x, y) by `scale`, pan by (dx, dy). `Controller`
gains `void on_gesture(const GestureEvent&)`.
- New key constants: `kKeyEscape = 27`, `kKeyLeft = 256`, `kKeyRight = 257`,
`kKeyUp = 258`, `kKeyDown = 259` (host-translated, like the ASCII ones).
- `include/plot/data/series.hpp`
- `enum class GatherMode { Envelope, Nearest, Mean };` — downsample policy.
`Envelope` = today's min/max pairs. `Nearest` = one raw sample per bucket
(via `snap`), x at the sample. `Mean` = one point per bucket, y = mean
(bucket `sum` / count), x at bucket centre.
- `virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
GatherMode mode = GatherMode::Envelope) const` — signature change; every
override updates; renderer passes the mode explicitly.
- New virtuals (uniform default, XY override):
`virtual f64 x_at(u64 i) const` (`x0() + i*dx()`),
`virtual bool neighbours(f64 x, u64& i0, u64& i1) const` (false when x is
outside `[x_at(0), x_at(n-1)]`; else the bracketing indices),
`virtual u64 snap_index(f64 x) const` (nearest index, clamped).
- `MinMaxPyramid` gains `f64 bucket_sum(u32 l, u64 i) const` and
`f64 bucket_sumsq(u32 l, u64 i) const` (Mean/RMS need them).
- `include/plot/plot/trace.hpp` — `Trace`/`TraceDesc` gain (trailing, defaults
keep aggregate init source-compatible):
`GatherMode mode = GatherMode::Envelope;`
`f32 dash_on = 0.0f, dash_off = 0.0f;` (logical px, 0 = solid),
`enum class Marker { None, Cross, Circle, Square }`, `Marker marker = Marker::None;`
`f32 marker_size_px = 3.0f; u32 marker_every = 0;` (0 = auto: stride
`ceil(samples_per_pixel)`, so markers only appear where samples are sparse).
`width == 0` draws markers only (scatter) — documented.
- `include/plot/render/style.hpp` — gains `f32 line_aa_px = 1.0f;` (logical,
the AA fringe half-width, clamped >= 0.25f at use). No text-colour fields:
text colour becomes a per-call parameter (see C).
- `include/plot/plot/plot.hpp` — `set_viewport` normalises negative w/h to 0 (R1).
- ~~`include/plot/plot/crosshair.hpp` — `CrosshairRow` gains `Color color`~~
DROPPED: `CrosshairRow` already names its trace; the renderer resolves the
colour as `plot.trace(row.trace).color` at draw time. No field added.
## Stream A — data layer (exclusive)
Files: `include/plot/data/*`, `src/data/*`, `tests/test_series.cpp`,
`tests/test_ring_buffer.cpp`, `tests/test_minmax_pyramid.cpp`,
`tests/test_xy_series.cpp` (new), plus the `Series::gather` override
forwarders and `docs/data-and-performance.md` NaN/policy paragraphs.
1. **B3**: bucket-edge selection uses `first = max(ceil_div(origin+b, span), lbase)`,
`last = min((origin+e)/span, lend)`; lead/trail raw spans cover the rest.
Regression: gather at unaligned `begin`/`end` mid-window asserts no emitted
x outside `[begin, end)` and envelope values equal exact reduce over the range.
2. **B7**: NaN policy per above; regression: NaN at first/middle/last position
— bounds and reduce stay finite, sum skips NaN.
3. **R3**: `level_span` caps at level 57 (span 2^63); `gather(level > 57)`
clamps; no UB/div-zero for any u32 level.
4. **R4**: docs say "at most `capacity()` rounded up to a power of two";
`StreamSeries` reserves the *rounded* `ring_.capacity()`.
5. **GatherMode** implementation in `gather_impl` (Mean/Nearest via the new
pyramid accessors); `XY`/`Array`/`Stream` forwarders updated; relative-x
contract implemented everywhere (x emitted as `x_at(i) - x0()`).
6. **XY series** (full feature): `include/plot/data/xy_series.hpp`,
`src/data/xy_series.cpp`, target `plot_core`. Sorted-ascending x
(documented precondition, `assert` in debug); binary-search
`neighbours`/`snap_index`; pyramid over sample index; `commit()` rebuilds.
7. **Snapshot**: `ArraySeries StreamSeries::freeze() const` — consumer-thread
only; copies the retained window (`copy_raw`) into a fresh ArraySeries with
this window's `x0()`/`dx()`. Freezes against the *last acquire* (documented).
8. **Incremental append**: `void ArraySeries::push(f32)` — appends, folds into
the pyramid (`append`), extends bounds incrementally (NaN-aware), extends
`x_bounds_`. `commit()` stays the wholesale path.
Acceptance: new tests for every item above; `plot_tests` builds and passes.
## Stream B — plot + input (exclusive)
Files: `src/plot/ticks.cpp`, `src/plot/plot.cpp`, `src/plot/figure.cpp`,
`src/input/controller.cpp`, `include/plot/input/controller.hpp`,
`include/plot/plot/axis.hpp` (offset doc), `tests/test_ticks.cpp`,
`tests/test_controller.cpp`, `tests/test_plot.cpp`, `tests/test_figure.cpp`,
`tests/test_measure.cpp`, and the plot-layer consumers of the new `Series`
virtuals (`crosshair_value`, `crosshair_span`, `covered_samples`,
`measurement` — route through `x_at`/`neighbours`/`snap_index` so an XY series
crosshairs and measures correctly).
1. **B2**: major = integer-index test (`k % ratio == 0` on the minor index,
`k` derived from `(v - first_major)/minor_step` rounded); no float tolerance.
Regression: epoch range 1.75e9 ± 300 s at 800 px produces labelled majors.
2. **R9**: majors are never truncated; only minors count against `kMaxTicks`.
3. **B4**: live window placement in axis space (log-aware span/offset) in
`Plot::place_live_x_window`, `Plot::set_x_range` live branch, and the two
figure link-group copies. Regression: streaming log axis holds visible
decades constant across samples; scrolled-back window keeps span.
4. **B5**: `on_wheel` — `dy == 0 && dx == 0` returns; `dy == 0, dx != 0` pans x
only (`dx` notches, 5% of the x span per notch, log-aware via `to_space`);
zoom path rejects `factor == 1` and non-finite. Same guard in the
middle-drag `axis_zoom`.
5. **R1**: `layout()` clamp guards `hi >= lo` before clamping (no UB even for a
hostile viewport; `set_viewport` normalises, already done by integrator).
6. **R2**: range setters refuse non-finite endpoints (Off branch + y), keep the
previous range and `auto_scale`.
7. **R8**: `add_measure`/`measurement` refuse horizontal-ruler (and
vertical-ruler dead-field) cursors — `valid` stays false, `add_measure`
returns an id that reports invalid.
8. **Interaction features**:
- Keyboard pan: Left/Right/Up/Down pan 10% of the span per press/repeat
(x-only for Left/Right, y-only for Up/Down; log-aware).
- Escape (`kKeyEscape`): cancels the active drag — restores the ranges the
axes had at `begin_drag` (captured then), drops box-zoom without applying,
clears grab state.
- Box-zoom masks per the modifier table above; applied per-axis at release.
- Double-click reset: two presses of the same button within 0.35 s and 4 px
(uses `PointerEvent::time_s`) re-fits every axis (`auto_scale = true` +
fit). Second press does not begin a drag.
Acceptance: controller tests for each gesture incl. cancel-restore; tick
regressions; XY crosshair/measure unit tests via an `XY` fixture.
## Stream C — render (exclusive)
Files: `src/render/*`, `include/plot/render/*` (renderer/primitives/text/buffer
interfaces), `src/gl/handle.cpp`, `include/plot/gl/handle.hpp`,
`tests/test_render_smoke.cpp`, `tests/test_primitives.cpp`.
1. **B1**: implement the relative-x staging contract (see Cross-cutting):
trace staging reconstructs `x_abs = series.x0() + rel` in f64, stages
`f32(to_space(x_abs) - to_space(origin))`; every data-space line draw
(grid, cursors, regions, measures, crosshair, annotations) picks a natural
origin (its own reference data x/y) and `mapping()` folds
`origin` into the offset uniform in f64 before the single narrowing.
Regression: EGL smoke renders an epoch-window series and a zero-origin
twin and asserts both rasterise to the same pixel column set (tolerance).
2. **B6**: two text batches — `text_under_` (legend names, cursor labels,
measure labels, annotation captions) flushed *immediately before*
`draw_crosshair_readout`; `text_over_` (readout text, tick labels, titles)
flushed at `end_frame`. The readout panel occludes under-batch text.
Regression: legend + overlapping readout frame — legend name pixels are not
lit inside the readout panel rect.
3. **R6**: `begin_frame` saves `GL_BLEND_SRC/_DST` via `glGetIntegerv` and
`end_frame` restores function + enable bit; `Texture::upload_rgba`
saves/restores `GL_UNPACK_ALIGNMENT`; `Renderer::release_gl()` (public)
destroys all GL handles explicitly, called first by the destructor (safe
when the context is current) and documented for host teardown order;
`epoch()` widened to u64 end-to-end (buffer generation + slot epoch +
VAO cached epoch); annotation captions clamp their box inside `axes`
(skip the caption when it cannot fit).
4. **Dash**: trace dash reaches `u_dash` per trace flush (device px, logical
converted); legend swatch samples the dash. `dash_off == 0` = solid.
5. **Markers**: staged as line segments through the existing batch
(Cross 2 segs, Circle 8-gon, Square 4 segs), colour = trace colour, size =
`device(marker_size_px)`, stride `marker_every` or auto `ceil(spp)`;
`width == 0` draws markers only. Render-smoke regression: a marker'd trace
lights vertices a plain line does not, and a `marker_every = 3` trace
stages 1/3 the marker count.
6. **AA knob**: `u_aa` uniform = `max(0.25, device(style.line_aa_px))`; shaders
drop the hardcoded fringe. Default output unchanged (1.0 logical).
7. **Text colour**: per-vertex `a_colour` (vec4); `stage_text(..., Color)`;
`flush_text` stops setting `u_color`; crosshair readout value rows draw in
their row's trace colour (`CrosshairRow::color`), labels in `axis_text`.
Smoke regression: a coloured readout row differs from the axis-text pass.
8. **GatherMode**: renderer passes `trace.mode` to `gather`.
Acceptance: smoke tests above; full suite green; no uniform/attribute drift
(cross-check against GLSL in the same PR).
## Stream D — adapters (exclusive)
Files: `adapters/**` only, `tests/test_glfw_host.cpp`, `tests/test_qt_pointer.cpp`,
`tests/test_gtk_input.cpp`, `tests/test_imgui_widget.cpp`,
`docs/embedding.md` (shared conventions: key repeat, touch parity, DPR note).
1. **R5**: GLFW figure capture records the button; only the owning button's
release ends it; a second button does not steal or clear the capture
(documented single-gesture policy). Test: press left row0, press right row1,
release right — left drag still routes to row0.
2. **R7**: `Host` caches row rects; invalidated on resize + `set_figure`,
refreshed once per frame; event handlers read the cache (one-frame-stale
rows during resize — documented).
3. Key repeat: ImGui `IsKeyPressed(..., repeat=true)`; policy comment.
4. **Timestamps**: all four adapters fill `PointerEvent::time_s`
(glfwGetTime / QEvent timestamps /1000 / gdk time /1000 / ImGui::GetTime).
5. **New keys**: GLFW maps arrows + Escape to the new constants (with
`GLFW_KEY_LEFT` etc.); Qt maps `Key_Left...`; GTK maps `GDK_KEY_Left...`;
ImGui `ImGuiKey_LeftArrow...`, `ImGuiKey_Escape`.
6. **Qt**: `wheelEvent` uses `pixelDelta()/40` notches when `angleDelta` is 0
(pixelDelta path); `DevicePixelRatioChange` refreshes the controller
viewport + scales; pinch via `grabGesture(Qt::PinchGesture)` ->
`GestureEvent`.
7. **GTK4**: `GtkGestureZoom` attached in the plot area factory ->
`GestureEvent` (scale + focal delta); scroll `ScrollUnit::Surface` path
already exists — keep.
8. **GLFW**: `on_scroll` forwards unchanged (controller owns the dy=0 case).
Acceptance: adapter-gated tests updated; toolkit-free seams (qt_pointer,
gtk_input) carry the new logic so headless tests cover it.
## Stream E — build, packaging, docs, CI (exclusive)
Files: root `CMakeLists.txt`, `cmake/*`, `CMakePresets.json`,
`.github/workflows/ci.yml` (new), `plot.pc.in` (new),
`docs/building-and-testing.md`, `docs/getting-started.md`, `README.md`
(feature list + versioning), `tests/test_series.cpp` (delete the wall-clock
steady-state case only), `readme_main.cpp` (new, referenced by README).
1. `GNUInstallDirs` for every install path; `VERSION`/`SOVERSION` on
`plot_core` when shared; `SameMinorVersion` compatibility (0.x reality);
generated `include/plot/version.hpp` (`configure_file`, installed, added to
the umbrella + exemption handling as needed); `plot.pc` pkg-config file;
install `LICENSE` + `licences/OFL-1.1.txt` under
`${CMAKE_INSTALL_DATADIR}/licenses/plot/`; `install(DIRECTORY)` gets
`FILES_MATCHING PATTERN "*.hpp"`.
2. `PLOT_BUILD_TTF_FONT` moves to `cmake/plot_options.cmake` (docs match);
coverage-flag detection switches to a `PLOT_COVERAGE` option check on the
config profile, not a `CMAKE_CXX_FLAGS` string match.
3. CI: `.github/workflows/ci.yml` — ubuntu-latest, apt deps (libglfw3-dev,
qt6-base-dev, libgtk-4-dev, libegl1, libglvnd-dev, xvfb, lcov,
python3-fonttools), jobs: debug (configure/build/xvfb ctest), coverage
(tools/coverage.sh, uploads the table), asan (linux-asan preset). `continue-on-error`
on the ImGui fetch is not needed (network is available on GH runners).
4. Docs: correct the case-count math (recount from the tree at integration
time), remove the stale hard-coded coverage figures from README and
building-and-testing (coverage.md stays the single source, regenerated),
document the new keys/gestures/touch parity in `interaction.md` +
`embedding.md` (note: B/C own feature *code*; E only documents what the
contract above specifies).
Acceptance: `cmake --install` smoke into a temp prefix finds `plot` via
`find_package` in a throwaway consumer; umbrella tests stay green; suite
counts match docs.
## Integration (integrator only)
Merge all streams; reconcile `include/plot/plot.hpp` umbrella; full
configure+build+ctest; regenerate coverage via `tools/coverage.sh`; update the
per-file coverage table and README numbers from the fresh run; run the GLFW
demo headless under Xvfb as the final smoke; update this design record's
status.
## Status: COMPLETE (2026-09-30)
All five streams landed and were integrated on `main` (uncommitted working
tree). `Controller::on_gesture` was implemented by the integrator (Stream B was
cut off before it). Incident: the root filesystem hit 100% mid-wave and
truncated `src/render/renderer.cpp` to 0 bytes; Stream C restored it from its
transcript and re-verified. Final state:
- `linux-debug` suite: **350 cases, 0 failed** (was 283); 4/4 ctest entries pass.
- Coverage: **93.9% aggregate (3730/3971) over 24 files**, every file >= 80%
(floor 80/90) — regenerated in `docs/coverage.md` by `tools/coverage.sh`.
- Headless smoke: `plot_glfw_demo` + `plot_multiplot_demo` under `xvfb-run`,
exit 0, screenshots contain rendered ink (65.4% / 20.0% non-background at
1280x720); the multiplot run reports **20,901 staged GPU segments for
~4.5M visible samples, GL error 0** (README paragraph updated).
- Known follow-ups: real touch hardware has never exercised the Qt/GTK pinch
paths; macOS/Windows remain untested; CI YAML is written but has run only
locally in structure (no GitHub remote execution during this wave).
+11 -1
View File
@@ -22,6 +22,10 @@ class ArraySeries final : public Series {
void reset(std::vector<f32> y, f64 x0, f64 dx);
/// Rebuilds the pyramid and cached bounds from y_ as it stands now.
void commit();
/// Appends one sample incrementally: the pyramid folds it (`append`, amortised
/// O(1)) and the cached bounds extend in O(1), NaN-aware, instead of commit()
/// rebuilding the whole store. The wholesale path stays `commit()`.
void push(f32 y);
u64 size() const override;
f64 x0() const override;
@@ -29,7 +33,8 @@ class ArraySeries final : public Series {
Range x_bounds() const override;
Range y_bounds() const override;
const MinMaxPyramid& pyramid() const override;
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const override;
void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode = GatherMode::Envelope) const override;
protected:
void copy_raw(u64 begin, u64 end, f32* out) const override;
@@ -41,6 +46,11 @@ class ArraySeries final : public Series {
MinMaxPyramid pyramid_;
Range x_bounds_{};
Range y_bounds_{};
/// False until a finite sample has been seen: `y_bounds_` is then a
/// placeholder (zero-span), not an aggregate, and the next finite sample
/// seeds it. This is what makes push()'s incremental extension and commit()'s
/// rebuild agree on a window that holds non-finite samples.
bool y_has_finite_ = false;
};
} // namespace plot::data
+49 -11
View File
@@ -19,6 +19,12 @@ struct MinMax {
/// series and the level maths is a shift.
inline constexpr u32 kBucket = 64;
/// Deepest pyramid level. `kBucket << 57` is `2^63`, the widest bucket span a
/// u64 sample count can name; past it the shift would overflow (UB), so every
/// level computation clamps here and a `gather()` asked for a deeper level
/// simply gets this one (whose buckets are already wider than any window).
inline constexpr u32 kMaxPyramidLevel = 57;
/// Incremental min/max LOD pyramid, indexed in absolute bucket space.
///
/// Level `l` bucket `k` covers the absolute samples
@@ -37,14 +43,14 @@ inline constexpr u32 kBucket = 64;
/// `drop_before(u)` slides the window's leading edge to absolute sample `u`: each
/// level discards the buckets whose span starts before `u`, so the memory is
/// bounded by the retained window and not by the number of samples ever appended.
/// A bucket entry is 24 bytes -- `MinMax` plus the exact `sum`/`sumsq` the exact
/// range queries fold over -- so level 0 costs 0.375n bytes and the geometric tail
/// brings the whole pyramid to about 0.75n bytes, ~19% of the 4n bytes of raw
/// samples. (A min/max-only index would be 0.25n, ~6%: the two doubles are the
/// price of answering mean/RMS/integral from whole buckets instead of raw
/// samples.) Every stored entry is the complete aggregate of its own span and
/// every stored span lies inside the retained window, so no aggregate is ever
/// recomputed or invalidated.
/// A bucket entry is 32 bytes -- `MinMax` plus the exact `sum`/`sumsq` the exact
/// range queries fold over and the finite-sample `count` the mean folds by -- so
/// level 0 costs 0.5n bytes and the geometric tail brings the whole pyramid to
/// about 1n bytes, ~25% of the 4n bytes of raw samples. (A min/max-only index
/// would be 0.25n, ~6%: the extra words are the price of answering mean/RMS/
/// integral from whole buckets instead of raw samples.) Every stored entry is
/// the complete aggregate of its own span and every stored span lies inside the
/// retained window, so no aggregate is ever recomputed or invalidated.
///
/// Queries are in *window* coordinates: index 0 is the oldest retained sample,
/// `size()` is the retained sample count, and `window_base()` is the absolute
@@ -64,7 +70,25 @@ 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);
/// Folds a backfilled sample into every aggregate that covers absolute index
/// `absolute_sample`.
///
/// **Precondition: the sample at that index was appended non-finite.** That is what makes
/// this purely additive -- min/max widen, sum/sumsq accumulate and count rises by one, at
/// every covering level -- so no bucket is ever recomputed and the cost is O(levels)
/// regardless of how wide the repaired span is. Amending a *finite* sample would need the
/// old value removed from sum/sumsq and could not restore min/max at all, which is why
/// this is a one-way NaN-to-value operation and not a general write.
///
/// An index outside `[window_base(), window_end())` is a no-op: the window slides on the
/// consumer thread, so a caller cannot prove the slot is still held before asking.
void amend(u64 absolute_sample, f32 v);
/// Slides the retained window's leading edge to absolute sample index
/// `absolute_sample`, dropping every aggregate that covered a sample before it.
/// Monotonic, and a no-op only when `absolute_sample <= window_base()`: an empty
@@ -100,6 +124,15 @@ class MinMaxPyramid {
/// Aggregate of the absolute level-`l` bucket `i`. Precondition:
/// level_base(l) <= i < level_base(l) + level_size(l).
MinMax bucket(u32 l, u64 i) const;
/// The bucket's exact `sum`/`sumsq` folds -- what `GatherMode::Mean` needs to
/// average a bucket and what an RMS readout folds without touching raw
/// samples. Same precondition as `bucket()`.
f64 bucket_sum(u32 l, u64 i) const;
f64 bucket_sumsq(u32 l, u64 i) const;
/// How many *finite* samples the bucket aggregated: the divisor that makes
/// `bucket_sum()` a mean under the non-finite-exclusion policy, and 0 exactly
/// when the bucket holds no usable sample. Same precondition as `bucket()`.
u32 bucket_count(u32 l, u64 i) const;
/// Min/max over the whole level-0 buckets fully contained in the window range
/// [begin, end). Partial edge buckets are excluded, never approximated; an
@@ -119,13 +152,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 {
+37 -2
View File
@@ -12,8 +12,13 @@ namespace plot::data {
/// Single-producer / single-consumer ring of at most `capacity` elements.
///
/// The capacity is rounded up to a power of two so slot addressing is a mask
/// instead of a modulo. The producer writes a slot and then publishes the new
/// The capacity is rounded up to a power of two -- `capacity()` reports the
/// *rounded* value, not the requested one, and `size()` saturates there -- so
/// slot addressing is a mask instead of a modulo. Callers that need the
/// retained window's exact bound must read `capacity()`, never their own
/// constructor argument (see `StreamSeries`, which reserves the rounded value).
///
/// The producer writes a slot and then publishes the new
/// sample count with a release store; a consumer that observes that count with an
/// acquire load is guaranteed to see the slot contents written before it.
///
@@ -59,6 +64,24 @@ class RingBuffer {
for (u64 i = 0; i < n; ++i) push(vs[i]);
}
/// Producer thread only: rewrites the slot at absolute index `absolute`, without pushing.
/// Returns false when that index is not retained -- already overwritten, or not yet
/// produced -- in which case nothing is written.
///
/// This is for *backfill*: a slot pushed as a placeholder and corrected when the data that
/// belongs there arrives late. It is the same race class as `push()` -- one relaxed store
/// into a slot a consumer may be reading, and T is lock-free by the class's static_assert,
/// so a reader sees the old value or the new one and never a torn one. It deliberately does
/// not touch `produced_`: the count fixes which indices a snapshot reads, and an amend
/// changes a value inside that window rather than extending it.
bool amend(u64 absolute, T v) {
const u64 p = produced_.load(std::memory_order_relaxed);
if (absolute >= p) return false;
if (p > cap_ && absolute < p - cap_) return false;
buf_[static_cast<usize>(absolute & mask_)].store(v, std::memory_order_relaxed);
return true;
}
/// Consumer: retained element count, saturating at capacity().
u64 size() const noexcept {
const u64 p = produced_.load(std::memory_order_acquire);
@@ -104,6 +127,18 @@ class RingBuffer {
/// pushing).
void clear() noexcept { produced_.store(0, std::memory_order_relaxed); }
/// Consumer thread only: discards every element and resumes the absolute count
/// at `absolute`, so the next push() lands there (not safe while a producer is
/// pushing).
///
/// This is for a *recompute*: a ring whose contents are derived from another is
/// emptied and refilled from a given absolute origin, and it must come back
/// numbered as its source is. clear() would restart it at 0 and leave every
/// absolute index -- base(), produced(), copy_from() -- off by that origin.
void clear_to(u64 absolute) noexcept {
produced_.store(absolute, std::memory_order_relaxed);
}
private:
u64 cap_;
u64 mask_;
+70 -16
View File
@@ -9,6 +9,20 @@
namespace plot::data {
/// What geometry a gather level produces for its buckets. The LOD *level* is
/// chosen exactly as before; the mode decides what each bucket contributes.
enum class GatherMode {
/// One (min, max) envelope pair per bucket: the default, and what a dense
/// time-series trace wants -- the eye reads the extremes.
Envelope,
/// One raw sample per bucket (the pyramid's `snap` pick), x at the sample:
/// a thinner geometry for traces whose *shape* matters more than extremes.
Nearest,
/// One point per bucket, y = the bucket mean (`sum` / count), x at the
/// bucket centre: a smoothed trace for noisy data.
Mean,
};
/// A uniform-grid series of y samples: sample i lives at x = x0() + i*dx().
///
/// Every concrete series answers range queries exactly, and never by scanning the
@@ -43,21 +57,60 @@ class Series {
/// so the series that owns the sliding window is the one that answers.
virtual bool streaming() const noexcept { return false; }
/// Appends interleaved (x, y) pairs in data coordinates for level `level`.
/// Appends interleaved (x, y) pairs for level `level`.
///
/// Level 0 emits the raw samples of [begin, end). Level >= 1 emits one (min, max)
/// envelope pair per *complete* level bucket wholly inside the retained window,
/// bounded by the two partial buckets at the edges of the requested range — the
/// bucket the window starts inside and the bucket the newest samples are in —
/// which it emits as raw samples through copy_raw(). A bucket that is only partly
/// retained holds aggregates of samples the window no longer has and a partial
/// trailing bucket is not stored at all, so neither may be drawn as an envelope.
/// **x is relative to `x_origin`**: the emitted value is `x_at(i) - x_origin` as f32, never
/// the absolute coordinate. A consumer reconstructs the absolute x in f64
/// (`x_origin + emitted`) before mapping it to the screen; staging an absolute epoch-time x
/// as f32 would quantise it to a grid coarser than the pixel (f32 carries 24 bits -- 1 s at
/// 1.7e9). y is unchanged.
///
/// The emitted geometry therefore always spans [begin, end) exactly, for at most
/// two buckets of extra raw work (about two pixels at the level level_for()
/// picks), and never reaches outside the retained window. Pairs are appended to
/// out_xy, which is never cleared.
virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const = 0;
/// **The caller names the origin because only the caller knows the span the f32 has to be
/// exact against.** The origin was `x0()` -- the start of the series' *retained window* --
/// and a relative value's ULP is then a fraction of the whole window however little of it is
/// on screen: 1.9 us over 20 s of retained data, which at a view 10 us wide is more than a
/// hundred pixels between where a sample is *drawn* and the x a readout quotes for the same
/// sample (`x_at()`, exact f64). Passing the visible range's minimum instead makes the error
/// 2^-24 of the *visible span* -- a fixed 4e-5 of a pixel over a 700 px axis -- at every
/// zoom. A caller with no view in mind may pass `x0()` and get the old behaviour.
///
/// Level 0 emits the raw samples of [begin, end). Level >= 1 emits one
/// geometry element per *complete* level bucket wholly inside the retained
/// window, bounded by the two partial buckets at the edges of the requested
/// range -- the bucket the window starts inside and the bucket the newest
/// samples are in -- which it emits as raw samples through copy_raw(). A
/// bucket that is only partly retained holds aggregates of samples the
/// window no longer has and a partial trailing bucket is not stored at all,
/// so neither may be drawn from aggregates. `mode` picks what a bucket
/// contributes (see `GatherMode`); the edge raw samples are the same for
/// every mode. A bucket whose samples are all non-finite is empty, not
/// zero-valued (the library-wide NaN policy): Envelope and Mean emit nothing
/// for it, Nearest draws the raw centre sample. `level` is clamped to
/// `kMaxPyramidLevel`.
///
/// The emitted geometry therefore always spans [begin, end) exactly, for at
/// most two buckets of extra raw work (about two pixels at the level
/// level_for() picks), and never reaches outside the retained window -- nor
/// outside [begin, end). Pairs are appended to out_xy, which is never
/// cleared.
virtual void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode = GatherMode::Envelope) const = 0;
/// Absolute x of sample `i` (`x0() + i*dx()` on a uniform-grid series).
/// Clamping is the caller's business: out-of-range `i` is the caller's bug.
virtual f64 x_at(u64 i) const;
/// The samples bracketing data-space `x`: sets `i0` to the last index with
/// `x_at(i0) <= x` and `i1` to the first with `x_at(i1) >= x` (equal when x
/// lands exactly on a sample). False when x lies outside the series' x
/// bounds; `i0`/`i1` are then untouched. The crosshair and measurement
/// paths read coordinates only through this and `x_at()`, so a series with
/// its own x array brackets exactly.
virtual bool neighbours(f64 x, u64& i0, u64& i1) const;
/// Index of the sample nearest data-space `x`, clamped to `[0, size()-1]`;
/// 0 for an empty series.
virtual u64 snap_index(f64 x) const;
/// Exact min/max, sum and sum-of-squares over [begin, end).
/// Preconditions: begin < end, end <= size(); a degenerate range yields a
@@ -75,8 +128,9 @@ class Series {
virtual void copy_raw(u64 begin, u64 end, f32* out) const = 0;
/// The shared body of the concrete gather() overrides: raw samples at level 0,
/// bucket min/max envelope above it.
void gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const;
/// bucket geometry above it (per `mode`).
void gather_impl(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode) const;
private:
/// [begin, end) split into the bucket-aligned interior the pyramid can answer
@@ -97,7 +151,7 @@ class Series {
/// 0 is entirely this, and a level >= 1 gather uses it for the two partial edge
/// buckets a pyramid of complete buckets cannot answer for. Precondition:
/// begin < end, end <= size().
void gather_raw(u64 begin, u64 end, std::vector<f32>& out_xy) const;
void gather_raw(u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy) const;
};
} // namespace plot::data
+19 -1
View File
@@ -5,6 +5,7 @@
#include <vector>
#include <plot/core/types.hpp>
#include <plot/data/array_series.hpp>
#include <plot/data/minmax_pyramid.hpp>
#include <plot/data/ring_buffer.hpp>
#include <plot/data/series.hpp>
@@ -57,6 +58,11 @@ namespace plot::data {
/// class specifies.
class StreamSeries final : public Series {
public:
/// `capacity` is the sample count to retain; it is rounded up to a power of
/// two by the ring (see `RingBuffer`), and `size()` saturates at that rounded
/// `capacity()` -- a `StreamSeries(100)` retains up to 128 samples. The
/// pyramid's level table is reserved for the rounded capacity up front, so a
/// saturated stream never grows it.
explicit StreamSeries(u64 capacity, f64 x0, f64 dx);
/// Producer thread only.
@@ -68,6 +74,17 @@ class StreamSeries final : public Series {
/// Consumer thread only: snapshots the produced count and brings the pyramid
/// and bounds up to it.
void acquire();
/// Consumer thread only: copies the window published by the *last acquire()*
/// into a fresh `ArraySeries` with this window's `x0()`/`dx()`. The frozen
/// series never changes again, no matter what the producer pushes afterwards
/// -- it answers from its own copy, not from the ring -- which is what a
/// consumer that needs a stable window for longer than one read wants. (The
/// ring itself keeps overwriting; only the copy is frozen, so this costs one
/// window's worth of samples. It reads through `copy_raw()`, so like every
/// read it is a snapshot of *which* absolute indices to walk, not a guarantee
/// about slots a producer is overwriting -- call it on the consumer thread,
/// interleaved with `acquire()`, per the class note.)
ArraySeries freeze() const;
u64 size() const override;
f64 x0() const override;
@@ -75,7 +92,8 @@ class StreamSeries final : public Series {
Range x_bounds() const override;
Range y_bounds() const override;
const MinMaxPyramid& pyramid() const override;
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const override;
void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode = GatherMode::Envelope) const override;
/// Always true: this series' `x_bounds()` is the retained ring window, which
/// slides with every `acquire()` -- the property `XFollow::Live` follows.
bool streaming() const noexcept override { return true; }
+87
View File
@@ -0,0 +1,87 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <vector>
#include <plot/core/types.hpp>
#include <plot/data/minmax_pyramid.hpp>
#include <plot/data/series.hpp>
namespace plot::data {
/// Series over its own x array: sample i lives at `x()[i]`, and the samples are
/// sorted by ascending x. This is the store for data whose x is *not* on a
/// uniform grid -- scattered measurements, event times, a log-swept frequency
/// axis -- where `ArraySeries`' fixed `dx` cannot hold.
///
/// **Precondition: `x` is sorted ascending.** The O(log n) crosshair and
/// nearest-sample lookups are binary searches, which are only correct on sorted
/// input; the constructor and `reset()` assert sortedness in debug builds and
/// the behaviour with unsorted input is undefined in release ones. Callers with
/// a possibly-unsorted source sort it first (sorting the *pairs*, not the
/// arrays independently, or the samples get scrambled).
///
/// Everything the base `Series` contracts to holds, with the sample index as
/// the grid: the pyramid buckets samples by *index* (`kBucket` samples per
/// level-0 bucket, exactly as for a uniform series) and `gather()` emits x
/// relative to `x0()` -- `f32(x_at(i) - x0())`, never the absolute
/// coordinate -- so an epoch-scale x array narrows to f32 the same safe,
/// O(visible pixels) way a uniform one does. The queries that need a *uniform*
/// grid are overridden with the binary-search answer instead: `x_at()` reads
/// the array, `neighbours()` brackets with `upper_bound`/`lower_bound`, and
/// `snap_index()` compares the two candidates the search leaves. `dx()` is
/// meaningless here and returns 0: nothing that routes through
/// `x_at`/`neighbours`/`snap_index` consults it.
class XYSeries final : public Series {
public:
/// Takes both arrays by value. `x.size() == y.size()` and `x` sorted
/// ascending (asserted in debug).
XYSeries(std::vector<f64> x, std::vector<f32> y);
/// Replaces both arrays, then rebuilds derived state (== commit()). Same
/// preconditions as the constructor.
void reset(std::vector<f64> x, std::vector<f32> y);
/// Rebuilds the pyramid and the cached bounds from the arrays as they stand.
void commit();
u64 size() const override;
/// x of sample 0: the first (smallest) x in the array.
f64 x0() const override;
/// Always 0: the grid is the x array, not a uniform step.
f64 dx() const override;
/// From the first and last x; a zero span for an empty series.
Range x_bounds() const override;
Range y_bounds() const override;
const MinMaxPyramid& pyramid() const override;
/// Same contract as `Series::gather`, with x emitted relative to `x_origin`.
void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode = GatherMode::Envelope) const override;
/// The sample's own x.
f64 x_at(u64 i) const override;
/// Binary-search bracket of data-space `x`: false outside
/// `[x_at(0), x_at(size() - 1)]`, else the last sample at or left of `x` and
/// the first at or right of it (equal on an exact hit).
bool neighbours(f64 x, u64& i0, u64& i1) const override;
/// Binary-search nearest sample, clamped to `[0, size() - 1]`; ties go to the
/// lower index, and an empty series returns 0.
u64 snap_index(f64 x) const override;
/// The x array itself (sorted ascending).
const std::vector<f64>& x() const noexcept { return x_; }
protected:
void copy_raw(u64 begin, u64 end, f32* out) const override;
private:
std::vector<f64> x_;
std::vector<f32> y_;
MinMaxPyramid pyramid_;
Range x_bounds_{};
Range y_bounds_{};
/// False until a finite sample has been seen (see `ArraySeries`): the cached
/// y bounds are then a placeholder, and the first finite sample seeds them.
bool y_has_finite_ = false;
};
} // namespace plot::data
+8
View File
@@ -58,8 +58,14 @@ enum : GLenum {
// Blending.
GL_BLEND = 0x0BE2,
GL_ZERO = 0x0000,
GL_ONE = 0x0001,
GL_SRC_ALPHA = 0x0302,
GL_ONE_MINUS_SRC_ALPHA = 0x0303,
GL_BLEND_SRC_RGB = 0x80C9 /* 1.4 */,
GL_BLEND_DST_RGB = 0x80C8 /* 1.4 */,
GL_BLEND_SRC_ALPHA = 0x80CB /* 1.4 */,
GL_BLEND_DST_ALPHA = 0x80CA /* 1.4 */,
// Pixel store state.
GL_UNPACK_ALIGNMENT = 0x0CF5,
@@ -127,7 +133,9 @@ enum : GLenum {
X(void, glClearColor, (float, float, float, float)) \
X(void, glClear, (GLbitfield)) \
X(void, glEnable, (GLenum)) \
X(GLboolean, glIsEnabled, (GLenum)) \
X(void, glBlendFunc, (GLenum, GLenum)) \
X(void, glBlendFuncSeparate, (GLenum, GLenum, GLenum, GLenum)) /* 1.4 */ \
X(void, glPixelStorei, (GLenum, GLint)) \
X(void, glDisable, (GLenum)) \
X(void, glScissor, (GLint, GLint, GLsizei, GLsizei)) \
+8 -2
View File
@@ -36,7 +36,10 @@ class Handle {
/// already used, so state that captured the old object -- the attribute
/// pointers of a vertex array above all -- must compare this counter rather
/// than the name (see `render::detail::InstanceStore::epoch()`).
u32 generation() const noexcept { return generation_; }
///
/// 64-bit on purpose: `epoch()` sums this with a slot counter, and a long-lived
/// streaming frame loop must not be able to wrap a u32 sum into a stale value.
u64 generation() const noexcept { return generation_; }
/// Deletes the GL object, if any, and empties the handle.
void reset() noexcept {
@@ -73,7 +76,7 @@ class Handle {
GLuint id_ = 0;
private:
u32 generation_ = 0;
u64 generation_ = 0;
};
/// Vertex buffer object. All fallible operations report through `Status`;
@@ -172,6 +175,9 @@ class Texture final : public Handle<Texture> {
bool create_r8(u32 width, u32 height, Status* st = nullptr) noexcept;
/// Replaces the whole image with `width * height * 4` bytes of RGBA data.
/// Sets `GL_UNPACK_ALIGNMENT` to 4 for the upload and restores the caller's
/// value afterwards, so a host that shares the context never inherits this
/// call's pixel-store state.
void upload_rgba(u32 width, u32 height, const void* pixels) const noexcept;
/// Replaces the whole image with `width * height` bytes of single-channel
+92 -4
View File
@@ -3,6 +3,8 @@
#pragma once
#include <vector>
#include <plot/core/rect.hpp>
#include <plot/core/types.hpp>
#include <plot/input/events.hpp>
@@ -47,14 +49,21 @@ using plot::RegionGrab;
/// | gesture | mode | effect |
/// |---|---|---|
/// | shift + left drag | `BoxZoom` | both ranges from the selection rect |
/// | ctrl + left drag | `BoxZoom` | x range only from the selection rect |
/// | alt + left drag | `BoxZoom` | y range only from the selection rect |
/// | left drag on a handle | `MoveCursor` / `MoveRegion` / `ResizeRegion*` | move the handle the press grabbed |
/// | left drag elsewhere | `Pan` | translate both ranges |
/// | right drag | `Pan` | same, for hosts that reserve left |
/// | middle drag on the x label strip | `AxisZoomX` | x-only zoom |
/// | middle drag on the y label strip | `AxisZoomY` | y-only zoom |
/// | wheel (shift: x only, ctrl: y only) | -- | zoom about the pointer |
/// | wheel `dx` (no `dy`) | -- | pan x, 5% of the span per notch |
/// | `+` / `-` | -- | zoom about the plot centre |
/// | `f` | -- | auto-fit every axis |
/// | Left/Right | -- | pan every x axis, 10% of its span |
/// | Up/Down | -- | pan every y axis, 10% of its span |
/// | Escape | -- | cancel the active drag: the ranges captured at `begin_drag` are restored, a box zoom applies nothing |
/// | double click (same button, 0.35 s, 4 px, `PointerEvent::time_s`) | -- | re-fit every axis; the second press begins no drag |
///
/// The right button pans *and* grabs: both are buttons a host drives drags with, so
/// neither one is singled out for handles. Middle keeps its own role on the label
@@ -74,9 +83,10 @@ using plot::RegionGrab;
/// Cursors and regions are *grabbable*: a press on one enters a drag that moves it,
/// with no modifier, and a press anywhere else pans exactly as it always has -- so
/// the new gesture is a superset of the old one and every host that drives a drag
/// keeps panning. `Ctrl` is no longer needed for a cursor move and does not change
/// what a press on one does; it only means the same thing it always did (a press
/// away from a handle pans, with or without it).
/// keeps panning. `Ctrl` and `Alt` no longer fall through to that press: they are
/// the modifier box zoom's masks now (see the gesture table -- Ctrl frames the x
/// range only, Alt the y range only, from the selection, over whatever was under
/// the press), which is why a *plain* press is the one that grabs a handle or pans.
///
/// One hit test runs for both, in this order, and that precedence is the whole rule:
///
@@ -203,13 +213,34 @@ 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.
Rect selection_rect() const noexcept { return selection_; }
/// The rectangle a host should *draw* for the open box zoom: the swept area, widened to the full
/// axes along whichever axis the modifier's mask will not zoom. Ctrl frames the x range only, so its
/// preview is the band that spans the plot -- which is what the release will zoom -- where the area
/// a purely horizontal drag sweeps is a sliver a few pixels tall. Shift (and any other combo) frames
/// both, and gets the swept box itself.
///
/// Meaningful while `BoxZoom`, like `selection_rect()`; the zero rect otherwise.
///
/// Defined in the implementation: widening the preview to the axes needs the plot's layout, and
/// `Plot` is only forward-declared here.
Rect selection_preview() const noexcept;
/// The cursor under the pointer -- a vertical marker or a horizontal ruler, whichever
/// line the pointer is nearer -- or `kInvalid` when the pointer is outside the plot area
/// or no cursor is within a few pixels.
@@ -237,6 +268,35 @@ class Controller {
void update_drag(const PointerEvent& e);
void end_drag(const PointerEvent& e);
/// The ranges (and, for x, the live offsets and auto-scale flags) every axis held
/// when a drag began -- what Escape restores. Captured at `begin_drag`, before the
/// gesture can move anything.
void capture_ranges();
/// Escape: the active drag is cancelled -- the ranges captured at `begin_drag`
/// are restored, a box zoom applies nothing, and the grab state is cleared. The
/// restore goes through the plot's own setters, so a live axis is restored as the
/// span and offset it held (the setters' native reading) rather than as positions
/// that the next frame's follow would overwrite anyway.
void cancel_drag();
/// Re-fits every axis (`auto_fit_x()` plus one `auto_fit_y()` per y axis), which
/// re-arms auto scale with the fit: the double-click reset.
void fit_all();
/// True when `e` is the second press of a double-click: the same button, within
/// 0.35 s (both timestamps > 0 -- a host that leaves `time_s` at 0 never produces
/// one) and within 4 logical px of the recorded press. Records `e` as the press a
/// third one would be measured against either way; a detected double-click clears
/// the record, so a third press starts a fresh gesture rather than re-firing.
bool double_click(const PointerEvent& e);
/// Pans every x axis by `fraction` of its own span, in axis space (log-aware:
/// a log axis translates multiplicatively). A positive fraction scrolls toward
/// larger x. The wheel's `dx` path and the Left/Right arrows share it.
void shift_x_axes(f64 fraction);
/// Same for every y axis: a positive fraction scrolls toward larger y (the Up
/// arrow). Screen y is inverted, so this is *not* the sign `pan()` derives from a
/// drag's `dy` -- an arrow says where to look, a drag says where the data goes.
void shift_y_axes(f64 fraction);
void pan(const PointerEvent& e);
void box_zoom_update(const PointerEvent& e);
void apply_box_zoom();
@@ -317,6 +377,34 @@ class Controller {
Rect selection_{};
f32 origin_x_ = 0.0f;
f32 origin_y_ = 0.0f;
/// The box-zoom mask, resolved once at `begin_drag` from the modifier table
/// (Shift = both, Ctrl = x-only, Alt = y-only, any other combo = both): which
/// axes the selection's release frames. `apply_box_zoom()` reads these for the
/// per-axis application *and* for the click threshold -- a masked-out dimension
/// of a thin selection does not make the gesture a click.
bool box_zoom_x_ = true;
bool box_zoom_y_ = true;
/// The range state a drag began with, per axis in order: what `cancel_drag()`
/// restores. Empty while no drag is open.
struct SavedX {
Range range{};
f64 offset = 0.0;
bool auto_scale = false;
};
struct SavedY {
Range range{};
bool auto_scale = false;
};
std::vector<SavedX> saved_x_;
std::vector<SavedY> saved_y_;
/// The last press seen, for the double-click reset: the button, the host
/// timestamp, and the position it was made at. `has_click_` is false until the
/// first press arrives.
bool has_click_ = false;
int click_button_ = -1;
f64 click_time_s_ = 0.0;
f32 click_x_ = 0.0f;
f32 click_y_ = 0.0f;
u64 drag_cursor_ = kInvalid;
/// The region drag's state. `drag_region_` is the region and `drag_zone_` the zone
/// the press resolved, kept for the whole gesture so the highlight a grab started
+31
View File
@@ -35,6 +35,26 @@ struct PointerEvent {
bool down = false; ///< the button is held after this event
bool pressed = false; ///< a press transition happened on this event
bool released = false; ///< a release transition happened on this event
/// Host-supplied monotonic seconds (the host's own clock, only differences
/// are read). Two presses of the same button within 0.35 s and 4 px are a
/// double-click and re-fit every axis. A host that leaves 0 never produces
/// one -- the field is optional, not another synchronisation contract.
f64 time_s = 0.0;
};
/// A pinch/two-finger gesture, in viewport pixels. The toolkit adapters
/// translate their native pinch state into one of these per frame of the
/// gesture: `scale` is this frame's zoom factor about `(x, y)` (1.0 = no
/// change), and `(dx, dy)` the focal point's movement since the previous
/// gesture event (a two-finger pan). GLFW has no touch input, so only hosts
/// that can deliver one feed this; the controller treats it exactly like the
/// equivalent wheel + pointer pair.
struct GestureEvent {
f32 x = 0.0f;
f32 y = 0.0f;
f32 scale = 1.0f;
f32 dx = 0.0f;
f32 dy = 0.0f;
};
/// A wheel/scroll event at a pixel position. A positive `dy` (scroll up) zooms
@@ -65,4 +85,15 @@ inline constexpr int kKeyFit = 'f';
/// Hosts that report shifted keys uppercase can pass this unchanged.
inline constexpr int kKeyFitUpper = 'F';
/// Escape cancels the active drag (pan, box zoom, cursor/region drag): the
/// ranges the axes had when the drag began are restored and no box is applied.
inline constexpr int kKeyEscape = 27;
/// Arrow keys pan: Left/Right move every x axis by 10% of its span per press
/// (repeat included), Up/Down move every y axis. Hosts translate their native
/// key codes to these, exactly as they do for the ASCII set above.
inline constexpr int kKeyLeft = 256;
inline constexpr int kKeyRight = 257;
inline constexpr int kKeyUp = 258;
inline constexpr int kKeyDown = 259;
} // namespace plot::input
+8
View File
@@ -37,6 +37,7 @@
#include <plot/data/ring_buffer.hpp>
#include <plot/data/series.hpp>
#include <plot/data/stream_series.hpp>
#include <plot/data/xy_series.hpp>
#include <plot/render/embedded_mono_font.hpp>
#include <plot/render/buffer.hpp>
@@ -53,6 +54,7 @@
#include <plot/plot/axis.hpp>
#include <plot/plot/crosshair.hpp>
#include <plot/plot/cursor.hpp>
#include <plot/plot/density.hpp>
#include <plot/plot/figure.hpp>
#include <plot/plot/grid.hpp>
#include <plot/plot/legend.hpp>
@@ -64,3 +66,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>
+42 -15
View File
@@ -52,19 +52,42 @@ struct Axis {
AxisPlace place = AxisPlace::Bottom;
bool log = false;
TickFormat format = TickFormat::Si;
/// The value the labels are written *relative to*: every value on this axis is formatted as
/// `v - origin`, and the ticks are generated on that shifted grid. A host uses it to read a
/// zoomed-in window against its own left edge -- the labels then say `0, 0.5ms, 1ms` with the
/// window's start printed once beside the axis, instead of `12.345671s, 12.345672s` (which
/// three significant digits cannot even distinguish). 0 by default, which is the plain
/// absolute axis; the *positions* of the ticks are unchanged by it in the sense that only
/// their printed value shifts.
f64 origin = 0.0;
/// When set, `origin` is re-taken from the axis' own range on every `refresh()` -- the labels
/// then count from whatever the left edge currently is, and the tick on that edge reads zero
/// exactly, without a host having to predict where the follow will place the window (which it
/// cannot: the window moves with the samples, and a value written a frame earlier is a frame
/// of scroll stale). The window-start label a host draws beside the axis is then
/// `origin` *after* the frame, which is the value these labels were built with.
bool origin_follows_range = false;
/// Whether this axis follows the newest sample. Only meaningful on an x axis --
/// a y axis has no live edge -- and `Off` on both, so nothing changes for a
/// caller who never asks for it. See XFollow.
XFollow follow = XFollow::Off;
/// How far behind the live edge the visible window's right edge sits, in data
/// units, under `XFollow::Live`. Stored rather than implied by the range,
/// because the range is recomputed from the series' newest x on every frame:
/// this is the part of the view the user chose, so it is the part that survives
/// the recompute. Three things write it, all of them through one clamp: a pan or
/// a zoom through `Plot::set_x_range()`, which derives it from the range the
/// gesture asked for, `Plot::set_x_offset()` for a caller that states it
/// directly, and `Plot::layout()`'s placement, which re-clamps it against the
/// retained window every frame. `Plot::x_offset()` reads it back.
/// How far behind the live edge the visible window's right edge sits, in **axis
/// space**, under `XFollow::Live`: data units on a linear axis, `log10` decades
/// on a logarithmic one -- the same space the controller's pan/zoom gestures run
/// in (`to_space`/`from_space`), so a log axis scrolls and holds *decades* the
/// way a linear axis scrolls and holds units. (`to_space(live.max) -
/// to_space(hi)` is the definition: the distance the axis' own transform
/// measures between the live edge and the window's right edge.) Stored rather
/// than implied by the range, because the range is recomputed from the series'
/// newest x on every frame: this is the part of the view the user chose, so it
/// is the part that survives the recompute. Three things write it, all of them
/// through one clamp: a pan or a zoom through `Plot::set_x_range()`, which
/// derives it from the range the gesture asked for, `Plot::set_x_offset()` for a
/// caller that states it directly, and `Plot::layout()`'s placement, which
/// re-clamps it against the retained window -- measured in the same space --
/// every frame. `Plot::x_offset()` reads it back.
f64 offset = 0.0;
/// Set by an *owner* that is following this axis' window on the plot's behalf: a
/// `Figure` link group, while it follows, writes its own live edge here every frame.
@@ -79,12 +102,16 @@ 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()`).
/// Set while this axis is a member of a `Figure` link group. On an x axis that is
/// an 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()`).
/// On a y axis it is the row's shared-y group (`Figure::set_share_row_y()`): the
/// group, not the plot, owns the range *and* the auto scale state, so
/// `Plot::autoscale_axes()` skips the axis and the figure fits the union of the
/// members' traces instead.
bool link_member = false;
/// Ticks for the last `refresh()`, ascending, majors and (when asked) minors.
std::vector<Tick> ticks;
+106 -27
View File
@@ -30,6 +30,35 @@ struct CrosshairStyle {
///< (once resolved) means solid.
};
/// What a crosshair readout's number *is*: one of the series' own samples, or a value
/// computed between two of them.
///
/// The two answer different questions and the right one is a property of the data, which the
/// plot cannot know — so it is the caller's to state.
///
/// * `NearestSample` answers **"which datum is under the pointer"**. The row reports the y of
/// whichever bracketing sample is nearer the crosshair's x (a tie reading the earlier), and
/// the marker is placed at **that sample's own x**, so the mark says *which* sample the
/// number came from. This is the default, and it is the honest mode for a source whose
/// samples are already averaged or decimated before they arrive: a number between two of
/// those is a second estimate of a signal that nothing measured, and at a zoom past the
/// sample spacing the polyline between two samples is a line the renderer drew, so a
/// reading off it reports the drawing.
/// * `Interpolated` answers **"what is the signal at this instant"**, by linear interpolation
/// between the two bracketing samples (`crosshair_interpolate()`). The marker stays on the
/// crosshair's own x, on the polyline, because that is where the number is. Right for a
/// continuous quantity sampled finely enough that the line between two samples is a model
/// the caller believes — a smooth analytical curve, an interpolating instrument — and
/// wrong wherever that line is an artefact of plotting.
///
/// Set per plot through `Plot::set_crosshair_value_mode()`. It reaches the readout's numbers,
/// the markers' positions and `Plot::crosshair_value()` alike, so a host and the panel beside
/// the pointer can never disagree about what the value means.
enum class CrosshairValue : u8 {
NearestSample, ///< The nearer bracketing sample's y, marked at that sample's x. Default.
Interpolated, ///< A linear blend of the two, marked at the crosshair's own x.
};
/// The crosshair's state: where it is, which of its two lines it draws, and whether the
/// per-signal readout is wanted.
///
@@ -44,10 +73,10 @@ struct CrosshairStyle {
/// drawn -- the single-coordinate `Plot::set_crosshair()` leaves the vertical one only,
/// and a `Figure` is always vertical-only because its rows share x and not y.
///
/// **The readout's values are interpolated, and deliberately so.** The y a row reports for
/// a trace is *linear interpolation between the two samples adjacent to `x`*, not a sample
/// and not an interval statistic -- see `crosshair_span()` for the rule stated in full, and
/// for why it is the opposite convention from `Series::reduce()`/`sum()`/`sumsq()`.
/// **The readout's values are samples by default** -- the y a row reports is the nearer of
/// the two samples adjacent to `x`, and its marker sits on that sample. `value_mode` is what
/// chooses otherwise; see `CrosshairValue` for the two questions it picks between, and
/// `crosshair_span()` for how both differ from `Series::reduce()`/`sum()`/`sumsq()`.
struct Crosshair {
/// Whether the crosshair is drawn at all. Set by `Plot::set_crosshair()`; cleared by
/// `Plot::clear_crosshair()`, which `input::Controller` calls when the pointer leaves the
@@ -58,15 +87,27 @@ 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;
/// Write the two values the crosshair's lines meet the axes at as small badges in the
/// label strips -- the y value beside the y ticks, the x value beside the x ticks, each
/// in its axis' own format. On by default: it is the axis reading an oscilloscope puts
/// where the eye already is, and a host that does not want it turns it off
/// (`Plot::set_crosshair_axis_labels()`). The badges are placed only when the strip they
/// belong to exists and the line they belong to is drawn (a vertical-only crosshair has
/// no y to badge, and a row whose x strip went to a neighbour badges nothing).
bool axis_labels = true;
/// The data x of the vertical line, on x axis 0.
f64 x = 0.0;
/// The data y of the horizontal line, on y axis 0. Meaningless for a vertical-only
/// crosshair, which draws no row and anchors its readout at the top of the axes.
f64 y = 0.0;
/// Whether a row's value is one of the series' samples or a blend of two -- see
/// `CrosshairValue`, which also says where each mode puts the marker. Set through
/// `Plot::set_crosshair_value_mode()`.
CrosshairValue value_mode = CrosshairValue::NearestSample;
/// This plot's own stroke and dash, when it wants to differ from the global style; a
/// default-constructed `CrosshairStyle` defers every field to `render::Style` (see
/// `CrosshairStyle`). Set through `Plot::set_crosshair_style()`.
@@ -84,14 +125,17 @@ struct Crosshair {
/// arithmetic, which is O(1) in the series' length. The crosshair runs on every pointer
/// move, so that is a requirement rather than a nicety.
///
/// **Interpolated, not exact.** The value a caller builds from this span by
/// `crosshair_interpolate()` between the two samples is the signal *at* `x`, which is
/// generally between two samples -- the opposite convention from `Series::reduce()`,
/// `sum()` and `sumsq()`, which are **exact over a sample range**. The two conventions sit
/// next to each other in this library on purpose and they do not agree: `reduce()` answers
/// "what are the samples' extremes over this interval", this answers "what is the signal at
/// this instant". A readout that pretended to be exact would be quoting an extreme or a
/// mean of a window as if it were the value under the pointer.
/// **`t` is a position, not yet an answer.** It says where the query sits between the two
/// samples and nothing more; what a caller makes of it is `CrosshairValue`'s to decide. In
/// `NearestSample` -- the default -- it *selects*, `t <= 0.5` taking `begin`, so the readout
/// quotes a sample the series actually holds. In `Interpolated` it *blends*, through
/// `crosshair_interpolate()`. A plot cannot tell which is right, because the question is
/// about the data and not about the drawing; `CrosshairValue` is where that is argued.
///
/// Either way this is a different convention from `Series::reduce()`, `sum()` and `sumsq()`,
/// which are **exact over a sample range**: those answer "what are the samples'
/// extremes/total over this interval", a crosshair answers "what is the signal at this one
/// x". What differs is a statistic of many samples against a reading at one place.
///
/// `valid` is false -- and every field left at its default -- for an **empty series**, a
/// non-positive `dx()`, a non-finite `x`, and an `x` strictly outside the series' window
@@ -104,13 +148,14 @@ struct CrosshairSpan {
/// Index of the first sample whose x is at or after the query: `begin` when the query
/// lands on a sample, `begin + 1` between two of them.
u64 end = 0;
/// The query's position between the two samples, in `[0, 1]`.
/// The query's position between the two samples, in `[0, 1]`. Whether it selects one of
/// them or blends both is `CrosshairValue`'s -- see the struct comment.
f64 t = 0.0;
bool valid = false;
};
/// The sample span `x` falls in, on `series`; see `CrosshairSpan` for the rule, for the
/// interpolation convention it is half of, and for the cases that yield `valid == false`.
/// The sample span `x` falls in, on `series`; see `CrosshairSpan` for what `t` means, for the
/// convention it is half of, and for the cases that yield `valid == false`.
///
/// O(1) and allocation-free: a subtraction, a division and a truncation. The series' own sample
/// fetch is the caller's -- `Plot::crosshair_value()` reads the span through
@@ -118,12 +163,14 @@ struct CrosshairSpan {
/// allocates.
CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept;
/// The interpolated y at `t` between two samples: `y_begin` at `t == 0`, `y_end` at
/// `t == 1`. The one place the interpolation's arithmetic lives, so the lookup's rule and
/// its documentation cannot drift apart.
inline f64 crosshair_interpolate(f64 y_begin, f64 y_end, f64 t) noexcept {
return y_begin + (y_end - y_begin) * t;
}
/// The linear blend of a span's two end samples at `t`: `y_begin + t * (y_end - y_begin)`,
/// which is what `CrosshairValue::Interpolated` reports.
///
/// Written as that form rather than `(1 - t) * a + t * b` so `t == 0` returns `y_begin`
/// exactly and `t == 1` returns `y_end` exactly, with no rounding of its own at the ends: a
/// query that landed on a sample must read that sample's value back unchanged, whichever mode
/// is in force. `NearestSample` does not call it at all.
f64 crosshair_interpolate(f64 y_begin, f64 y_end, f64 t) noexcept;
/// One trace's line of the readout: which trace it is about, the value under the pointer
/// and the boxes the renderer stages.
@@ -150,8 +197,14 @@ struct CrosshairRow {
/// entries -- so a row that is not listed is still a listed *trace*, with a marker, just
/// not a line in a panel that has no room for it.
bool listed = false;
/// The trace's interpolated y at the crosshair's x, on the trace's own y axis. Only
/// meaningful when `valid`.
/// The data x the row's value belongs to, on the trace's own x axis: in
/// `CrosshairValue::NearestSample` the **quoted sample's own x**, which is generally not the
/// crosshair's; in `Interpolated` the crosshair's x itself, since that is where an
/// interpolated number is. `marker` is placed from this and from `y`. Only meaningful when
/// `valid`.
f64 x = 0.0;
/// The row's value, on the trace's own y axis: the nearer sample's y, or the blend of the
/// two -- see `CrosshairValue`. Only meaningful when `valid`.
f64 y = 0.0;
/// The trace's name, or the positional placeholder of an unnamed one.
std::string label;
@@ -161,9 +214,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`.
@@ -217,6 +285,17 @@ struct CrosshairLayout {
/// `Crosshair::show_readout` is off, or when the panel could not be placed inside the
/// axes at all.
Rect panel{};
/// The two axis badges, when `Crosshair::axis_labels` is on and the crosshair is active:
/// the y value in the **y label strip**, right-aligned on the axis line at the horizontal
/// line's height, and the x value in the **x label strip**, centred on the vertical
/// line's column -- each in its axis' own format, so a badge reads exactly like the tick
/// labels beside it. Zero-sized (and the text empty) when the flag is off, the crosshair
/// is inactive, the line they belong to is not drawn, their strip does not exist (a row
/// whose x strip went to the row below), or there is no text backend to measure them.
Rect y_axis_badge{};
std::string y_axis_text;
Rect x_axis_badge{};
std::string x_axis_text;
/// One row per visible trace, in trace order -- see `CrosshairRow`. Present whether or
/// not the panel could list them, so the markers are drawn even in a plot too small to
/// hold a panel.
+20
View File
@@ -6,6 +6,7 @@
#include <plot/core/color.hpp>
#include <plot/core/types.hpp>
#include <plot/math/vec2.hpp>
namespace plot {
@@ -86,6 +87,25 @@ struct Cursor {
u32 axis_x = 0; ///< the x axis `x` is in. Dead for `Horizontal`.
u32 axis_y = 0; ///< the y axis `y` is in. Dead for `Vertical`.
bool visible = true;
/// Whether the pointer may hit this cursor at all: `false` for a *readout* -- a marker a
/// host keeps to show something it computed, which the user must not be able to drag. The
/// controller's `cursor_at()` skips it, so it is neither hovered nor grabbed, and the
/// hit radius it would have taken is left to whatever is behind it. Drawn exactly as a
/// visible one is: this is about the gesture, not the ink.
bool selectable = true;
/// The stroke's logical width; `<= 0` means the style's default for this cursor
/// (`Style::handle_width_px` while it is hovered or grabbed, `Style::line_width_px`
/// otherwise). Per cursor, like the colour, because a host uses cursors for two different
/// jobs: the instruments that frame a reading (a marker at an edge, a pull handle) and the
/// *readout marks* a computation places on the data (a measured level, a peak). Those want
/// different weights, and a hover can only make the stroke thicker, never thinner -- the cue
/// that the pointer has hold of something must not be the thing that hides it.
f32 width = 0.0f;
/// The dash, logical pixels: ink length and gap. `off <= 0` (the default) is a solid stroke --
/// the same convention as a trace's, the crosshair's and the grid's. A dashed readout mark is
/// distinguishable by *form* from the solid chrome around it, which is what an instrument that
/// reports a number should be.
Vec2 dash{};
/// True when this is a horizontal ruler (spans the axes' width, reads its `y`). The one
/// place the orientation becomes a branch, so every consumer takes the same decision and
+195
View File
@@ -0,0 +1,195 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <span>
#include <vector>
#include <plot/core/types.hpp>
namespace plot {
/// The most cells one density grid may hold: **262 144**, a mebibyte of `f32` counts.
///
/// A grid is the axes rect in device pixels -- the screen's resolution and not the data's, which is
/// the whole point of accumulating into a grid -- but the screen is not a fixed thing. A window can
/// be dragged to any width, and at 2x device scale the axes rects of a 1520x850-logical window are
/// 2877 device pixels wide each: four rows of one trace are **4.5 M cells**, 18 MB of counts
/// between them, and every one of them is walked per frame. That walk is `O(cells)`: the fade
/// multiplies every cell, a host's `ink()`/`peak()` read every cell, and a frame the fade moved
/// converts and uploads every cell as one byte. So a resolution tied to the window is a per-frame
/// cost tied to the window, with no ceiling -- measured headless on llvmpipe with four rows of one
/// trace, a 2 s window and a 3040x1700 framebuffer: **4.5 M cells, 12.9 ms of tick and 12.8 ms of
/// draw a frame, 4.9 MB uploaded**.
///
/// This is the ceiling, in **cells** rather than in pixels: the axes rect is scaled down, uniformly,
/// until it fits, so a cell stays square and the picture keeps the axes' aspect (see `DensityGrid`).
/// The number is a frame's budget read off that same measurement -- the same four rows at the cap
/// are 1.05 M cells and **3.9 ms of tick and 5.6 ms of draw**, a third and a half of the unbounded
/// grid's pass -- while the image is still 681 columns across on a 3784-pixel axes rect. It is a
/// **cap** and not a resolution: a grid whose axes rect is under it is still exactly that rect in
/// device pixels, and only an axes rect of more than a quarter of a megapixel -- a 4K window's, or
/// one row of a tall one -- is scaled.
inline constexpr usize kDensityCellCap = usize{1} << 18;
/// Which way a plot's **phosphor density** fades between the acquisitions drawn into it.
///
/// The two are answers to two different questions, which is why the choice belongs to the
/// caller rather than to a default: a bench scope's persistence is set by *time* -- "the trace
/// is still visible a second after it was drawn" -- while a sweep-to-sweep accumulation is set
/// by *count* -- "the tenth acquisition back is gone". The two agree only while the trigger
/// rate is constant, and a rate that varies between runs is exactly when a caller has to pick.
enum class DensityDecay {
/// `intensity *= exp(-dt / time_constant_s)` every time the grid is advanced
/// (`Plot::decay_density()`), so the image fades while you watch it whatever the trigger
/// rate -- a slow stream draws faint sweeps, a fast one bright ones, and both are still
/// legible a time constant later. A `time_constant_s <= 0` freezes the grid instead of
/// fading it.
TimeConstant,
/// `intensity *= per_acquisition` once per accumulated acquisition, applied by
/// `Plot::accumulate_density()` *before* the new acquisition is deposited, so the sweep just
/// drawn is the brightest and each older one is the factor down: the N-th back is gone
/// whatever the rate. It scales with the trigger rate by construction -- the record of the
/// last few sweeps is what the display holds -- which is what a caller wants when the
/// interesting comparison is between successive acquisitions and not between seconds.
PerAcquisition,
};
/// A plot's phosphor density: whether the grid is on, how it fades, and how a cell's count is
/// turned into brightness. Plain data, replaced wholesale by `Plot::set_density()`.
///
/// The image is **one grid per trace**, drawn in that trace's own colour (`Trace::color`), because
/// a density that had one hue for the whole plot could not say *which* trace put the ink there --
/// two signals agreeing in a cell would be one blended pixel, and a display of several traces
/// could not be read back to the trace it came from. A cell's brightness is a count of sweeps in
/// *that trace's* colour: `full_scale` (default one sweep, the trace's own brightness) is where it
/// saturates, and a signal crossing the same place every acquisition settles exactly at the colour
/// its trace would have drawn.
///
/// Its reason to exist as a *plot* feature rather than a host's overlay is the same one
/// `Plot::set_invalid_spans()` has: the plot is what knows a trace's device geometry -- the
/// visible window about to be drawn, on the axes rect the layout settled -- so the host would
/// otherwise have to rasterise traces the library has already staged, at a second derivation of
/// the same mapping that could disagree with the first.
struct DensityConfig {
/// Whether the grid is drawn *and* accumulated. Off is the default and costs nothing: no
/// grid is allocated, no frame uploads one, and the traces are drawn exactly as they were.
/// Turning it off drops what was accumulated --- a caller that has stopped paying is not
/// holding a stale image -- see `Plot::set_density()`.
bool enabled = false;
/// How the grid fades (see `DensityDecay`).
DensityDecay decay = DensityDecay::PerAcquisition;
/// `TimeConstant`'s constant, in **stream seconds** -- the same clock the x axis reads, so a
/// caller advancing the density by its frame's own `dt` gets a fade that does not depend on
/// the frame rate. `<= 0` freezes the grid.
f32 time_constant_s = 1.0f;
/// `PerAcquisition`'s factor, clamped to `[0, 1]` at use. 0.5 -- half per acquisition -- is
/// what a phosphor's afterglow looks like sweep to sweep: 1, 0.5, 0.25, 0.125 and by the
/// seventh the layer is under 1 %.
f32 per_acquisition = 0.5f;
/// The cell value that reads as **full brightness**, in accumulated sweeps -- and the one field
/// a caller reads *off its own trace* rather than off the fade. A cell at this value is drawn in
/// its trace's own colour at that colour's own alpha; the default of **one sweep** is therefore
/// the ink the newest acquisition leaves, so the acquisition just drawn reads exactly as the
/// trace it is a picture of (a signal crossing the same place every acquisition settles at the
/// trace's brightness, where a ceiling above one sweep would settle *brighter* than the trace and
/// leave a gain offset between the newest sweep and the image behind it). Everything above the
/// ceiling clamps to the same pixel: where several acquisitions agree, the cell is the trace's
/// colour at full strength and no brighter.
///
/// A caller that wants the fade's own ramp on screen instead -- the newest sweep a *fraction* of
/// the steady state, so the eye can rank the sweeps -- sets this to the sum the fade settles at
/// (for `PerAcquisition`'s factor `f` that is `1/(1-f)`) and accepts the gain offset; that was
/// this field's first default, and it is a legitimate choice, not the one a phosphor makes.
f32 full_scale = 1.0f;
/// The gamma applied to the normalised cell value before it becomes a luminance. **1.0 by
/// default: linear in sweeps**, because that is what a count means -- a cell covered by half the
/// acquisitions is half the ink. Under 1 lifts the faint end, and that is what
/// `Style::intensity_gamma` is for where the value is a *dwell fraction* rather than a count;
/// here it flattens the top instead (0.45 maps a quarter of the scale to 0.54 and the ceiling to
/// 1.0, which is most of the ramp spent on the last sweep). Over 1 darkens the faint end for a
/// caller that wants only the strong agreement visible.
f32 gamma = 1.0f;
};
/// The density's own image: **the axes rect in device pixels, up to `kDensityCellCap` cells**, so
/// its resolution is the screen's and not the data's -- the whole point of accumulating into a grid
/// rather than keeping the acquisitions that were drawn into it -- while still being bounded by
/// something other than the window a user has dragged. Under the cap the grid *is* the axes rect and
/// a cell is a device pixel; past it the rect is scaled down uniformly (see `kDensityCellCap` for
/// the number and the frame budget it comes from), and the renderer magnifies the image back over
/// the rect with a linear filter, because a cell is a count of sweeps and not a pixel of a picture.
///
/// The bound is what makes the memory and the per-frame cost *numbers* rather than functions of the
/// window: `columns() * rows() * 4` bytes whatever the trigger has done **and** whatever the
/// window is, at most **262 144 cells, 1 048 576 bytes** a grid -- against the 4.5 MB a trace a
/// 3040x1700 framebuffer's axes used to ask for, and the 26 ms of tick and draw a frame the pass
/// over four of them then cost. A plot holds **one grid per trace** (see `DensityConfig`), so a
/// display's memory is that figure times the traces actually drawn -- a plot's unbound slots hold no
/// grid -- and four traces hold at most 4 MiB between them. The renderer adds one byte per cell as
/// the luminance it uploads (`DensityGrid::cells()` is `f32` because a **count** is what decays and
/// what `ink()`/`peak()` report; the byte is derived per changed frame, not stored here).
///
/// A cell's value is a **number of sweeps**: every visible trace of the plot that covers that cell
/// during one `Plot::accumulate_density()` leaves 1.0 in it -- once, however many of that trace's
/// own samples crossed it -- so brightness means how many acquisitions were here, and two traces
/// agreeing in one cell is twice the ink. (What the *beam* did inside one column is a different
/// question, and the library answers it elsewhere: `Style::intensity_grading`.) Decay multiplies
/// the whole image, so a cell's value is a decaying count of sweeps and not a bounded score.
///
/// The grid is indexed row-major from the axes rect's **top-left** corner: cell `(c, r)` is at
/// `r * columns() + c` and covers the device span `[axes.x + c*w/columns(), axes.x + (c+1)*w/columns())`
/// by `[axes.y + r*h/rows(), axes.y + (r+1)*h/rows())` -- the device pixel `(axes.x + c, axes.y + r)`
/// while the rect is under the cap, and a block of them when the plot scaled the image down.
/// `revision()` moves on every change, which is what lets the renderer skip the upload -- and the
/// whole conversion -- on a frame the grid did not move.
class DensityGrid {
public:
/// Grid width in cells: the axes rect's width in device pixels, rounded up, scaled down when the
/// rect is past `kDensityCellCap` (see the class comment). 0 until the first accumulation on a
/// laid-out plot.
u32 columns() const noexcept { return columns_; }
/// Grid height in cells: the axes rect's height in device pixels, rounded up, scaled down with
/// `columns()`: `columns() * rows()` cells.
u32 rows() const noexcept { return rows_; }
usize cell_count() const noexcept {
return static_cast<usize>(columns_) * static_cast<usize>(rows_);
}
/// The cells, row-major from the top-left, one per cell of the axes rect -- see the class comment
/// for the resolution and what one cell's value means.
std::span<const f32> cells() const noexcept { return cells_; }
/// One cell, or 0.0 outside the grid.
f32 at(u32 column, u32 row) const noexcept {
if (column >= columns_ || row >= rows_) return 0.0f;
return cells_[static_cast<usize>(row) * columns_ + column];
}
/// The **total ink**: every cell's value summed. The reading that says how much has been
/// drawn into the grid at all -- it grows with each accumulation and falls with each decay,
/// and is 0 for a grid that is off or was just cleared.
f64 ink() const noexcept {
f64 total = 0.0;
for (const f32 v : cells_) total += static_cast<f64>(v);
return total;
}
/// The **peak**: the brightest cell's value, 0 for an empty grid. It is the reading that
/// says whether the accumulation is saturating (at or past `DensityConfig::full_scale`, which
/// reads as full brightness) and whether the decay has reached the sweep behind the newest.
f32 peak() const noexcept {
f32 top = 0.0f;
for (const f32 v : cells_) top = v > top ? v : top;
return top;
}
/// How many times the image changed: bumped by every accumulation, decay, clear and resize.
/// The renderer compares it with the revision it last uploaded, so a frame that changed
/// nothing uploads nothing.
u64 revision() const noexcept { return revision_; }
private:
friend class Plot;
std::vector<f32> cells_;
u32 columns_ = 0;
u32 rows_ = 0;
u64 revision_ = 0;
};
} // namespace plot
+155 -31
View File
@@ -27,13 +27,21 @@ struct FigureLayout {
/// strips and axes come from its own `Plot::layout()` against this rect, so a row
/// with a y title keeps its column and no two rows can disagree about chrome.
std::vector<Rect> rows;
/// The strip the figure's one shared x axis is drawn in: the bottom row's own
/// `PlotLayout::x_label_area`, at the bottom of the bottom row's rect and so as
/// wide as that row's axes rather than the whole figure. Read back from the row
/// One rect per plot, flattened row-major (row 0's cells left to right, then row 1's,
/// ...): the **viewport** each `Plot` was given. A row of one cell has exactly its row
/// rect here; a row of several tiles its rect left to right with `col_gap` between
/// cells, the cells' widths summing to the row's less the gaps.
std::vector<Rect> cells;
/// The strip the figure's bottom x axis is drawn in: the bottom row's own
/// `PlotLayout::x_label_area` (for a bottom row split into several cells, only the **last
/// cell's** strip -- the others' strips are their own `plot(i).layout().x_label_area`, which
/// is where the controller's axis-zoom hit test reads each cell's gestures from), at the
/// bottom of the bottom row's rect and so as wide as that row's axes rather than the whole
/// figure. Read back from the row
/// rather than derived here, so a host and the frame cannot disagree about where
/// the axis is. It is nonzero whenever the figure has rows and the bottom row
/// reserves a label strip -- including the case where every row labels itself, in
/// which case it is simply the bottom row's own strip, one of several.
/// reserves a label strip. A row above whose columns differ from the row below keeps
/// a strip of its own (see `Figure`), which is its plots' `x_label_area`, not this.
///
/// It is the rect the controller's axis-zoom hit test reads (`x_label_area` is what
/// a middle drag in a label strip enters `DragMode::AxisZoomX` from), which is why
@@ -44,6 +52,10 @@ struct FigureLayout {
/// figure edge to edge, which is the default; it is dropped for a figure too
/// short to hold it, so the rows never lose their height to a gap.
f32 row_gap = 0.0f;
/// The horizontal gap between two cells of a row, in device pixels. `set_col_gap()`'s
/// value, or 0 when any row holding several cells was too narrow to hold it and
/// dropped it, so the cells never lose their width to a gap.
f32 col_gap = 0.0f;
};
/// Several plots stacked in one column, sharing one x axis.
@@ -59,12 +71,15 @@ struct FigureLayout {
/// **The shared x axis.** Row heights are an equal division of the figure by
/// default, `set_row_weights()` gives a row more of it, and every row keeps a floor
/// height so chrome can never erase a plot. When `set_x_labels_only_on_bottom()` is
/// on -- the default -- the figure's one x axis is the **bottom row's own**: the rows
/// above reserve no x chrome at all, and the bottom row keeps its own label and title
/// strips, so those labels and that title are drawn once, from the bottom row's own
/// ticks and transform. That places the shared axis *inside* a row, where the
/// controller's axis-zoom hit test can reach it, instead of in a band outside every
/// row that no gesture could land in.
/// on -- the default -- the x axis is the **bottom row's own** for every run of rows
/// with the *same columns* (the same number of cells and the same normalised cell
/// weights): those rows above reserve no x chrome at all, and the bottom row of the run
/// keeps its own label and title strips, so those labels and that title are drawn once,
/// from its own ticks and transform. A row whose columns differ from the row below keeps
/// its own strip, drawn under each of its cells, because the axis below is at another
/// scale and width. The rule is structural, never a function of what a plot holds. That
/// places a shared axis *inside* a row, where the controller's axis-zoom hit test can
/// reach it, instead of in a band outside every row that no gesture could land in.
///
/// **The x link.** `plot(i).set_x_range()` and a controller gesture both reach a
/// member's x range the same way, so a figure cannot see either of them happen.
@@ -94,13 +109,30 @@ class Figure {
/// which is the *unlinked* group: rows that share nothing, each with the range
/// its own plot was given.
explicit Figure(u32 rows);
/// A figure of `cells_per_row.size()` rows, row `r` holding `cells_per_row[r]` plots side
/// by side (a count of 0 is clamped to 1). Every row of one cell makes this the same
/// figure as `Figure(rows)`. There is deliberately no `initializer_list` constructor, so
/// `Figure{3u}` keeps meaning three rows.
explicit Figure(std::span<const u32> cells_per_row);
u32 row_count() const noexcept { return static_cast<u32>(plots_.size()); }
/// One row's plot. A row that does not exist yields a default-constructed `Plot`
/// rather than reading out of bounds -- the rule every accessor on `Plot` follows
/// -- so a stale row can only ever affect itself.
Plot& plot(u32 row) noexcept;
const Plot& plot(u32 row) const noexcept;
/// The number of figure rows -- the bands stacked top to bottom -- which equals
/// `plot_count()` for a figure no row of which is split.
u32 row_count() const noexcept { return static_cast<u32>(cells_per_row_.size()); }
/// The number of plots, i.e. cells over all rows. `plot()`, `link_x()`, `x_link()` and
/// the hovered plot of `set_crosshair()` index this flattened (row-major) range.
u32 plot_count() const noexcept { return static_cast<u32>(plots_.size()); }
/// How many cells row `row` holds; 0 for a row that does not exist.
u32 cells_in_row(u32 row) const noexcept;
/// The flattened index of row `row`'s first cell; `plot_count()` for `row ==
/// row_count()` and for anything beyond.
u32 first_plot_of_row(u32 row) const noexcept;
/// The row that holds flattened plot `plot` (the last row for an index past the end).
u32 row_of_plot(u32 plot) const noexcept;
/// One plot, by flattened cell index. A plot that does not exist yields a
/// default-constructed `Plot` rather than reading out of bounds -- the rule every
/// accessor on `Plot` follows -- so a stale index can only ever affect itself.
Plot& plot(u32 index) noexcept;
const Plot& plot(u32 index) const noexcept;
// --- geometry ---------------------------------------------------------
@@ -119,6 +151,21 @@ class Figure {
void clear_row_weights() noexcept { row_weights_.clear(); }
bool has_row_weights() const noexcept { return !row_weights_.empty(); }
/// How much of row `row`'s width each of its cells gets: `weights[i]` is cell i's
/// share of what is left after every cell has its floor width (64 logical pixels,
/// capped at an equal share of a narrow row). Normalised. Refused, changing nothing,
/// when the row does not exist, the count is not the row's cell count, or a weight is
/// not a positive finite number. A row with no weights divides equally.
Status set_cell_weights(u32 row, std::span<const f32> weights);
/// Back to an equal division of row `row`; a row that does not exist is ignored.
void clear_cell_weights(u32 row) noexcept;
bool has_cell_weights(u32 row) const noexcept;
/// The horizontal gap left between two cells of a row, in device pixels (0.0 by
/// default). Negative values are clamped to 0, and a gap too wide for a row to hold is
/// dropped for that layout rather than squeezing the cells to nothing.
void set_col_gap(f32 device_px) noexcept { col_gap_ = device_px > 0.0f ? device_px : 0.0f; }
f32 col_gap() const noexcept { return col_gap_; }
/// The gap left between two rows, in device pixels (0.0 by default, so the rows
/// tile the figure). Negative values are clamped to 0, and a gap too large for the
/// figure to hold is dropped for that layout rather than squeezing the rows to
@@ -126,9 +173,34 @@ 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_; }
/// 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.
/// 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.
/// In a split row whose cells share one y (`set_share_row_y`) only the **last** cell
/// keeps it, taken off the row like the y gutter, so the gap between two plots of the
/// row is the column gap.
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 rows with the same columns as the row below hand it their x axis (the
/// default; a row with different columns keeps its own), or every row draws its own
/// labels and the figure draws nothing extra.
void set_x_labels_only_on_bottom(bool on) noexcept { labels_bottom_only_ = on; }
bool x_labels_only_on_bottom() const noexcept { return labels_bottom_only_; }
@@ -143,7 +215,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.
@@ -172,17 +245,21 @@ class Figure {
/// is not a number; such a call therefore leaves every row's crosshair as it was.
void set_crosshair(f64 x, bool active);
/// Places the crosshair with the pointer's **row** and that row's local y (spec §17): the
/// shared x goes to every row -- vertical lines everywhere, as the plain overload does -- and
/// the horizontal line is drawn **only in `hovered_row`**, where its y is a value of *that*
/// row's own scale. It is the fix for the reported defect -- a figure showing no horizontal
/// Places the crosshair with the pointer's **plot** and that plot's local y (spec §17): the
/// shared x goes to every plot -- vertical lines everywhere, as the plain overload does -- and
/// the horizontal line is drawn in `hovered_row` -- the **flattened plot index**, not the
/// figure-row index; they differ when a row holds several cells -- where its y is a value of
/// *that* plot's own scale. **When the row is a split whose cells share one y
/// (`set_share_row_y`), the line is drawn in every cell of that row**: it is one value on one
/// scale, so stopping it at the divider would hide the number the neighbouring cell is read
/// against. Rows never share y with each other, so no other row gets a line. It is the fix for the reported defect -- a figure showing no horizontal
/// line at all -- without inventing a figure-wide y, which would mean a different value in
/// every row: the line appears where the pointer is, and no row ever shows another row's y.
///
/// `y_in_row` is a data coordinate on `hovered_row`'s y axis 0, exactly as `Crosshair::y`'s
/// two-coordinate placement is; a host reads it from the row's own y transform at the
/// pointer's pixel row. `hovered_row` outside `[0, row_count())` is treated as "no row under
/// the pointer": every row gets the vertical line and none gets a horizontal one, the same
/// pointer's pixel row. `hovered_row` outside `[0, plot_count())` is treated as "no plot under
/// the pointer": every plot gets the vertical line and none gets a horizontal one, the same
/// state the single-coordinate overload produces.
///
/// Refused, changing nothing, when `x` is not a number -- matching `set_crosshair(x, active)`
@@ -203,9 +280,10 @@ class Figure {
/// How many groups there are, excluding the unlinked group 0.
u32 x_link_count() const noexcept { return static_cast<u32>(links_.size()); }
/// Moves one row into a link group and applies that group's range to it at once,
/// so the row is on the group's window from this call rather than from the next
/// frame. `group` 0 takes the row back out of its group, leaving it with the range
/// Moves one plot into a link group and applies that group's range to it at once; `row`
/// is the **flattened plot index** (see `plot_count()`), which is the row index only for a
/// figure with one cell per row. The plot is on the group's window from this call rather
/// than from the next frame. `group` 0 takes the plot back out of its group, leaving it with the range
/// it holds now.
///
/// A group with no range yet -- one that no row and no `set_x_link_range()` has
@@ -219,7 +297,7 @@ class Figure {
/// per-row window. `XFollow::Off` is the group's to set
/// (`set_x_link_follow()`), not the axis'.
Status link_x(u32 row, u32 group);
/// The group a row is in, or 0 when it is in none.
/// The group the plot with flattened index `row` is in, or 0 when it is in none.
u32 x_link(u32 row) const noexcept;
/// Sets a group's shared range and pushes it to every member.
@@ -246,6 +324,22 @@ class Figure {
/// A group's follow state. `Off` for group 0 and for a group that does not exist.
XFollow x_link_follow(u32 group) const noexcept;
/// Makes the cells of every split row (two or more) share their y axis 0: one range
/// and one auto scale state per row, default off. A range set on any member -- a
/// wheel, a drag, `Plot::set_y_range()` -- becomes the row's and is pushed to all of
/// its cells at the next `layout()`; the row is auto or manual as one, and while it is
/// auto it fits the union of every cell's visible traces (a cell with none is ignored;
/// padding and log handling are the plot's own, via `Plot::fit_y_bounds()`). The group,
/// not the plot, owns auto scale: members' own autoscale is suppressed, because each
/// would fit only its own traces and the cells would fight over the range. Turning it
/// on or off forgets the groups' state; a row of one cell is never a group.
void set_share_row_y(bool on);
bool share_row_y() const noexcept { return share_row_y_; }
/// Whether figure row `row` is auto scaled as a group. True for a row that is not a
/// group -- fewer than two cells, sharing off, or a row that does not exist -- so
/// "auto" is also the answer when there is no shared state to be manual.
bool row_y_auto(u32 row) const noexcept;
private:
/// One link group: the range its members share, the scroll state behind that
/// range while it follows, the rows in it (ascending) and, in step with them, the
@@ -302,7 +396,35 @@ class Figure {
/// a following group's window, push both to all members.
void sync_x_links();
/// A split row's shared y axis: the range last pushed to its cells (the value the
/// next frame compares each cell against, so a gesture is told from the group's own
/// write -- the same device as `XLink::pushed`), whether the group has been
/// initialised from its first cell, and whether it is auto scaled.
struct RowY {
Range pushed{};
bool have = false;
bool auto_scale = true;
};
/// Advances every split row's y group for this frame: adopt what a cell was changed
/// to (a range or an auto flag), fit the union of the cells' traces while the group
/// is auto, push the result to all cells. Runs before any `Plot::layout()`, which is
/// where a plot would otherwise autoscale on its own.
void sync_row_y();
/// Fills the row bookkeeping (`cells_per_row_`, `row_first_`, `cell_weights_`) from a
/// per-row cell count, clamping each to at least one. Does not touch `plots_`, which the
/// constructors size from `total_cells()`.
void build_cells(std::span<const u32> cells);
std::vector<Plot> plots_;
std::vector<u32> cells_per_row_;
/// Prefix sums of `cells_per_row_`: `rows + 1` entries, `row_first_[r]` the flattened
/// index of row r's first cell and the last entry the plot count.
std::vector<u32> row_first_;
std::vector<std::vector<f32>> cell_weights_;
bool share_row_y_ = false;
std::vector<RowY> row_y_;
f32 col_gap_ = 0.0f;
/// What `plot()` yields for a row that does not exist, so a stale row can be used
/// without reading out of bounds. Owned rather than a static, so one figure's
/// stray row can never write into another's.
@@ -312,6 +434,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;
};
+361 -11
View File
@@ -2,6 +2,7 @@
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <span>
#include <vector>
#include <plot/core/rect.hpp>
@@ -11,6 +12,7 @@
#include <plot/plot/axis.hpp>
#include <plot/plot/crosshair.hpp>
#include <plot/plot/cursor.hpp>
#include <plot/plot/density.hpp>
#include <plot/plot/grid.hpp>
#include <plot/plot/legend.hpp>
#include <plot/plot/measure.hpp>
@@ -38,6 +40,13 @@ namespace plot {
/// are for a host that wants one without the other.
enum class XStrip { Both, LabelsOnly, TitleOnly, None };
/// Which parts of the left y chrome `Plot::layout()` reserves. `Both` is a plot drawn on
/// its own. `None` reserves no label width, no title column and ignores the figure's common
/// gutter: it is the mode of every cell of a split row but the first when the figure shares
/// the row's y axis (`Figure::set_share_row_y`), where the first cell draws the one axis.
/// The cell's ticks still refresh and its grid still builds.
enum class YStrip { Both, None };
/// Whether a strip mode reserves the label strip. One rule, read by `Plot::layout()`
/// when it sizes the strip and by the renderer when it decides whether to stage the
/// labels -- a row that reserves none would otherwise stage them over its own data.
@@ -49,6 +58,28 @@ constexpr bool x_strip_has_title(XStrip mode) noexcept {
return mode == XStrip::Both || mode == XStrip::TitleOnly;
}
/// One span of the primary x axis the host has declared invalid, in **data** coordinates --
/// `Region`'s convention. In udpscope's case a reserved hole: samples the stream's counter says
/// exist and that have not been delivered.
///
/// `repaired` says the host has since filled the span's samples in. Such a span is **still
/// reported**, because the shading is a record of what the window was missing, but it is drawn in
/// the fainter of the two tints -- so a reader can distinguish a gap that is still empty from one
/// that has since been repaired, and from a span that has left the list entirely.
struct InvalidSpan {
f64 x0 = 0.0;
f64 x1 = 0.0;
bool repaired = false;
};
/// One invalid span's measured shading: the rect, clipped to the axes' columns, and the state that
/// chooses its tint. A struct and not a bare `Rect` for `RegionLayout`'s reason -- the renderer
/// draws exactly what the layout measured and derives nothing of its own.
struct InvalidSpanLayout {
Rect fill;
bool repaired = false;
};
/// Pixel geometry of a plot's chrome, all of it in the same pixel space as
/// `Rect` (origin top-left) and therefore in **device** pixels: everything here is
/// what the renderer draws with, already converted from the logical metrics in
@@ -88,6 +119,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
@@ -100,13 +136,29 @@ struct PlotLayout {
/// controller's hit test reads the very same rects, so where a region is drawn and
/// where a pointer can grab it cannot drift apart.
std::vector<RegionLayout> regions;
/// Every invalid span's shading, in `Plot::invalid_spans()` order and clipped to the axes'
/// columns. Measured here for the regions' first reason -- the frame draws exactly these
/// numbers -- and reserving nothing, so a hole cannot move the strips the labels were
/// measured into. A span entirely outside the window contributes no rect at all, so this is
/// not index-aligned with `invalid_spans()` and nothing needs it to be: there is no hit test
/// on a band and no label beside it. Each entry carries the span's `repaired` state alongside
/// its rect, so the tint is chosen where the geometry is measured rather than looked up again
/// at draw time.
std::vector<InvalidSpanLayout> invalid_spans;
/// 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.
CrosshairLayout crosshair;
/// The box-zoom frame, when a modified drag has one open: the area the gesture is framing, in
/// viewport pixels, already widened along whichever axis the modifier will *not* zoom (see
/// `input::Controller::selection_preview()`, which is where that is decided). Here rather than
/// derived by the renderer, for the regions' reason -- the frame draws exactly these numbers -- and
/// left as the zero rect whenever no gesture is open, which is what both the fill and the four
/// border segments read as "draw nothing".
Rect selection;
};
/// What a pointer is on, as of the last pointer event: the cursor marker under it, the
@@ -152,8 +204,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
@@ -185,6 +245,12 @@ class Plot {
void set_x_strip_mode(XStrip mode) noexcept { x_strip_ = mode; }
XStrip x_strip_mode() const noexcept { return x_strip_; }
/// Which parts of the left y chrome `layout()` reserves; `YStrip::Both` by default. With
/// `YStrip::None` the y label width, the y title column and the figure's common gutter are
/// all zero, and the renderer stages no y labels or title (`y_label_area.w == 0`).
void set_y_strip_mode(YStrip mode) noexcept { y_strip_ = mode; }
YStrip y_strip_mode() const noexcept { return y_strip_; }
/// The font layout() measures tick labels with. Borrowed, not owned, and may be
/// null -- then the label areas fall back to PlotLayout's defaults and no string
/// is measured: a plot with no renderer lays out with whatever the host sets
@@ -221,6 +287,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
@@ -375,6 +448,20 @@ class Plot {
/// 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;
/// The y-side twin of `set_x_link_member()`: publishes that this y axis is a member
/// of its row's shared-y group (`Figure::set_share_row_y()`). A linked y axis is
/// never fitted by `layout()` -- the group owns its range and auto scale, and fits
/// the union of every member's traces -- but a gesture or `set_y_range()` still
/// writes it, which is how the group learns of a change. A no-op for an axis that
/// does not exist. Not for callers outside the figure.
void set_y_link_member(u32 axis, bool linked) noexcept;
/// Union of the bounds of the visible traces bound to y axis `axis`: what a shared-y
/// group collects from each member. False -- and `out` untouched -- when none.
bool y_data_bounds(u32 axis, Range& out) const;
/// Fits y axis `axis` to `bounds` the way `auto_fit_y()` fits it to its own traces
/// (same padding, same log handling), leaving `auto_scale` alone. False -- range
/// untouched -- when the axis does not exist or cannot be fitted to `bounds`.
bool fit_y_bounds(u32 axis, Range bounds);
/// 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
@@ -464,6 +551,152 @@ 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 caption (see `Cursor::label`): a vertical marker's badge text -- a horizontal
/// ruler writes its own value and needs no caption to be readable. Rendered on a
/// backplate, and labels whose backplates meet are merged into one panel with one line per
/// label (see the renderer), which is what keeps a marker pair half a division apart
/// readable instead of a smear of two texts over one column. An empty string removes the
/// caption; an id that names no cursor is ignored, like every other setter here.
void set_cursor_label(u32 id, std::string label);
/// 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_; }
/// Spans of the primary x axis the host has declared invalid -- in udpscope's case a reserved
/// hole: samples the stream's counter says exist and that have not been delivered. Each span's
/// `x0`/`x1` are in **data** coordinates, `Region`'s convention, and is shaded behind
/// the traces and over the grid.
///
/// Deliberately *not* a `Region`: a region is interactive -- draggable bounds, hot zones, a
/// label, a cached statistic, a controller hit test -- and a plot can carry dozens of holes at
/// once, which would put a hundred-odd grab targets in the way of every pointer event for
/// marks nobody can usefully drag. This is passive shading and nothing else.
///
/// The span is copied, so the caller's storage need not outlive the call. Spans need not be
/// sorted and may overlap; an empty call clears the shading.
///
/// A span whose `repaired` flag is set is shaded more faintly (`Style::invalid_repaired_fill`
/// against `Style::invalid_fill`). Clearing the shading is the host's decision and nothing
/// here retires a span on its own: a repaired span is drawn until the host stops sending it.
void set_invalid_spans(std::span<const InvalidSpan> spans);
std::span<const InvalidSpan> invalid_spans() const noexcept { return invalid_spans_; }
// --- phosphor density --------------------------------------------------
/// Turns this plot's **phosphor density** on or off, and states how it fades. Off -- the
/// default -- is the plot exactly as it was: no grid is allocated, no geometry is deposited
/// and no frame uploads anything.
///
/// On, the plot keeps an intensity grid behind each of its traces, in that trace's own colour
/// (see `DensityGrid` for the resolution, the bound on it and the per-cell meaning, and
/// `DensityConfig` for the per-trace colour and the memory the grids cost), and deposits the
/// acquisition on screen into it with `accumulate_density()`. While a density is on the
/// renderer draws **no solid trace** over it: the trace ink *is* the image, so the newest
/// acquisition is deposited like every other and fades with the rest -- a trace left drawn on
/// top of its own density could never fade, which is what a report of "the last acquisition
/// never fades" was. It is deliberately *not* a set of retained traces -- the library has the
/// trace geometry in hand, so a density costs one grid per trace whatever the trigger has done,
/// where retaining the acquisitions would cost a window copy each.
///
/// Like `set_invalid_spans()` it reserves **no layout space** and nothing on it can be picked:
/// it is drawn inside the axes it was sized to, and a pointer neither finds it nor is found by
/// it. A cell is a device pixel of that axes rect while the rect is under `kDensityCellCap` and
/// a block of them past it, which the renderer scales back up with a linear filter -- the image
/// reserves no space either way, so the bound changes its resolution and nothing about the
/// layout.
///
/// **Disabling drops what was accumulated at once**, not at the next acquisition: a caller who
/// has stopped paying for the display is not left holding a stale image of it, which is the
/// rule the app's own `set_persist_depth()` follows for the very same reason. Re-enabling
/// starts from an empty grid -- the image is sized to the screen, and a screen it has not been
/// sized against yet is a screen it has nothing to say about.
void set_density(const DensityConfig& config);
/// The configuration, as set.
const DensityConfig& density() const noexcept { return density_; }
/// The accumulated image **of a trace**, for a host that wants to draw it or read it back. There
/// is one per trace, in trace order, because each is drawn in that trace's own colour (see
/// `DensityConfig`); an out-of-range trace reads an empty (0x0) image. Empty until the first
/// `accumulate_density()` on a laid-out plot, and dropped by `set_density()` off.
const DensityGrid& density_grid(u32 trace) const noexcept {
return trace < density_grids_.size() ? density_grids_[trace] : empty_density_grid();
}
/// Deposits **the acquisition on screen** into each trace's grid: every visible trace of this
/// plot, the newest acquisition included, over the x window its own axis is showing, 1.0 into
/// every cell it covers -- **once per cell per sweep**, however many of that trace's own samples
/// crossed it, so a cell's value is how many traces have covered it since the last decay and not
/// how much the beam moved (see `DensityGrid`; the dwell question is
/// `Style::intensity_grading`'s). Called once per *acquisition*, which is the cadence the grids
/// count in: a caller that deposited every frame would add one sweep sixty times a second, and
/// the per-acquisition fade would have nothing to measure itself against. The renderer draws no
/// polyline for a trace whose density is on (see `set_density()`), so this deposit is the whole
/// of what the newest acquisition looks like -- and it fades with the rest.
///
/// The geometry is the frame's own. The visible x range is mapped onto sample indices through
/// the series' own x (`Series::neighbours()`), never the `dx()` grid, and never the whole
/// series -- exactly as `Renderer::draw_traces()` maps it -- and the gathered points are taken
/// to pixels through the transforms the plot's last `layout()` pinned. So this belongs *after*
/// a layout: a plot that has never been laid out has no axes rect to size the grid to and
/// deposits nothing.
///
/// The grid is sized to that axes rect, **the rect in device pixels up to `kDensityCellCap`
/// cells** (`DensityGrid`), and a grid of the wrong size is reallocated -- which drops what it
/// held, because the image is of the axes rect and a new rect is a new image. Under
/// `DensityDecay::PerAcquisition` the fade happens here, before the deposit, so the acquisition
/// just drawn is the brightest. A NaN sample is a gap: it deposits nothing and joins nothing,
/// the library-wide policy.
void accumulate_density();
/// Fades the grid by `dt_s` of **stream seconds** -- the clock the x axis reads, so a caller
/// advancing the display by its frame's own `dt` gets a fade that does not depend on the frame
/// rate. Only `DensityDecay::TimeConstant` fades here: `PerAcquisition`'s factor is applied by
/// `accumulate_density()`, because there is no acquisition in a frame that merely passed.
/// A non-positive `dt_s` and a non-positive `time_constant_s` are both no-ops, so a caller that
/// has not measured a frame yet cannot wipe the grid by accident.
void decay_density(f64 dt_s);
/// Empties the grid (and leaves the configuration alone). What a caller does when the data
/// under the image is replaced: the screen-relative image of another stream's sweeps says
/// nothing about this one.
void clear_density() noexcept;
/// The measurement for `id`. Re-evaluated only when a cursor moved, when the
/// measure was added, or when the series underneath it changed: a different
@@ -585,6 +818,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_; }
@@ -628,12 +875,35 @@ class Plot {
/// calls this when the pointer leaves the axes.
void clear_crosshair() noexcept { crosshair_.active = false; }
/// The box-zoom frame the host is showing, in **viewport pixels**, or the zero rect for none.
///
/// Set by the host from `input::Controller::selection_preview()` while a modified left drag is
/// choosing a range, and taken off when it ends. It is the *host's* job and not the plot's because
/// the plot has no controller and learns nothing about pointer, button or modifier (see
/// `plot::input`): this is the receiving end of that boundary, exactly as `set_hot_handles()` is.
/// Drawn as a frame -- a slightly opaque fill under a light border -- over everything else inside
/// the axes, so the area being framed stays legible against the traces it covers.
void set_selection_rect(Rect rect) noexcept { selection_ = rect; }
/// Takes the frame off, leaving the same state as before any drag: the next one starts from nothing.
void clear_selection_rect() noexcept { selection_ = Rect{}; }
/// The frame as set -- what `layout()` measures into `PlotLayout::selection`, and what a host or a
/// test reads back without paying a layout.
Rect selection_rect() const noexcept { return selection_; }
/// Switches the readout panel off -- or back on -- without moving the crosshair: the lines and
/// the per-trace markers stay, the panel is not measured and no row is listed. A host that wants
/// the pointer's column marked but not a panel over its data sets this once; it is deliberately
/// **not** part of `set_crosshair()`, so placing a hover cannot silently change it.
void set_crosshair_readout(bool on) noexcept { crosshair_.show_readout = on; }
/// Writes or clears the two **axis badges** (see `Crosshair::axis_labels`): the y value in
/// the y label strip and the x value in the x label strip, at the heights and columns the
/// crosshair's lines meet the axes at. Takes effect on the next `layout()`. **Not** part of
/// `set_crosshair()`, for the same reason the readout flag is not.
void set_crosshair_axis_labels(bool on) noexcept { crosshair_.axis_labels = on; }
/// The axis-badge flag, as set.
bool crosshair_axis_labels() const noexcept { return crosshair_.axis_labels; }
/// Overrides this plot's crosshair stroke and dash, in logical pixels, without touching the
/// global style every other plot draws with (spec §17): each field left negative in `s`
/// defers to the matching `Style::crosshair_*` metric, so `CrosshairStyle{}` (the default)
@@ -645,20 +915,31 @@ class Plot {
/// dash, which live on `CrosshairLayout` because they depend on `ui_scale`.
const CrosshairStyle& crosshair_style() const noexcept { return crosshair_.style; }
/// Chooses what a readout's numbers *are*: one of the series' own samples, or a value
/// computed between two of them. `CrosshairValue` argues the two cases and says where each
/// puts the marker; the default is `NearestSample`.
///
/// Deliberately **not** part of `set_crosshair()`, like the readout switch and the style
/// override: it is a statement about the data, so a pointer move must not be able to change
/// it. It takes effect on the next `layout()` and reaches `crosshair_value()` immediately.
void set_crosshair_value_mode(CrosshairValue mode) noexcept { crosshair_.value_mode = mode; }
/// The readout's value mode, as set -- see `set_crosshair_value_mode()`.
CrosshairValue crosshair_value_mode() const noexcept { return crosshair_.value_mode; }
/// The crosshair's state: where it is, which lines it draws and whether the readout is
/// on. Read-only -- `set_crosshair()` and `clear_crosshair()` are how it moves -- so the
/// coordinates it holds and the geometry `layout()` places from them cannot disagree.
const Crosshair& crosshair() const noexcept { return crosshair_; }
/// The **interpolated** value of `trace`'s series at a data `x`: `crosshair_span()`
/// between the two adjacent samples, then `crosshair_interpolate()` -- the same lookup
/// the readout's rows are built from, exposed for a host that wants the number for
/// itself (a status line, a tooltip of its own).
/// The value this plot's readout would report for `trace` at a data `x`: the nearer
/// bracketing sample's y, or the blend of the two, according to
/// `crosshair_value_mode()` -- the same lookup the readout's rows are built from, exposed
/// for a host that wants the number for itself (a status line, a tooltip of its own).
///
/// False, leaving `out_y` untouched, for an id that names no trace, a trace with no
/// series, and an `x` the series has no sample at -- the cases `CrosshairSpan::valid`
/// documents, plus the trace itself. Never a clamped or nearest-sample answer: see
/// `crosshair_span()` for why an out-of-window x is not a value.
/// documents, plus the trace itself. Never a clamped answer: see `crosshair_span()` for why
/// an out-of-window x is not a value.
///
/// O(1) and allocation-free once the plot has looked a value up before: the span is
/// index arithmetic and the samples are read through one `Series::gather()` of at most
@@ -666,6 +947,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
@@ -756,6 +1048,30 @@ class Plot {
/// region's pixel geometry is derived -- the controller hit-tests what this
/// measured instead of mapping the bounds through the transform again.
void build_regions();
/// Measures every invalid span into `layout_.invalid_spans`: its interval clipped to the axes'
/// columns, with a one-device-pixel floor, and the state that picks its tint. Here and not in
/// the renderer for `build_regions()`' reason -- one x mapping, the frame's own.
void build_invalid_spans();
/// Deposits one trace's visible window into its own density grid: the same index window
/// `Renderer::draw_traces()` maps -- `neighbours()` for the ends, the 64-spp gate for the LOD
/// level -- gathered into `density_points_`, mapped to pixels through the transforms of the
/// plot's last layout, and left with `deposit_density_segment()`. A trace with no series, no
/// visible window or non-positive `dx()` deposits nothing.
void deposit_density_trace(const Trace& trace, DensityGrid& grid);
/// Deposits one already-mapped segment of a trace polyline into `grid`: for every grid column
/// the segment crosses, the rows it covers in that column, +1 each. The columns it also *clips*:
/// a column whose slice of the segment lies entirely outside the axes is skipped, so a trace off
/// the top of the plot deposits nothing rather than a band along the edge, and a segment with
/// both ends past an edge keeps the part that is inside. A degenerate segment (`x0 == x1`, or a
/// non-finite coordinate) fills the single column it is in.
void deposit_density_segment(f32 x0, f32 y0, f32 x1, f32 y1, DensityGrid& grid) noexcept;
/// Grows `density_grids_` -- one grid per trace -- to the last layout's axes rect in device
/// pixels, clearing a grid when the size moved (a *different* screen is a different image), and
/// returns whether any grid is usable. The one place the grids are allocated.
bool size_density_grids();
/// The image an out-of-range trace reads (see `density_grid()`): a shared empty grid, so the
/// accessor need not hand back a reference into nothing.
static const DensityGrid& empty_density_grid() noexcept;
/// Exact statistics of `region` over `series`, or an invalid result when the
/// interval covers no sample. The one place the region arithmetic lives.
static RegionStats evaluate_region_stats(const Region& region, const data::Series& series);
@@ -773,7 +1089,7 @@ class Plot {
/// never names a trace.
void build_legend();
/// Measures the crosshair into `layout_.crosshair`: the two lines' stroke boxes, one row
/// per visible trace with its interpolated value and marker, and the readout panel --
/// per visible trace with its nearest sample's value and marker, and the readout panel --
/// placed beside the crosshair, flipped where it would cross an edge and clamped, so
/// every rect it produces is inside `axes`. A plot with no visible trace still gets its
/// lines: the rows are the readout's, not the crosshair's.
@@ -783,11 +1099,18 @@ 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;
/// Which parts of the left y chrome are reserved at all; see set_y_strip_mode().
YStrip y_strip_ = YStrip::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_{};
@@ -798,6 +1121,28 @@ class Plot {
std::vector<MeasureSlot> measures_;
std::vector<Annotation> annotations_;
std::vector<Region> regions_;
std::vector<InvalidSpan> invalid_spans_;
/// The phosphor density: its configuration, the grids it accumulates into, and the scratch the
/// one visible window being deposited is gathered into. **One grid per trace** -- the image is of
/// the axes rect but each trace's is tinted with that trace's own colour (see `DensityConfig`), so
/// the memory is `traces * columns * rows * 4` bytes and not one plot's worth -- and each is
/// bounded by `kDensityCellCap`. The vector tracks `traces_` and outlives the frames its images
/// were drawn in, which is the whole point of accumulating into an image rather than retaining
/// traces.
DensityConfig density_{};
std::vector<DensityGrid> density_grids_;
/// Scratch for `deposit_density_trace()`: interleaved (x, y) of one gathered window, re-used
/// across traces and frames so a steady-state accumulation allocates nothing. Bounded by the
/// window, not the series, as `Renderer::gather_` is.
std::vector<f32> density_points_;
/// The per-sweep dedupe's stamps, one per grid **row** and shared by every trace's deposit: the
/// key of the last (trace, column) to deposit at that row, so one acquisition's own samples
/// cannot count a cell twice (see `deposit_density_segment()`). One `u64` a row -- a few kB at
/// any window -- and not one per cell, which would double a grid's memory for a comparison.
std::vector<u64> density_stamp_;
/// The sweep's own number, bumped once per trace deposited: the high half of every stamp key, so
/// every trace of a plot, and every acquisition, deposits exactly once per cell it covers.
u32 density_trace_ = 0;
/// The (region, trace) statistics cache, filled on demand by `region_stats()`
/// and so mutable: a lookup on a const Plot may cache, exactly as `measures_`
/// does. Entries are created for the pairs a caller actually asks about, never
@@ -812,6 +1157,11 @@ class Plot {
/// The crosshair's state (see Crosshair): at most one per plot, placed by
/// `set_crosshair()` and read back by `crosshair()` and `layout()`.
Crosshair crosshair_{};
/// The host's box-zoom frame (`set_selection_rect()`): viewport pixels, the zero rect when no
/// modified drag is choosing a range. State rather than a `PlotLayout` field alone because a host
/// sets it on every drag motion and reads it back; `layout()` only carries it through.
Rect selection_{};
/// The samples `crosshair_value()` reads a span through: `Series::gather()` appends
/// `(x, y)` pairs and never clears, so the lookup clears this and refills it with the at
/// most two samples a span covers. Sized to hold them from construction -- four floats is
+18 -4
View File
@@ -48,7 +48,10 @@ void format_si(f64 v, char* buf, usize n, const char* unit = nullptr);
/// field is derived from that one number, so no field can ever reach its radix:
/// 119.5 s is `2:00`, not `1:60`, and 3599.5 s is `1:00:00`.
///
/// Shapes, by magnitude: below 1 s `500ms`; below 60 s `1.5s`; below 1 h `1:30`;
/// Shapes, by magnitude: below 1 s the prefixed unit that keeps the mantissa in
/// `[1, 1000)` -- `500ms`, `5ms`, `500us`, `5us`, `5ns`, `5ps` (the micro sign is
/// UTF-8 U+00B5, as in `format_si`) -- with an exact zero printed as `0` rather
/// than as a zero of some unit; below 60 s `1.5s`; below 1 h `1:30`;
/// at or above 24 h, as a decimal day count `2.5d`. Between 1 h and 24 h the
/// shape is `1:01:01` -- and because a value that rounds up to 24 h takes the day
/// shape, the hour field of that shape is always below 24. The 60 s and 1 h
@@ -68,14 +71,25 @@ inline constexpr usize kMaxTicks = 256;
/// `include_minors` adds, for grid geometry, the minor ticks subdividing each
/// major step by 5 (by 4 when the step's leading digit is 2). Without it `out`
/// holds exactly the labelled major ticks -- the labelled set a caller draws
/// numbers for, with no unlabelled entries to filter. Either way the total is
/// capped at kMaxTicks.
/// numbers for, with no unlabelled entries to filter. The cap counts **minor**
/// ticks only: a range dense enough to exceed it loses its subdivisions, never
/// its labelled majors -- every major at or below `hi` is emitted, so the far
/// end of the axis keeps its numbers.
///
/// A range that straddles the origin gets a tick of **exactly** `0.0` there, labelled
/// `0`. The walk cannot land on the origin by itself -- it starts at
/// `ceil(min / stride) * stride`, which is not representable whenever the stride is
/// not a power of two -- so the tick that means zero arrives a few ULP off and would
/// otherwise be labelled from the far end of the SI table (`-139a` between `-500m` and
/// `500m`). Every tick is an integer multiple of the stride, so anything within a
/// millionth of one *is* the origin and nothing else; a range whose ticks are
/// legitimately that small keeps them.
///
/// Hostile input clears `out` and returns: a non-finite endpoint, `max <= min`
/// (a zero or negative span, which includes the collapsed range of a
/// single-sample series), a non-positive `px` or `min_px`, and a span whose step
/// overflows to infinity.
void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out,
bool include_minors = false);
bool include_minors = false, f64 origin = 0.0);
} // namespace plot
+35 -2
View File
@@ -9,6 +9,16 @@
namespace plot {
/// How a trace's geometry is gathered from its LOD pyramid. The level choice
/// is unchanged; this picks what a bucket contributes (see
/// `data::GatherMode`).
using TraceMode = data::GatherMode;
/// A marker shape for per-sample points. Drawn with the trace's own colour
/// through the shared line pipeline; `marker_every == 0` strides
/// automatically (one marker per pixel column at most).
enum class Marker { None, Cross, Circle, Square };
/// How a caller asks for a trace. The series pointer is borrowed and must outlive
/// the plot's use of it; `axis_x`/`axis_y` index the plot's x and y axis lists.
struct TraceDesc {
@@ -16,7 +26,8 @@ struct TraceDesc {
Color color = Color::hex(0x4FA3FF);
/// The stroke's width in **logical** pixels, like every stroke in `Style` (see
/// that struct's unit rule): the renderer converts it once, so a trace has the
/// same weight against the axis lines whatever the display's DPI.
/// same weight against the axis lines whatever the display's DPI. `0` draws
/// the markers only -- a scatter trace.
f32 width = 1.5f;
u32 axis_x = 0, axis_y = 0;
/// What the trace is called, for the legend. Empty means "do not list it", which
@@ -26,6 +37,20 @@ struct TraceDesc {
/// unchanged, exactly as `AxisDesc`'s trailing format does (and, in a build with
/// `-Wextra`, warning-clean).
std::string name{};
/// Downsample policy for the LOD buckets (Envelope by default).
TraceMode mode = TraceMode::Envelope;
/// Dash pattern in logical pixels: ink, then gap. `dash_off <= 0` (and the
/// default) is a solid line -- no geometry is added, the dash is a
/// fragment-shader discard.
f32 dash_on = 0.0f;
f32 dash_off = 0.0f;
/// Per-sample markers (see `Marker`). `marker_every == 0` strides
/// automatically -- at most about one marker per pixel column -- so a dense
/// trace never stages more markers than it has columns; a positive value
/// draws every n-th gathered sample literally.
Marker marker = Marker::None;
f32 marker_size_px = 3.0f;
u32 marker_every = 0;
};
/// A series bound to a colour, width and axis pair. The series pointer may be
@@ -35,13 +60,21 @@ struct Trace {
const data::Series* series = nullptr;
Color color = Color::hex(0x4FA3FF);
/// Logical pixels, as in `TraceDesc::width`: converted to device pixels by the
/// renderer's one `Style::device()` call per stroke.
/// renderer's one `Style::device()` call per stroke. `0` = scatter (markers
/// only, if any).
f32 width = 1.5f;
u32 axis_x = 0, axis_y = 0;
/// The legend's entry for this trace, empty when it has none. A name does not
/// make a trace drawable: the legend lists a trace only when it is visible, so a
/// null series is skipped there exactly as it is by every other consumer.
std::string name{};
/// Same fields as `TraceDesc`, applied by the renderer.
TraceMode mode = TraceMode::Envelope;
f32 dash_on = 0.0f;
f32 dash_off = 0.0f;
Marker marker = Marker::None;
f32 marker_size_px = 3.0f;
u32 marker_every = 0;
/// True when the trace has a series with at least one sample to draw.
bool visible() const noexcept { return series != nullptr && series->size() > 0; }
+15 -8
View File
@@ -35,10 +35,11 @@ struct LineInstance {
/// rect onto the two the same way round, so a cell is never mirrored. `(1, 0)`
/// is unrotated text, and `(0, -1)` is a run advancing up the screen, i.e. a
/// 90 degree counter-clockwise label such as a y-axis title.
/// - `(r, g, b, a)` is the run's tint, straight alpha. Per instance rather than
/// a uniform, so one draw can carry a readout whose values are coloured per
/// row (each row's own trace) while its labels stay the axis text colour.
///
/// Six floats, so the whole text pipeline still fits one instanced draw call per
/// frame with rotation free: a matrix per glyph would be three times the bytes
/// for the same pixels.
/// Ten floats, still one instanced draw call per frame.
struct TextInstance {
f32 x;
f32 y;
@@ -46,10 +47,14 @@ struct TextInstance {
f32 v;
f32 ax;
f32 ay;
f32 r;
f32 g;
f32 b;
f32 a;
};
static_assert(sizeof(LineInstance) == 4 * sizeof(f32), "line instance must stay 4 floats");
static_assert(sizeof(TextInstance) == 6 * sizeof(f32), "text instance must stay 6 floats");
static_assert(sizeof(TextInstance) == 10 * sizeof(f32), "text instance must stay 10 floats");
/// How a store's staged instances reach the GPU. Semantics are identical either
/// way -- only the transfer differs, and `Renderer::init()` picks one from
@@ -137,7 +142,9 @@ class InstanceStore {
/// buffer cannot leave the vertex array bound to a deleted one), and `slot_epoch_`
/// advances on every upload that lands in a new slot. Both counters only ever
/// increase, so their sum changes whenever either does, and never repeats.
u32 epoch() const noexcept { return buffer_.generation() + slot_epoch_; }
/// 64-bit: the sum of two counters that advance every upload of a streaming
/// frame loop must not be able to wrap into a stale value.
u64 epoch() const noexcept { return buffer_.generation() + slot_epoch_; }
void bind() const noexcept { buffer_.bind(); }
bool persistent() const noexcept { return mode_ == UploadMode::PersistentMapped; }
@@ -165,7 +172,7 @@ class InstanceStore {
u32 frame_uploads_ = 0;
/// Times an upload landed in a new slot. Kept apart from the buffer's own
/// identity (see `epoch()`) so neither has to know about the other.
u32 slot_epoch_ = 0;
u64 slot_epoch_ = 0;
u8* mapped_ = nullptr; ///< base of the mapped ring, null in `Orphan` mode
/// Guards every slot the frame just ended wrote. Recorded once, after its last
/// draw; waited on once, at the start of the next frame.
@@ -186,7 +193,7 @@ class LineBuffer {
}
bool mark_drawn() noexcept { return store_.mark_drawn(); }
usize slot_offset() const noexcept { return store_.slot_offset(); }
u32 epoch() const noexcept { return store_.epoch(); }
u64 epoch() const noexcept { return store_.epoch(); }
bool persistent() const noexcept { return store_.persistent(); }
void bind() const noexcept { store_.bind(); }
@@ -206,7 +213,7 @@ class TextBuffer {
}
bool mark_drawn() noexcept { return store_.mark_drawn(); }
usize slot_offset() const noexcept { return store_.slot_offset(); }
u32 epoch() const noexcept { return store_.epoch(); }
u64 epoch() const noexcept { return store_.epoch(); }
bool persistent() const noexcept { return store_.persistent(); }
void bind() const noexcept { store_.bind(); }
+10 -8
View File
@@ -2,6 +2,7 @@
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <plot/core/color.hpp>
#include <plot/core/types.hpp>
#include <plot/math/vec2.hpp>
#include <plot/render/text.hpp>
@@ -60,7 +61,7 @@ class LineBatch {
/// Like `LineBatch` it keeps its capacity across frames. Each staged glyph is
/// one instance: the glyph cell's top-left corner in pixels, the glyph rect's
/// normalised top-left in the atlas and the cell's rotation basis, interleaved as
/// `x,y,u,v,ax,ay` — the layout the text shader declares (see `TextInstance` in
/// `x,y,u,v,ax,ay,r,g,b,a` — the layout the text shader declares (see `TextInstance` in
/// buffer.hpp).
///
/// Every glyph a batch stages is read through one atlas rect of one size — its
@@ -92,8 +93,9 @@ class TextBatch {
/// rasterised them. `axis` is the direction the run advances in, in pixel
/// space; it rotates the cell (see `TextInstance`), and is normalised here, so
/// any non-zero direction works and a zero or non-finite one means "unrotated".
void add_glyph(Vec2 pen_px, const Glyph& glyph, f32 px = 1.0f,
Vec2 axis = {1.0f, 0.0f});
/// `color` tints this run's glyphs (per-vertex `a_colour`, see `TextInstance`).
void add_glyph(Vec2 pen_px, const Glyph& glyph, f32 px = 1.0f, Vec2 axis = {1.0f, 0.0f},
Color color = {1.0f, 1.0f, 1.0f, 1.0f});
/// Stages every codepoint of `utf8` from `pen_px` and returns the pen past the
/// last glyph, so the run is exactly `font.measure(utf8) * px` long measured
@@ -104,14 +106,14 @@ class TextBatch {
/// `next_codepoint()` and `glyph()`, so a run stages exactly what `measure()`
/// summed. A codepoint the backend has no glyph for stages nothing and moves
/// the pen nowhere — a run is never wider than the glyphs it can draw. A null
/// or empty string stages nothing and returns `pen_px`.
/// or empty string stages nothing and returns `pen_px`. `color` tints the run.
Vec2 add_text(const char* utf8, Vec2 pen_px, f32 px, const TextBackend& font,
Vec2 axis = {1.0f, 0.0f});
Vec2 axis = {1.0f, 0.0f}, Color color = {1.0f, 1.0f, 1.0f, 1.0f});
/// Glyphs staged so far, one instance each.
u32 glyph_count() const noexcept { return static_cast<u32>(v_.size() / 6u); }
u32 glyph_count() const noexcept { return static_cast<u32>(v_.size() / 10u); }
/// Interleaved `x,y,u,v,ax,ay`, six floats per staged glyph.
/// Interleaved `x,y,u,v,ax,ay,r,g,b,a`, ten floats per staged glyph.
const std::vector<f32>& data() const noexcept { return v_; }
/// The cell the staged glyphs are drawn in: what `set_cell()` declared, or --
@@ -124,7 +126,7 @@ class TextBatch {
private:
/// Stages one cell whose basis the caller has already normalised, so a run and
/// the pen it advances share one rounding of the axis.
void add_cell(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 unit_ux);
void add_cell(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 unit_ux, Color color);
std::vector<f32> v_;
Vec2 cell_uv_{};
+45 -1
View File
@@ -35,8 +35,19 @@ class LineProgram {
/// discard on the interpolated `v_along`, so it costs no geometry, and it is the one uniform
/// that distinguishes the crosshair's stroke from every other line in the library.
void set_dash(Vec2 on_off) const noexcept;
/// `u_aa`: the AA fringe half-width in pixels. The style's `line_aa_px` in
/// device pixels, clamped at 0.25; 1.0 reproduces the historical fringe.
void set_aa(f32 px) const noexcept;
/// `u_color`: stroke colour, alpha included.
void set_color(Color color) const noexcept;
/// `u_snap`: with it on, an **axis-aligned** segment's ink is rounded onto whole
/// pixels along the axis it is constant on, so a hairline lights one column (or
/// row) fully instead of two at half coverage. It is for the chrome that frames a
/// plot -- grid lines, axis lines, panel and legend borders. Anything that reports
/// a data position -- a trace, a cursor, a region edge, the crosshair -- leaves it
/// off, because the rounding moves a line by up to half a pixel. A diagonal segment
/// is untouched whatever this says.
void set_snap(bool on) const noexcept;
private:
gl::Program program_;
@@ -66,7 +77,40 @@ 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.
/// 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_;
};
/// Linked phosphor-density program and its uniform surface: one textured quad over a plot's
/// axes rect. It fails exactly the way the two above do, propagating `Unsupported` and
/// `GLFailure`.
///
/// It is the one pipeline with **no vertex data**: the quad's four corners come from
/// `gl_VertexID`, so a draw is one four-vertex strip whatever the grid's size and there is no
/// buffer to stage or upload. The density itself reaches the GPU as a texture -- one texel per
/// cell -- which is the one thing this pipeline has that the others do not.
///
/// `create()` also points the `u_density` sampler at texture unit 0, which is the unit
/// `plot::gl::Texture::bind()` defaults to, and leaves this program bound. The texture stays
/// with the caller, who owns its lifetime.
class DensityProgram {
public:
bool create(Status* st = nullptr) noexcept;
bool valid() const noexcept { return program_.id() != 0; }
void bind() const noexcept { program_.use(); }
/// `u_viewport`: render target size in pixels.
void set_viewport(Vec2 px) const noexcept;
/// `u_origin` and `u_size`: the quad in `Rect` pixels -- the axes rect's top-left corner and its
/// size, which is the area the image is drawn over (its own grid may hold fewer cells than the
/// rect has pixels: see `kDensityCellCap`). They are two uniforms rather than a rect because a
/// `vec4` uniform would need a fourth setter shape for one caller; a corner and a size are what
/// the vertex shader builds the quad from anyway.
void set_rect(Vec2 origin, Vec2 size) const noexcept;
/// `u_color`: the tint the cells' luminances are multiplied into.
void set_color(Color color) const noexcept;
private:
+295 -33
View File
@@ -29,9 +29,37 @@ 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;
/// Traces drawn as a polyline whose *segments* were graded by slope -- the sparse
/// half of the same phosphor model (`Renderer::stage_trace_slopes`). **Disjoint from
/// `column_traces`**, and therefore from `graded_traces`: a window is either dense
/// enough to reduce onto the pixel grid or it is drawn as a polyline, never both. A
/// host asserting `graded_traces == column_traces` keeps that reading unchanged.
u32 slope_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
/// The phosphor density's own cells, when one was drawn: the **sum** of every trace's
/// `Plot::density_grid()` resolution, zero for a plot with the density off. It is what the
/// *frame* drew, so a host reading the density's cost off a frame reads it here rather than off
/// the grids, which say what is accumulated and not what was spent -- and it is the number that
/// scales with the trace count, since the image is one grid per trace (see `DensityConfig`).
u32 density_cells = 0;
/// The bytes of that image the frame **uploaded**: `columns * rows` when the grid had changed
/// since the last frame and 0 when it had not. It is the one field that separates the two
/// costs of a density -- the conversion and upload on the frame that moved, the single draw
/// call on the frames that did not -- which is exactly the pair a host comparing frame times
/// with the display on and off needs to tell apart.
u32 density_upload_bytes = 0;
};
/// Draws a `Plot` into whatever framebuffer is bound.
@@ -67,13 +95,47 @@ struct FrameStats {
/// a guard -- and the frame reports it (see `last_frame_had_gl_error()`).
///
/// Not thread-safe, and confined to the thread that owns the current context.
/// One cursor label's measured text and placed badge: what `Renderer::collect_cursor_label()`
/// builds and `Renderer::draw_clustered_cursor_labels()` places and draws. A rendering
/// detail of the cursor labels' backplates, declared at namespace scope only because a
/// private nested type cannot be named in an out-of-class member definition's signature.
struct CursorLabel {
std::string text; ///< the one line the backplate carries
Color border{}; ///< the panel's border: the cursor's own colour
f32 anchor = 0.0f; ///< the coordinate the label belongs to (x for a marker, y for a ruler)
f32 w = 0.0f; ///< the measured text width
bool horizontal = false;
/// The placed pen and backplate: centred on the anchor across the mark, nudged onto a
/// free row when a close neighbour's badge is in the way, and clustered into one panel
/// by `draw_clustered_cursor_labels()`.
Vec2 pen{};
Rect box{};
};
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: all three programs, the vertex arrays and
/// their instance buffers, the glyph atlas texture and the density's own 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
@@ -194,12 +256,13 @@ class Renderer {
/// exactly as a stand-alone plot is, and `draw(Plot&)` itself is unchanged for
/// single-plot hosts.
/// The figure's one shared x axis is the **bottom row's own**, so it is drawn by
/// that row's normal path: when the figure says labels belong on the bottom only
/// (the default), the rows above reserve no x chrome and the bottom row stages its
/// own labels and title once, inside its own strip -- the rect
/// `FigureLayout::x_axis_strip` reads back. With that switched off, every row keeps
/// its own chrome and each draws its own labels; there is then no shared axis.
/// A shared x axis is the **bottom row's own** (of a run of rows with the same
/// columns), so it is drawn by that row's normal path: when the figure says labels
/// belong on the bottom only (the default), the rows above that match the row below
/// reserve no x chrome and the bottom row stages its own labels and title once,
/// inside its own strip -- the rect `FigureLayout::x_axis_strip` reads back. A row
/// whose columns differ keeps and draws its own; with that switched off, every row
/// does and there is no shared axis.
/// Styling, the text backend and `ui_scale` are installed on **every** row before
/// the figure computes its layout, so the strips the rows measure and the strokes
@@ -215,7 +278,7 @@ class Renderer {
///
/// The GL state it leaves behind, so a host can embed the plot without guessing:
/// vertex array 0, program 0, `GL_ARRAY_BUFFER` 0, texture unit 0 active and
/// nothing bound to `GL_TEXTURE_2D` on it, blending off, scissoring off, and the
/// nothing bound to `GL_TEXTURE_2D` on it, scissoring off, and the
/// viewport left at the frame's rect (the caller sets its own for the next pass).
///
/// The scissor is the other piece of state a frame moves, and it ends the frame *off*:
@@ -346,12 +409,19 @@ class Renderer {
/// One axis pair's data -> pixel mapping, already shifted so the viewport's
/// top-left corner is the origin (the shaders emit NDC relative to the GL
/// viewport, not the framebuffer).
///
/// `origin_x` is the data-space reference the caller staged its geometry
/// relative to: every data-space x reaches this layer as the f32
/// `to_space(x) - to_space(origin_x)` (B1), so the mapping folds the origin
/// back into the offset uniform -- `offset_x = sx*to_space(origin_x) + tx -
/// viewport.x`, computed in f64 and narrowed once -- and the staged values
/// stay O(visible pixels) however large the absolute x is (an epoch axis).
struct Mapping {
Vec2 scale{1.0f, 1.0f};
Vec2 offset{0.0f, 0.0f};
};
Mapping mapping(const Plot& plot, u32 axis_x, u32 axis_y) const noexcept;
Mapping mapping(const Plot& plot, u32 axis_x, u32 axis_y, f64 origin_x) const noexcept;
/// The identity mapping for geometry already staged in viewport pixels.
Mapping pixel_mapping() const noexcept;
/// Shifts one framebuffer-space pixel position into viewport space.
@@ -370,12 +440,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
@@ -408,13 +493,21 @@ class Renderer {
/// measurement band fills, is the wrong primitive here). A rect with no width or
/// no height stages nothing.
void fill_pixels(const Rect& rect, Color color);
/// Stages one data-space segment, taking the two axes' log flags into account.
void stage_segment(f64 x0, f64 y0, f64 x1, f64 y1, bool log_x, bool log_y);
/// Stages `n` interleaved `x,y` data-space points (a gathered window).
void stage_xy(const f32* xy, u32 n, bool log_x, bool log_y);
/// Lays one run of text out at a framebuffer position and stages it, shifting
/// it into viewport space. Returns the pen past the run.
Vec2 stage_text(Vec2 pos_px, const char* utf8);
/// Stages one data-space segment as f32 values **relative to `origin_x`** in
/// the axis' pre-affine space (`log10` of it on a log axis): the mapping folds
/// `origin_x` back in, in f64, before the single f32 narrowing (B1), so a
/// large-magnitude x -- an epoch axis -- never loses sub-pixel precision.
void stage_segment(f64 origin_x, f64 x0, f64 y0, f64 x1, f64 y1, bool log_x, bool log_y);
/// Stages `n` interleaved `x,y` points of a gathered window (see `Series::gather`): x
/// **relative to `origin_x`**, which the caller asked the gather for, y absolute. On linear
/// axes that is already what the batch wants and the buffer is handed over as it is; a log
/// axis reconstructs the absolute x in f64 per segment and stages
/// `to_space(x_abs) - to_space(origin_x)` -- O(visible pixels), not O(epoch).
void stage_xy(const f32* xy, u32 n, f64 origin_x, bool log_x, bool log_y);
/// Lays one run of text out at a framebuffer position and stages it into
/// `batch`, shifting it into viewport space and tinting it `color` (the
/// per-instance `a_colour`). Returns the pen past the run.
Vec2 stage_text(render::TextBatch& batch, Vec2 pos_px, const char* utf8, Color color);
/// Width of the longest prefix of `utf8` that fits `budget_px` along `axis`, at
/// the size the text pipeline draws with: exactly what `stage_text_clipped()`
/// stages.
@@ -430,7 +523,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
@@ -450,6 +544,34 @@ class Renderer {
void draw_plot(Plot& plot, const PlotLayout& layout);
void draw_grid(const Plot& plot);
/// The plot's **phosphor density** (`Plot::set_density()`), when one is on: one textured quad per
/// trace over the axes rect, each in **that trace's own colour** -- a saturated cell is the trace
/// at its own brightness -- drawn over the grid and the invalid-span bands. The traces themselves
/// are not drawn while it is on (`draw_traces()` returns), so this is the whole of the data ink
/// and the newest acquisition is inside it.
///
/// Each trace's image reaches the GPU as an `GL_R8` texture, one texel per cell, converted here
/// from the grid's counts -- the scale and the gamma are the plot's and this stage derives
/// neither, so the numbers a host reads back and the picture it sees are one derivation. The
/// texture is magnified over the axes rect with a linear filter, because past `kDensityCellCap` a
/// cell is several device pixels across. A frame whose grids did not change skips the conversion
/// *and* the uploads and costs the draw calls, which `FrameStats::density_upload_bytes` says per
/// frame.
void draw_density(const Plot& plot, const PlotLayout& layout);
/// One grid's quad: fills the shared `GL_R8` texture from `grid` (skipping when its revision and
/// address are the ones already uploaded) and draws it over the axes rect in `tint` -- the rect,
/// and not the grid's own cell count, so a capped grid is scaled back over the area it is an
/// image of. The one place the density conversion lives, shared by every trace of
/// `draw_density()`.
void draw_density_grid(const DensityConfig& config, const DensityGrid& grid, Color tint,
const PlotLayout& layout);
/// `Plot::invalid_spans()`, as `PlotLayout::invalid_spans` measured them: one fill per span,
/// in `style_.invalid_fill` or — for a span the host has marked repaired —
/// `style_.invalid_repaired_fill`. Over the grid and under everything else inside the axes, so
/// a hole reads as a property of the plot's background -- the grid shows through it and a trace
/// that does have data in those columns is drawn on top, which is what distinguishes a reserved
/// hole from a trace that has gone quiet.
void draw_invalid_spans(const PlotLayout& layout);
void draw_axis_lines(const Plot& plot);
/// The chrome helpers take the frame's layout: `Plot::layout()` recomputes the
/// ticks, the strips and the grid, so it is computed once, in `draw()`, and handed
@@ -459,6 +581,53 @@ 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 **sparse** window's polyline, graded by the same measure: each segment is
/// staged into the step its own *slope* earns, so a near-vertical segment between two
/// adjacent samples -- a discontinuity, which the signal crossed without dwelling
/// anywhere in it -- is faint and a near-flat one is bright.
///
/// It exists because grading used to be gated on the column reduction, and that
/// reduction is gated on a *density* (two gathered points per pixel column) whose only
/// purpose is removing the moire beat. Dwell has nothing to do with that density, so
/// zooming in past it switched the whole phosphor model off and drew an edge as
/// brightly as a flat top -- the edge got brighter the further in you zoomed.
///
/// The measure agrees with `stage_trace_density()`'s at the boundary but is **absolute
/// rather than per column**: below two points a column there is no column peak to
/// normalise against, so a segment's dwell is `1 / (1 + rows it spans)` outright. A
/// flat segment earns the top step and one crossing the whole axis earns
/// `Style::intensity_floor`, which is what a riser's column already gets on the dense
/// side.
///
/// Geometry is staged exactly as `stage_xy()` stages it -- in the pre-affine space,
/// relative to `x_origin` -- so only *which batch* a segment lands in differs from the
/// ungraded polyline, and the caller flushes with the trace's own `Mapping` and
/// `snap = false`. Returns the segments staged across all steps (0 stages nothing).
u32 stage_trace_slopes(const Plot& plot, const Trace& trace, const f32* xy, u32 points,
f64 x_origin, bool log_x, bool log_y);
/// 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
@@ -470,13 +639,22 @@ class Renderer {
/// the clip, and a range maximum that undershoots the edge by a ulp is not mistaken for
/// off the plot.
void draw_cursors(const Plot& plot, const PlotLayout& layout);
/// One cursor's label: the caller's caption for either orientation, and for a horizontal
/// ruler the value itself, written in its y axis' own tick format (`Axis::format_value()`)
/// -- the number a ruler exists to be read off. A vertical cursor's caption starts at the
/// top of the axes beside its marker; a ruler's text starts at the axes' left edge on its
/// own row, with the caption first on that line when there is one.
void draw_cursor_label(const Plot& plot, const PlotLayout& layout, const Cursor& cursor,
f32 at);
/// One cursor's label, measured rather than drawn: a horizontal ruler's line is the value
/// itself, written in its y axis' own tick format (`Axis::format_value()`) -- the number a
/// ruler exists to be read off -- with the caption first on the line when there is one; a
/// vertical cursor's line is its caption, beside the marker at the top of the axes. The
/// label is then placed (centred on its mark, stacked clear of close neighbours) and drawn
/// clustered (`draw_clustered_cursor_labels()`), which is what keeps a pair of readout
/// marks half a division apart readable: two texts over one column is a smear, one panel
/// with two lines is a readout.
void collect_cursor_label(const Plot& plot, const PlotLayout& layout, const Cursor& cursor,
f32 at, std::vector<CursorLabel>& labels);
/// Draws the placed labels' backplates -- one panel per cluster of meeting boxes, every
/// line at its own placed pen -- and stages the lines. Clustering is what keeps a pair of
/// readout marks half a division apart readable: two texts over one column is a smear, one
/// panel with two lines is a readout.
void draw_clustered_cursor_labels(const PlotLayout& layout,
const std::vector<CursorLabel>& labels);
/// The regions: the fill, one bound per edge, the optional label. Every rect comes
/// from `PlotLayout::regions` -- the same geometry the controller hit-tests -- so
/// a region is drawn where it can be grabbed, and neither side derives the other's
@@ -494,13 +672,23 @@ 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
/// rect's fractional edges is lit half-heartedly, and a frame bright on three sides and dim on the
/// fourth reads as a mistake). One helper, two callers: the border's colour is the only thing a
/// legend panel and a readout panel do not share.
void draw_readout_panel(const Rect& panel, Color border);
/// legend panel and a readout panel do not share. `background_alpha` scales the frame's own
/// background under the border: the legend and the crosshair's readout keep the shared
/// translucent backplate (what they cover stays faintly visible), while a *badge* -- a
/// cursor's value box, the crosshair's axis badges -- is read against whatever it sits on
/// (ticks, a noisy trace) and is drawn opaque.
void draw_readout_panel(const Rect& panel, Color border, float background_alpha);
/// One trace's colour swatch in `box`, opaque whatever the trace's own alpha is: a swatch drawn at
/// a trace's 15% would be the least legible thing in the frame, and the swatch is the one element
/// that says which colour an entry or a readout row *is*. The legend's entries and the crosshair's
@@ -530,6 +718,23 @@ class Renderer {
/// Drawn after every other interior pass -- the legend included -- because it is what the
/// 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);
/// The crosshair's **axis badges** (`CrosshairLayout::y_axis_badge`/`x_axis_badge`): the
/// values the two lines meet the axes at, each on the shared readout backplate with its
/// border in the crosshair's colour, drawn over the tick labels they sit among. Called
/// from `draw_plot()` after the label strips are drawn, which is the only order in which a
/// badge in a strip can sit on the ticks and still be read.
void draw_crosshair_axis_badges(const PlotLayout& layout);
/// The host's box-zoom frame (`PlotLayout::selection`): a fill at `selection_fill` under a border at
/// `selection_edge`, or nothing at all when nothing was swept. Drawn last of the interior passes, so
/// the area being framed is legible over the traces, the regions and the readout alike.
void draw_selection_frame(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
@@ -545,12 +750,59 @@ class Renderer {
render::TextAtlas atlas_;
render::LineProgram line_program_;
render::TextProgram text_program_;
/// The phosphor density's pipeline, its one attribute-less vertex array and the texture a grid is
/// uploaded into. The texture is `GL_R8`, one texel per cell of the plot's grid, and is re-filled
/// for each trace in turn -- a trace's grid is drawn before the next trace's replaces it -- so a
/// row of several traces still costs the one texture. It is re-created when the grid's size moves
/// (a resized plot is a differently sized image, and the plot has already dropped what the old one
/// held).
render::DensityProgram density_program_;
gl::VertexArray density_vao_;
gl::Texture density_texture_;
/// The luminance image as uploaded, re-used across frames so a steady-state frame reallocates
/// nothing: one byte per cell.
std::vector<u8> density_pixels_;
u32 density_w_ = 0;
u32 density_h_ = 0;
/// The grid the texture currently holds, by address, and its revision. Compared per frame and per
/// trace, so a frame whose grids did not move skips the conversion *and* the upload -- the pause
/// in a persistence display. The address is part of the key because the texture is shared: two
/// traces of one plot have two grids with two revisions, and without the address a skip would
/// draw one trace's image under another's colour.
const DensityGrid* density_uploaded_grid_ = nullptr;
u64 density_uploaded_revision_ = 0;
render::LineVertexArray line_vao_;
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
@@ -559,6 +811,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;
+42 -6
View File
@@ -16,7 +16,9 @@ namespace plot::render {
/// `v_along`, the distance travelled from `a_p0` in pixels.
///
/// Uniforms: `u_scale` (vec2, data space to pixels), `u_offset` (vec2, pixels),
/// `u_viewport` (vec2, pixels), `u_width` (float, pixels), `u_color` (vec4).
/// `u_viewport` (vec2, pixels), `u_width` (float, pixels), `u_aa` (float, the AA
/// fringe half-width in pixels -- the style's `line_aa_px` in device pixels,
/// clamped at 0.25), `u_color` (vec4).
/// `plot::Rect` is top-left origin while GL is bottom-left, so the y axis is
/// flipped exactly once, here.
///
@@ -42,16 +44,50 @@ 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;
/// GLSL source of the phosphor-density pipeline: one textured quad over the axes rect.
///
/// The vertex shader builds the quad from `gl_VertexID` -- a four-vertex `GL_TRIANGLE_STRIP`
/// like the other two pipelines, and the only one with **no vertex attributes at all**: the
/// image is one rectangle whose corners are uniform state, so there is no geometry to stage and
/// nothing to upload (the density's own bytes go to a texture, not to a buffer).
///
/// Uniforms: `u_viewport` (vec2, pixels), `u_origin` (vec2, the quad's top-left corner in `Rect`
/// pixels), `u_size` (vec2, its size in pixels -- the axes rect the image is drawn over, which the
/// texture's cells are spread across: one texel a cell while the rect is under `kDensityCellCap`,
/// and a filtered magnification past it), `u_color` (vec4), `u_density` (sampler2D, unit 0).
///
/// The fragment shader multiplies the sampled single-channel image into `u_color`'s alpha, which
/// is the whole of the brightness mapping: a cell's count was turned into a luminance byte on the
/// CPU (`Plot`), where the count, the decay and the gamma live, so this stage has no scale of its
/// own to get out of step with the numbers a host reads back.
///
/// `Rect` is top-left origin while GL is bottom-left, so the y axis is flipped exactly once,
/// here, as in the line pipeline.
extern const char* const kDensityVS;
extern const char* const kDensityFS;
} // namespace plot::render
+162 -3
View File
@@ -38,12 +38,45 @@ 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);
Color measure_fill = Color::hex(0xFFB020, 0.15f);
/// The box-zoom selection's frame, drawn while a modified left drag is choosing a range: a light
/// neutral near full alpha for the border and the same neutral at a low one for the fill. Neutral
/// and not a trace colour, on `invalid_fill`'s argument -- the frame says "this is the area", and a
/// tint a trace could have would read as data. The fill's alpha is chosen against the same floor:
/// low enough that what is under the frame stays readable, because the point is to show what is
/// being selected, not to hide it.
Color selection_fill = Color::hex(0xC8D0DC, 0.12f);
Color selection_edge = Color::hex(0xC8D0DC, 0.85f);
/// The shading over a span the host has declared invalid (`Plot::set_invalid_spans()`): the
/// axis furniture's own neutral grey at a low alpha, on the same model as `measure_fill`
/// above. Neutral and not a trace colour on purpose -- a band is the *absence* of data, and
/// one in a hue a trace could have would read as a signal rather than as a gap. The alpha is
/// low enough that the grid under it stays legible, which is what makes the band read as a
/// region of the plot rather than as a panel laid over it.
Color invalid_fill = Color::hex(0x8A94A6, 0.18f);
/// The same shading for a span whose samples have since arrived and been written in, at well
/// under half the alpha. The gap is still reported -- the window was drawn without that data --
/// but a reader can tell a hole that is still empty from one that has been filled. Fainter by
/// alpha alone and not by hue, so the two read as two depths of one mark rather than as two
/// different kinds of thing; and still above the threshold where the difference from *no* band
/// is visible, which is the floor this alpha is chosen against.
Color invalid_repaired_fill = Color::hex(0x8A94A6, 0.08f);
/// The crosshair's colour: its two lines and the readout panel's border (spec §15). Markers are
/// deliberately *not* this colour -- they carry each trace's own, because a marker says *which*
/// trace and the line says *where*.
@@ -75,6 +108,21 @@ struct Style {
f32 crosshair_width = 0.75f;
f32 crosshair_dash_on = 4.0f;
f32 crosshair_dash_off = 4.0f;
/// The full extent, in logical pixels, of the little cross drawn at each trace's readout
/// point (`CrosshairRow::marker`), in that trace's own colour.
///
/// **Deliberately larger than `marker_px`**, which is what a trace's own samples are dotted
/// with at a deep zoom. In the default `CrosshairValue::NearestSample` the readout's mark is
/// *snapped to a sample*, so it lands among those dots -- at the same size it would read as
/// one more sample instead of as "this is the one the panel is quoting". The lines are chrome
/// and recede; this mark is the answer to the question the hover asks, and it is the one
/// crosshair element that should not.
///
/// Its stroke is `crosshair_marker_width`, thicker than the lines' for the same reason. `0`
/// across draws no mark at all, which is how a caller that wants the panel without the marks
/// says so.
f32 crosshair_marker_px = 9.0f;
f32 crosshair_marker_width = 1.5f;
// --- the scale ----------------------------------------------------------
@@ -96,12 +144,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
@@ -124,6 +199,90 @@ 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.
///
/// Below `dense_threshold_spp`, where there are no columns to reduce, this grades
/// the **polyline** by each segment's own slope instead (`FrameStats::slope_traces`
/// against `graded_traces`) -- the same measure for a sparse window, where a
/// segment *is* the sub-column. One switch covers both because the alternative was
/// a defect: gated on the reduction, the model switched off as a window narrowed
/// past the threshold, and an edge drawn at a third of a flat top's ink jumped to
/// all of it in one pixel of zoom.
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.
+6 -5
View File
@@ -54,20 +54,21 @@ class LineVertexArray {
gl::VertexArray vao_;
u32 instances_ = 0;
/// Instance-buffer generation the attribute pointers currently point into.
u32 epoch_ = 0;
u64 epoch_ = 0;
};
/// Vertex array plus instance buffer of the text pipeline: `a_pos` at location 0
/// (the cell's top-left corner in pixels), the glyph's atlas rect `a_uv` at
/// location 1 and its rotation basis `a_axis` at location 2, two floats each,
/// every one advancing once per instance. The layout is the one `TextInstance`
/// location 1, its rotation basis `a_axis` at location 2 and the run's tint
/// `a_colour` at location 3 (`vec2`, `vec2`, `vec2`, `vec4` floats), each
/// advancing once per instance. The layout is the one `TextInstance`
/// declares and shaders.hpp consumes; a caller never writes a location, stride or
/// offset, it stages a `TextBatch` and uploads its `data()`.
class TextVertexArray {
public:
bool create(Status* st = nullptr, UploadMode mode = UploadMode::Orphan) noexcept;
/// Uploads `glyphs` instances from interleaved `x,y,u,v,ax,ay` floats.
/// Uploads `glyphs` instances from interleaved `x,y,u,v,ax,ay,r,g,b,a` floats.
void upload(const f32* interleaved, u32 glyphs) noexcept;
void bind() const noexcept { vao_.bind(); }
@@ -84,7 +85,7 @@ class TextVertexArray {
TextBuffer buffer_;
gl::VertexArray vao_;
u32 instances_ = 0;
u32 epoch_ = 0;
u64 epoch_ = 0;
};
} // namespace plot::render
+28
View File
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
// Template for <plot/version.hpp>: configure_file() expands it into
// <build>/include/plot/version.hpp at configure time, and that generated file
// -- not this one -- is a public header. The umbrella names it, and the
// install rules place it under include/plot/ with the rest of the surface, so
// a consumer can ask what it compiled against: the macros for a preprocessor
// test, the plot::version constants for everything else. Both come from the
// project() version, so there is exactly one place a release edits.
#pragma once
#define PLOT_VERSION_MAJOR @plot_VERSION_MAJOR@
#define PLOT_VERSION_MINOR @plot_VERSION_MINOR@
#define PLOT_VERSION_PATCH @plot_VERSION_PATCH@
#define PLOT_VERSION_STRING "@plot_VERSION@"
namespace plot::version {
/// The library version: one component per constant, and the whole thing as the
/// string the project() declares (e.g. "0.1.0").
inline constexpr int major = PLOT_VERSION_MAJOR;
inline constexpr int minor = PLOT_VERSION_MINOR;
inline constexpr int patch = PLOT_VERSION_PATCH;
inline constexpr const char* string = PLOT_VERSION_STRING;
} // namespace plot::version
+26
View File
@@ -0,0 +1,26 @@
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: 2026 Martino Ferrari
#
# pkg-config metadata for the plot core library: configure_file() turns this
# into <build>/plot.pc and the install rules place it in
# ${CMAKE_INSTALL_LIBDIR}/pkgconfig/. It serves consumers whose build is not
# CMake -- find_package(plot) and the package config under lib/cmake/plot/
# remain the primary interface. The prefix is anchored to the file's own
# location (`pkg-config --define-prefix` and a plain PKG_CONFIG_PATH both
# resolve it), so an install relocated after configuration -- including
# `cmake --install --prefix` -- still resolves its own headers and library.
# The ${...} references below are pkg-config's own, evaluated against this
# file's prefix; configure_file runs with @ONLY so they reach the installed
# file untouched.
prefix=${pcfiledir}/../..
exec_prefix=${prefix}
libdir=${prefix}/@PLOT_PC_LIBDIR@
includedir=${prefix}/@PLOT_PC_INCLUDEDIR@
Name: @PROJECT_NAME@
Description: @PROJECT_DESCRIPTION@
Version: @PROJECT_VERSION@
Libs: -L${libdir} @PLOT_PC_LIB@
Libs.private: @PLOT_PC_LIBS_PRIVATE@
Cflags: -I${includedir}
+30
View File
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
// The README's embed block, committed as a file: the whole of a minimal GLFW
// program (the adapter opens the window, runs the loop and feeds the
// controller, and the callback is where live data would be pushed). The
// compile command it is verified with is in README.md, next to the same code.
#include <glfw/glfw_host.hpp> // the optional GLFW adapter
#include <plot/plot.hpp>
#include <cmath>
#include <vector>
int main() {
plot::glfw::Host host;
plot::Plot& p = host.plot();
std::vector<plot::f32> y(512);
for (plot::usize i = 0; i < y.size(); ++i)
y[i] = static_cast<plot::f32>(std::sin(0.05 * static_cast<double>(i)));
plot::data::ArraySeries series(std::move(y), 0.0, 1.0);
const plot::u32 x = p.add_x_axis({"x", {0.0, 511.0}, true});
const plot::u32 sig = p.add_y_axis({"signal", {-1.0, 1.0}, true, plot::AxisPlace::Left});
p.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, x, sig, "sine"});
plot::Status st;
return host.run({800, 480, "plot"}, [](plot::Plot&, plot::f64) {}, &st) ? 0 : 1;
}
+33 -9
View File
@@ -4,6 +4,7 @@
#include <plot/data/array_series.hpp>
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <utility>
@@ -23,23 +24,45 @@ void ArraySeries::commit() {
pyramid_.clear();
pyramid_.reserve(n);
// Bounds seed on the first *finite* sample and fold the rest: a non-finite
// sample is excluded from every aggregate, and a window with none aggregates
// to a zero-span Range (the library-wide NaN policy).
f32 lo = 0.0f, hi = 0.0f;
bool first = true;
bool finite = false;
for (const f32 v : y_) {
pyramid_.append(v);
if (first) {
lo = v;
hi = v;
first = false;
} else {
if (!std::isfinite(v)) continue;
if (finite) {
lo = std::min(lo, v);
hi = std::max(hi, v);
} else {
lo = v;
hi = v;
finite = true;
}
}
y_bounds_ = first ? Range{0.0, 0.0} : Range{f64(lo), f64(hi)};
y_has_finite_ = finite;
y_bounds_ = finite ? Range{f64(lo), f64(hi)} : Range{0.0, 0.0};
x_bounds_ = n == 0 ? Range{x0_, x0_} : Range{x0_, x0_ + f64(n - 1) * dx_};
}
void ArraySeries::push(f32 v) {
y_.push_back(v);
pyramid_.append(v);
// The incremental twin of commit()'s fold: skip non-finite, seed on the first
// finite sample, extend after that.
if (std::isfinite(v)) {
if (y_has_finite_) {
y_bounds_.min = std::min(y_bounds_.min, f64(v));
y_bounds_.max = std::max(y_bounds_.max, f64(v));
} else {
y_bounds_ = Range{f64(v), f64(v)};
y_has_finite_ = true;
}
}
x_bounds_ = Range{x0_, x0_ + f64(y_.size() - 1) * dx_};
}
u64 ArraySeries::size() const { return static_cast<u64>(y_.size()); }
f64 ArraySeries::x0() const { return x0_; }
@@ -52,8 +75,9 @@ Range ArraySeries::y_bounds() const { return y_bounds_; }
const MinMaxPyramid& ArraySeries::pyramid() const { return pyramid_; }
void ArraySeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const {
gather_impl(level, begin, end, out_xy);
void ArraySeries::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode) const {
gather_impl(level, begin, end, x_origin, out_xy, mode);
}
void ArraySeries::copy_raw(u64 begin, u64 end, f32* out) const {
+76 -14
View File
@@ -12,8 +12,12 @@
namespace plot::data {
namespace {
/// Samples covered by one level-l bucket.
constexpr u64 level_span(u32 l) { return u64{kBucket} << l; }
/// Samples covered by one level-l bucket. Clamped to kMaxPyramidLevel: the
/// shift past level 57 would overflow u64 (UB), and a bucket wider than any
/// u64 sample count is the same bucket as the widest one anyway.
constexpr u64 level_span(u32 l) {
return u64{kBucket} << (l > kMaxPyramidLevel ? kMaxPyramidLevel : l);
}
/// Level-l buckets needed to hold n samples (ceiling division).
constexpr u64 buckets_for(u64 n, u32 l) {
@@ -72,11 +76,13 @@ void cover_buckets(const MinMaxPyramid& p, u64 b0, u64 b1, Fn&& fn) {
}
/// Folds the decomposition of [b0, b1) into a min/max Range. Returns a
/// zero-span Range when there is nothing to fold.
/// zero-span Range when there is nothing to fold. Buckets with no finite
/// sample (count 0) are absent, not zero-valued, and are skipped.
Range fold_range(const MinMaxPyramid& p, u64 b0, u64 b1) {
Range r{};
bool first = true;
cover_buckets(p, b0, b1, [&](u32 l, u64 i) {
if (p.bucket_count(l, i) == 0) return;
const MinMax m = p.bucket(l, i);
if (first) {
r.min = f64(m.min);
@@ -96,10 +102,13 @@ void MinMaxPyramid::add_level() { levels_.emplace_back(); }
void MinMaxPyramid::reserve(u64 n) {
// Materialise every level n samples can need -- level 0 plus one per doubling
// of the bucket -- so a run of appends never grows the level vector. See the
// header for why the bucket deques themselves cannot be pre-sized.
// of the bucket, capped at kMaxPyramidLevel -- so a run of appends never grows
// the level vector. See the header for why the bucket deques themselves cannot
// be pre-sized.
u32 want = 1;
for (u64 span = level_span(0); span <= n && want < 63; span <<= 1) ++want;
for (u64 span = level_span(0); span <= n && want <= kMaxPyramidLevel; span <<= 1) {
++want;
}
if (levels_.size() < want) levels_.resize(want);
}
@@ -115,20 +124,25 @@ void MinMaxPyramid::push(u32 l, u64 k, const Entry& e) {
}
void MinMaxPyramid::append(f32 v) {
const f64 vd = f64(v);
// The new sample always lands in the in-progress level-0 bucket, which is the
// only bucket any append ever touches: a bucket becomes an entry exactly once,
// complete, and is never rewritten.
if (count_ % kBucket == 0) {
if (count_ % kBucket == 0) pending_ = Entry{};
// Non-finite samples occupy their absolute position -- bucket boundaries are
// positional, so NaN or not the sample advances count_ and completes its
// bucket -- but they are excluded from every aggregate (see the header).
if (std::isfinite(v)) {
const f64 vd = f64(v);
if (pending_.count == 0) {
pending_.mm.min = v;
pending_.mm.max = v;
pending_.sum = vd;
pending_.sumsq = vd * vd;
} else {
pending_.mm.min = std::min(pending_.mm.min, v);
pending_.mm.max = std::max(pending_.mm.max, v);
}
pending_.sum += vd;
pending_.sumsq += vd * vd;
++pending_.count;
}
++count_;
if (count_ % kBucket != 0) return; // the bucket is still partial: nothing to push
@@ -141,7 +155,8 @@ void MinMaxPyramid::append(f32 v) {
// Cascade: level l gains an entry exactly when kBucket << l divides the sample
// count, so the walk stops at the first level that gained nothing -- amortised
// O(1) per sample (1/kBucket + 1/(2*kBucket) + ... = 1/(kBucket/2) merges).
for (u32 l = 1; l < 63; ++l) {
// Capped at kMaxPyramidLevel: past it the span shift would overflow.
for (u32 l = 1; l <= kMaxPyramidLevel; ++l) {
const u64 span = level_span(l);
if (count_ % span != 0) break;
const u64 k = count_ / span - 1; // the level-l bucket that just completed
@@ -152,14 +167,55 @@ 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);
}
}
void MinMaxPyramid::amend(u64 absolute_sample, f32 v) {
if (!std::isfinite(v)) return;
if (absolute_sample < base_ || absolute_sample >= count_) return;
const f64 vd = f64(v);
const auto fold = [&](Entry& e) {
// An empty bucket's `mm` is meaningless (see the Entry note), so the first finite
// sample *sets* the extremes instead of widening from a zero that was never a sample.
if (e.count == 0) {
e.mm.min = v;
e.mm.max = v;
} else {
e.mm.min = std::min(e.mm.min, v);
e.mm.max = std::max(e.mm.max, v);
}
e.sum += vd;
e.sumsq += vd * vd;
++e.count;
};
// The in-progress level-0 bucket, when the index lies inside it. It is not an entry yet,
// so the loop below cannot reach it; leaving it out would lose the amendment for a hole
// whose tail is still filling, which is exactly the hole a prompt backfill arrives into.
if (count_ % kBucket != 0 && absolute_sample >= (count_ / kBucket) * kBucket) {
fold(pending_);
}
// Every level independently: min/max/sum/sumsq/count are each associative, so folding the
// value into a parent gives the same aggregate as re-merging its children would.
for (u32 l = 0; l < static_cast<u32>(levels_.size()); ++l) {
const u64 i = absolute_sample / level_span(l);
if (i < levels_[l].base) continue;
if (i - levels_[l].base >= static_cast<u64>(levels_[l].entries.size())) continue;
fold(levels_[l].entries[static_cast<usize>(i - levels_[l].base)]);
}
}
void MinMaxPyramid::drop_before(u64 absolute_sample) {
if (absolute_sample <= base_) return;
if (absolute_sample > count_) {
@@ -225,6 +281,12 @@ u64 MinMaxPyramid::level_size(u32 l) const noexcept {
MinMax MinMaxPyramid::bucket(u32 l, u64 i) const { return at(l, i).mm; }
f64 MinMaxPyramid::bucket_sum(u32 l, u64 i) const { return at(l, i).sum; }
f64 MinMaxPyramid::bucket_sumsq(u32 l, u64 i) const { return at(l, i).sumsq; }
u32 MinMaxPyramid::bucket_count(u32 l, u64 i) const { return at(l, i).count; }
Range MinMaxPyramid::reduce_buckets(u64 begin, u64 end) const {
const u64 e = std::min(end, size());
if (begin >= e) return Range{0.0, 0.0};
@@ -265,7 +327,7 @@ u32 MinMaxPyramid::level_for(u64 n, f64 samples_per_pixel) const {
const f64 ratio = samples_per_pixel / f64(kBucket);
if (n == 0 || !(ratio > 1.0)) return 0;
const f64 lg = std::floor(std::log2(ratio));
u32 l = lg >= f64(62) ? 62u : static_cast<u32>(lg);
u32 l = lg >= f64(kMaxPyramidLevel) ? kMaxPyramidLevel : static_cast<u32>(lg);
while (l > 0) {
const bool exists = l < level_count() && level_size(l) >= 2;
if (exists && buckets_for(n, l) >= 2) break;
+137 -27
View File
@@ -4,6 +4,7 @@
#include <plot/data/series.hpp>
#include <algorithm>
#include <cmath>
#include <cstddef>
namespace plot::data {
@@ -13,6 +14,9 @@ namespace {
/// gather path never allocates, and large enough to amortise the call.
constexpr u64 kGatherChunk = 256;
/// Ceiling division: first multiple of `d` at or after `v`, in `d` units.
constexpr u64 ceil_div(u64 v, u64 d) { return v / d + (v % d == 0 ? u64{0} : u64{1}); }
} // namespace
Series::QueryParts Series::split_query(u64 begin, u64 end) const {
@@ -75,6 +79,7 @@ Range Series::reduce(u64 begin, u64 end) const {
const u64 n = copy_edges(p, edge);
for (u64 i = 0; i < n; ++i) {
const f64 v = f64(edge[i]);
if (!std::isfinite(v)) continue; // non-finite samples are excluded (B7)
if (first) {
r.min = v;
r.max = v;
@@ -84,6 +89,8 @@ Range Series::reduce(u64 begin, u64 end) const {
r.max = std::max(r.max, v);
}
}
// No finite sample at all (an all-NaN window): a zero-span Range, per the
// library-wide NaN policy.
return first ? Range{0.0, 0.0} : r;
}
@@ -98,7 +105,10 @@ f64 Series::sum(u64 begin, u64 end) const {
}
f32 edge[2 * kBucket];
const u64 n = copy_edges(p, edge);
for (u64 i = 0; i < n; ++i) total += f64(edge[i]);
for (u64 i = 0; i < n; ++i) {
const f64 v = f64(edge[i]);
if (std::isfinite(v)) total += v; // non-finite samples are excluded (B7)
}
return total;
}
@@ -115,77 +125,177 @@ f64 Series::sumsq(u64 begin, u64 end) const {
const u64 n = copy_edges(p, edge);
for (u64 i = 0; i < n; ++i) {
const f64 v = f64(edge[i]);
total += v * v;
if (std::isfinite(v)) total += v * v; // non-finite samples are excluded (B7)
}
return total;
}
void Series::gather_raw(u64 begin, u64 end, std::vector<f32>& out_xy) const {
const f64 x_0 = x0();
f64 Series::x_at(u64 i) const { return x0() + f64(i) * dx(); }
bool Series::neighbours(f64 x, u64& i0, u64& i1) const {
const u64 n = size();
if (n == 0) return false;
const f64 lo = x_at(0);
const f64 hi = x_at(n - 1);
if (!(x >= lo && x <= hi)) return false;
const f64 step = dx();
if (!(step > 0.0)) {
i0 = 0;
i1 = 0;
return true;
}
const f64 t = (x - lo) / step;
const u64 k = x >= hi ? n - 1 : static_cast<u64>(t); // floor, in range
// An exact hit collapses to one index, whatever the division above rounded
// to: compare against the samples themselves, not against t's fraction.
if (x_at(k) == x) {
i0 = k;
i1 = k;
return true;
}
if (k + 1 < n && x_at(k + 1) == x) {
i0 = k + 1;
i1 = k + 1;
return true;
}
i0 = k;
i1 = k + 1 < n ? k + 1 : k;
return true;
}
u64 Series::snap_index(f64 x) const {
const u64 n = size();
if (n == 0) return 0;
const f64 step = dx();
if (!(step > 0.0)) return 0;
const f64 k = std::round((x - x0()) / step);
// The first test also rejects NaN (every comparison with it is false), which
// would otherwise reach the u64 cast undefined.
if (!(k > 0.0)) return 0;
if (k >= f64(n)) return n - 1;
return static_cast<u64>(k);
}
void Series::gather_raw(u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy) const {
const f64 step = dx();
// Sample i sits at `x0() + i*step`, so this is `x_at(i) - x_origin` with the subtraction done
// once in f64 rather than per sample: see gather() for why the caller owns the origin.
const f64 shift = x0() - x_origin;
f32 raw[kGatherChunk];
for (u64 i = begin; i < end;) {
const u64 take = std::min<u64>(kGatherChunk, end - i);
copy_raw(i, i + take, raw);
for (u64 k = 0; k < take; ++k) {
out_xy.push_back(f32(x_0 + f64(i + k) * step));
out_xy.push_back(f32(shift + f64(i + k) * step)); // x relative to x_origin: see gather()
out_xy.push_back(raw[k]);
}
i += take;
}
}
void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const {
void Series::gather_impl(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode) const {
const u64 n = size();
const u64 b = std::min(begin, n);
const u64 e = std::min(std::max(end, b), n);
if (b >= e) return;
if (level == 0) {
gather_raw(b, e, out_xy);
gather_raw(b, e, x_origin, out_xy);
return;
}
// Public levels clamp to the deepest representable one (R3): past
// kMaxPyramidLevel the bucket-span shift would overflow, and the widest
// bucket there is is already wider than any window.
level = std::min(level, kMaxPyramidLevel);
// Level >= 1: one envelope pair per *retained* bucket the range overlaps, x at
// the bucket's first sample (min) and at its last sample (max), which keeps x
// monotonic while covering the bucket's span. The pyramid's buckets are
// absolute, so map the window range onto its grid and clamp to the buckets that
// are both complete and wholly inside the window: a bucket that is only partly
// retained aggregates samples the window no longer has, and a partial trailing
// bucket is not stored at all.
// Level >= 1: one geometry element per *retained* bucket the range overlaps.
// The pyramid's buckets are absolute, so map the window range onto its grid
// and clamp to the buckets that are both complete and wholly inside the
// requested range: `first` is the first bucket whose span starts at or after
// `begin` (ceiling, so the bucket cannot reach left of the range) and `last`
// is one past the last bucket whose span ends at or before `end` (floor, so
// it cannot reach right of it). This is the B3 fix: a floor on `first` (or a
// bucket containing `end - 1`) emitted aggregates that covered samples
// outside [begin, end), so the envelope was wrong for any unaligned range.
// Buckets that are only partly *retained* are also excluded by construction:
// stored entries are complete aggregates of their own span, and a partial
// trailing bucket is never stored.
//
// Those two excluded buckets are still part of the range, and one of them holds
// the newest samples -- for a live trace, the part being watched. They are drawn
// from the raw samples instead: at most one bucket at each end, about a pixel
// each at the level level_for() picks, so the envelope path stays O(buckets)
// and the geometry still spans the requested range exactly.
const f64 x_0 = x0();
// The two excluded spans at the edges -- [b, lead) and [trail, e) -- are part
// of the range, and one of them holds the newest samples -- for a live trace,
// the part being watched. They are drawn from the raw samples instead: at
// most one bucket at each end, about a pixel each at the level level_for()
// picks, so the envelope path stays O(buckets) and the geometry still spans
// the requested range exactly, for every mode.
const f64 step = dx();
const f64 shift = x0() - x_origin; // as in gather_raw(): x_at(i) - x_origin = shift + i*step
const u64 span = u64{kBucket} << level;
const u64 origin = pyramid().window_base();
const u64 lbase = pyramid().level_base(level);
const u64 lend = lbase + pyramid().level_size(level);
const u64 first = std::max((origin + b) / span, lbase);
const u64 last = std::min((origin + e - 1) / span + 1, lend);
const u64 first = std::max(ceil_div(origin + b, span), lbase);
const u64 last = std::min((origin + e) / span, lend);
if (first >= last) {
// No complete bucket of this level is inside the range -- a window squeezed
// into one bucket -- so the raw samples are the only exact geometry there is.
gather_raw(b, e, out_xy);
gather_raw(b, e, x_origin, out_xy);
return;
}
const u64 lead = first * span - origin; // window index the first emitted bucket starts at
if (b < lead) gather_raw(b, std::min(lead, e), out_xy);
if (b < lead) gather_raw(b, std::min(lead, e), x_origin, out_xy);
switch (mode) {
case GatherMode::Envelope: {
// One (min, max) pair per bucket, x at the bucket's first sample (min)
// and at its last sample (max), which keeps x monotonic while covering
// the bucket's span. A bucket with no finite sample is absent, not
// zero-valued, and emits nothing.
for (u64 bi = first; bi < last; ++bi) {
if (pyramid().bucket_count(level, bi) == 0) continue;
const MinMax m = pyramid().bucket(level, bi);
const u64 first_sample = bi * span - origin; // >= 0: stored buckets start inside the window
const u64 last_sample = std::min((bi + 1) * span, origin + e) - 1 - origin;
out_xy.push_back(f32(x_0 + f64(first_sample) * step));
out_xy.push_back(f32(shift + f64(first_sample) * step)); // x relative to x_origin
out_xy.push_back(m.min);
out_xy.push_back(f32(x_0 + f64(last_sample) * step));
out_xy.push_back(f32(shift + f64(last_sample) * step));
out_xy.push_back(m.max);
}
break;
}
case GatherMode::Nearest: {
// One raw sample per bucket, picked the way pyramid().snap() would pick
// it: the sample nearest the bucket's centre, which on the fixed grid is
// the centre sample itself. x is at the sample; the value is raw, so a
// bucket whose centre sample is non-finite contributes that NaN, exactly
// as level 0 would have drawn it.
for (u64 bi = first; bi < last; ++bi) {
const u64 centre = bi * span + span / 2 - origin; // inside [b, e): the bucket is wholly inside
f32 v = 0.0f;
copy_raw(centre, centre + 1, &v);
out_xy.push_back(f32(shift + f64(centre) * step)); // x relative to x_origin
out_xy.push_back(v);
}
break;
}
case GatherMode::Mean: {
// One point per bucket, y = the bucket's finite samples' mean (the exact
// sum the bucket stores, divided by its finite count) and x at the
// bucket's centre. A bucket with no finite sample has no mean and emits
// nothing.
for (u64 bi = first; bi < last; ++bi) {
const u32 count = pyramid().bucket_count(level, bi);
if (count == 0) continue;
const u64 centre = bi * span + span / 2 - origin;
out_xy.push_back(f32(shift + f64(centre) * step)); // x relative to x_origin
out_xy.push_back(f32(pyramid().bucket_sum(level, bi) / f64(count)));
}
break;
}
}
const u64 trail = last * span - origin; // one past the last emitted bucket's span
if (trail < e) gather_raw(trail, e, out_xy);
if (trail < e) gather_raw(trail, e, x_origin, out_xy);
}
} // namespace plot::data
+17 -4
View File
@@ -18,8 +18,11 @@ constexpr u64 kReadChunk = 256;
StreamSeries::StreamSeries(u64 capacity, f64 x0, f64 dx)
: ring_(capacity), x0_(x0), dx_(dx) {
// Size the pyramid's level table for a full window up front: the ring never
// holds more than capacity() samples, so appending never grows it.
pyramid_.reserve(capacity);
// holds more than capacity() samples, so appending never grows it. The ring
// rounded the request up to a power of two, and the window it will hold is
// the *rounded* capacity -- reserving the raw argument would under-size a
// non-power-of-two request exactly when the stream saturates (R4).
pyramid_.reserve(ring_.capacity());
}
void StreamSeries::push(f32 y) { ring_.push(y); }
@@ -60,6 +63,15 @@ void StreamSeries::acquire() {
y_bounds_ = reduce(0, size());
}
ArraySeries StreamSeries::freeze() const {
// The window of the last acquire(), copied out once: the frozen series owns
// its samples, so nothing the producer does afterwards can move it.
const u64 n = size();
std::vector<f32> y(static_cast<usize>(n));
if (n > 0) copy_raw(0, n, y.data());
return ArraySeries(std::move(y), x0(), dx());
}
u64 StreamSeries::size() const { return pyramid_.size(); }
f64 StreamSeries::x0() const { return x0_ + f64(pyramid_.window_base()) * dx_; }
@@ -75,8 +87,9 @@ Range StreamSeries::y_bounds() const { return y_bounds_; }
const MinMaxPyramid& StreamSeries::pyramid() const { return pyramid_; }
void StreamSeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const {
gather_impl(level, begin, end, out_xy);
void StreamSeries::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode) const {
gather_impl(level, begin, end, x_origin, out_xy, mode);
}
void StreamSeries::copy_raw(u64 begin, u64 end, f32* out) const {
+201
View File
@@ -0,0 +1,201 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/data/xy_series.hpp>
#include <algorithm>
#include <cassert>
#include <cmath>
#include <cstddef>
#include <utility>
namespace plot::data {
namespace {
/// Ceiling division: the first level-`l` bucket whose whole span starts at or
/// after `v` samples (in bucket units).
constexpr u64 ceil_div(u64 v, u64 d) { return v / d + (v % d == 0 ? u64{0} : u64{1}); }
} // namespace
XYSeries::XYSeries(std::vector<f64> x, std::vector<f32> y) { reset(std::move(x), std::move(y)); }
void XYSeries::reset(std::vector<f64> x, std::vector<f32> y) {
// The binary searches below (and every O(log n) consumer of them) are only
// correct on sorted input; see the header for the precondition.
assert(x.size() == y.size());
assert(std::is_sorted(x.begin(), x.end()) && "XYSeries requires x sorted ascending");
x_ = std::move(x);
y_ = std::move(y);
commit();
}
void XYSeries::commit() {
const u64 n = static_cast<u64>(y_.size());
pyramid_.clear();
pyramid_.reserve(n);
// Same NaN-aware fold as ArraySeries::commit(): non-finite y is excluded
// from the cached bounds, and a window with no finite sample aggregates to a
// zero-span Range. (The pyramid's append() applies the same policy per
// bucket.) The x bounds come straight from the sorted array's ends.
f32 lo = 0.0f, hi = 0.0f;
bool finite = false;
for (const f32 v : y_) {
pyramid_.append(v);
if (!std::isfinite(v)) continue;
if (finite) {
lo = std::min(lo, v);
hi = std::max(hi, v);
} else {
lo = v;
hi = v;
finite = true;
}
}
y_has_finite_ = finite;
y_bounds_ = finite ? Range{f64(lo), f64(hi)} : Range{0.0, 0.0};
x_bounds_ = n == 0 ? Range{0.0, 0.0} : Range{x_.front(), x_.back()};
}
u64 XYSeries::size() const { return static_cast<u64>(y_.size()); }
f64 XYSeries::x0() const { return x_.empty() ? 0.0 : x_.front(); }
f64 XYSeries::dx() const { return 0.0; }
Range XYSeries::x_bounds() const { return x_bounds_; }
Range XYSeries::y_bounds() const { return y_bounds_; }
const MinMaxPyramid& XYSeries::pyramid() const { return pyramid_; }
f64 XYSeries::x_at(u64 i) const { return x_[static_cast<usize>(i)]; }
bool XYSeries::neighbours(f64 x, u64& i0, u64& i1) const {
const u64 n = size();
if (n == 0) return false;
// Also rejects NaN: every comparison with it is false.
if (!(x >= x_.front() && x <= x_.back())) return false;
// Last sample at or left of x, first at or right of it. Both searches are in
// range: x >= front puts a lower_bound hit at index >= 1's predecessor at 0,
// and x <= back caps upper_bound's predecessor at n - 1.
const auto up = std::upper_bound(x_.begin(), x_.end(), x);
const auto lo = std::lower_bound(x_.begin(), x_.end(), x);
i0 = static_cast<u64>(up - x_.begin()) - 1;
i1 = static_cast<u64>(lo - x_.begin());
return true;
}
u64 XYSeries::snap_index(f64 x) const {
const u64 n = size();
if (n == 0) return 0;
if (!(x > x_.front())) return 0; // at or left of the first sample; NaN too
if (x >= x_.back()) return n - 1;
const auto it = std::lower_bound(x_.begin(), x_.end(), x);
const u64 hi = static_cast<u64>(it - x_.begin());
const u64 lo = hi - 1; // hi >= 1: x is strictly past x_.front()
return (x - x_[static_cast<usize>(lo)]) <= (x_[static_cast<usize>(hi)] - x)
? lo
: hi; // ties go to the lower index
}
void XYSeries::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode) const {
const u64 n = size();
const u64 b = std::min(begin, n);
const u64 e = std::min(std::max(end, b), n);
if (b >= e) return;
// x emitted relative to the caller's origin: see Series::gather(). The x array is f64, so
// the subtraction happens wide and only the small, exact offset narrows.
const f64 origin = x_origin;
if (level == 0) {
for (u64 i = b; i < e; ++i) {
out_xy.push_back(f32(x_[static_cast<usize>(i)] - origin));
out_xy.push_back(y_[static_cast<usize>(i)]);
}
return;
}
level = std::min(level, kMaxPyramidLevel);
// The bucket grid is the sample index, exactly as for a uniform series, and
// an XYSeries never slides its window, so the pyramid's base is 0 and the
// window range needs no origin shift. Bucket selection is the B3-correct
// ceil/floor: `first` is the first bucket whose samples all sit at or after
// `begin`, `last` one past the last bucket wholly before `end`, and the two
// edge spans around them are read from the raw arrays -- so no emitted
// geometry comes from outside [begin, end), and the emitted x values are the
// array's own coordinates relative to the caller's origin.
const u64 span = u64{kBucket} << level;
const u64 lend = pyramid().level_size(level);
const u64 first = std::min(ceil_div(b, span), lend);
const u64 last = std::min(e / span, lend);
if (first >= last) {
for (u64 i = b; i < e; ++i) {
out_xy.push_back(f32(x_[static_cast<usize>(i)] - origin));
out_xy.push_back(y_[static_cast<usize>(i)]);
}
return;
}
const u64 lead = first * span;
for (u64 i = b; i < lead; ++i) {
out_xy.push_back(f32(x_[static_cast<usize>(i)] - origin));
out_xy.push_back(y_[static_cast<usize>(i)]);
}
switch (mode) {
case GatherMode::Envelope: {
for (u64 bi = first; bi < last; ++bi) {
if (pyramid().bucket_count(level, bi) == 0) continue;
const MinMax m = pyramid().bucket(level, bi);
const u64 lo = bi * span; // the bucket's first sample
const u64 hi = std::min((bi + 1) * span, e) - 1; // its last sample inside the range
out_xy.push_back(f32(x_[static_cast<usize>(lo)] - origin));
out_xy.push_back(m.min);
out_xy.push_back(f32(x_[static_cast<usize>(hi)] - origin));
out_xy.push_back(m.max);
}
break;
}
case GatherMode::Nearest: {
for (u64 bi = first; bi < last; ++bi) {
const u64 centre = bi * span + span / 2; // inside the bucket, hence inside [b, e)
out_xy.push_back(f32(x_[static_cast<usize>(centre)] - origin));
out_xy.push_back(y_[static_cast<usize>(centre)]);
}
break;
}
case GatherMode::Mean: {
for (u64 bi = first; bi < last; ++bi) {
const u32 count = pyramid().bucket_count(level, bi);
if (count == 0) continue;
const u64 centre = bi * span + span / 2;
out_xy.push_back(f32(x_[static_cast<usize>(centre)] - origin));
out_xy.push_back(f32(pyramid().bucket_sum(level, bi) / f64(count)));
}
break;
}
}
const u64 trail = last * span;
for (u64 i = trail; i < e; ++i) {
out_xy.push_back(f32(x_[static_cast<usize>(i)] - origin));
out_xy.push_back(y_[static_cast<usize>(i)]);
}
}
void XYSeries::copy_raw(u64 begin, u64 end, f32* out) const {
const u64 want = end > begin ? end - begin : 0;
if (want == 0) return;
const u64 n = size();
const u64 b = std::min(begin, n);
const u64 e = std::min(std::max(end, b), n);
std::copy(y_.begin() + static_cast<std::ptrdiff_t>(b),
y_.begin() + static_cast<std::ptrdiff_t>(e), out);
std::fill(out + static_cast<std::ptrdiff_t>(e - b),
out + static_cast<std::ptrdiff_t>(want), 0.0f);
}
} // namespace plot::data
+6
View File
@@ -317,9 +317,15 @@ bool Texture::create_r8(u32 width, u32 height, Status* st) noexcept {
void Texture::upload_rgba(u32 width, u32 height, const void* pixels) const noexcept {
glBindTexture(GL_TEXTURE_2D, id_);
// RGBA rows are 4-byte aligned whatever the width, but the *caller's* upload
// state is not ours to move: save what the host had and put it back, the same
// contract `upload_r8`'s callers honour for the single-channel path.
GLint host_alignment = 4;
glGetIntegerv(GL_UNPACK_ALIGNMENT, &host_alignment);
glPixelStorei(GL_UNPACK_ALIGNMENT, 4);
glTexImage2D(GL_TEXTURE_2D, 0, static_cast<GLint>(GL_RGBA8), static_cast<GLsizei>(width),
static_cast<GLsizei>(height), 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels);
glPixelStorei(GL_UNPACK_ALIGNMENT, host_alignment);
}
void Texture::upload_r8(u32 width, u32 height, const void* pixels) const noexcept {
+310 -17
View File
@@ -39,6 +39,21 @@ constexpr f32 kCursorHitRadiusLogical = 7.0f;
/// A box-zoom selection narrower than this on either side is a click. Logical for
/// the same reason: the gesture is the same hand movement on any display.
constexpr f32 kMinBoxLogical = 4.0f;
/// Share of an axis' span one horizontal wheel notch (`dx`) pans, in axis space:
/// a notch is a small, deliberate step, a twentieth of the view.
constexpr f64 kPanPerNotch = 0.05;
/// Share of an axis' span one arrow-key press pans, in axis space -- twice the
/// wheel's step, because a key press is a coarser statement than a detent. A held
/// key repeats (the adapters' shared policy), so a hold pans continuously.
constexpr f64 kPanPerPress = 0.10;
/// The double-click window: two presses of the same button within this many
/// seconds -- and within `kDoubleClickPxLogical` of each other -- are a double-click
/// reset, not two gestures. Measured on `PointerEvent::time_s`, the host's clock.
constexpr f64 kDoubleClickSeconds = 0.35;
/// How close together the two presses of a double-click must land, in **logical**
/// pixels: the same hand-distance rule as the other tolerances here, converted
/// through `Style::device()` at the plot's scale.
constexpr f32 kDoubleClickPxLogical = 4.0f;
/// The narrowest *interior* a region's body keeps, in **logical** pixels: the edge bands are
/// cut back until this much of the middle is left, so a region too narrow for the full grab
/// radius on each side is still something a pointer can take hold of and *move*. Without it
@@ -136,7 +151,13 @@ void Controller::on_pointer(const PointerEvent& raw) {
plot_->layout(); // keep the chrome geometry the gestures map through current
if (e.pressed && e.down) {
if (double_click(e)) {
// The second press of a double-click re-fits every axis and begins no drag:
// a reset is a click-shaped gesture, not the start of one.
fit_all();
} else {
begin_drag(e);
}
} else if (e.released) {
end_drag(e);
} else if (e.down && mode_ != DragMode::None) {
@@ -158,13 +179,30 @@ void Controller::on_wheel(const WheelEvent& raw) {
// like a zero-notch zoom -- a factor of exactly one -- and the zoom body would
// still run, calling the range setters with the unchanged range. Those clear
// `auto_scale` unconditionally, so a single garbage wheel would silently disarm
// auto-fit on every axis. Return before the event can mutate anything.
if (!std::isfinite(raw.dy)) return;
// auto-fit on every axis. `dx` is refused by the same rule: it pans when `dy` is
// zero, and a garbage pan is no more welcome than a garbage zoom. Return before
// the event can mutate anything.
if (!std::isfinite(raw.dy) || !std::isfinite(raw.dx)) return;
const WheelEvent e = sanitized(raw, has_pointer_, pointer_x_, pointer_y_);
plot_->layout();
// A zero-notch wheel -- `dy == 0` and `dx == 0`, which a trackpad or a driver
// happily forwards -- must change nothing at all. Letting it run would call the
// range setters with every range unchanged, and those clear `auto_scale`: one
// empty scroll would silently disarm auto-fit on every axis. There is nothing to
// apply, so there is nothing to lay out or republish either.
if (e.dy == 0.0f && e.dx == 0.0f) return;
if (e.dy != 0.0f) {
const f64 factor = zoom_factor(static_cast<f64>(e.dy));
if (!(factor > 0.0)) return;
// The zoom is refused, and the whole event with it, when its factor could not
// be applied honestly: a factor of exactly one changes no range but would
// still clear `auto_scale` on the way through the setters, a non-finite one
// (a huge downward `dy` explodes `exp`) would hand a non-finite span to the
// setters, and a non-positive one is not a zoom at all. "Leaves the plot
// exactly as it was" is the rule for an unusable amount.
if (!std::isfinite(factor) || !(factor > 0.0) || factor == 1.0) return;
const bool shift = has_mod(e.mods, Mod::Shift);
const bool ctrl = has_mod(e.mods, Mod::Ctrl);
@@ -183,6 +221,17 @@ void Controller::on_wheel(const WheelEvent& raw) {
zoom_y_axis(i, plot_->y_transform(i).to_data_y(static_cast<f64>(e.y)), factor);
}
}
}
// A horizontal wheel pans x only: `dx` notches, 5% of the x span each, in axis
// space (so a log axis scrolls multiplicatively, exactly as its transform maps).
// A positive `dx` scrolls right -- the view moves toward larger x, the same
// reading `WheelEvent`'s comment gives the field. On a live axis the range
// setter reads the shifted window as a span and an offset behind the edge, so
// scrolling right runs to the live edge and stops there, exactly as a drag pan
// does.
if (e.dx != 0.0f) shift_x_axes(static_cast<f64>(e.dx) * kPanPerNotch);
// The ranges moved under a pointer that did not, so the geometry under it changed: refresh
// the layout -- the region rects the hit test reads were measured for the range this wheel
// has just replaced -- and republish what is hot. A frame drawn right after a scroll then
@@ -197,29 +246,106 @@ void Controller::on_wheel(const WheelEvent& raw) {
publish_hot();
}
void Controller::on_gesture(const GestureEvent& raw) {
// The same refusal rule as the wheel: a frame carrying something that is not
// a number is not a gesture, and a frame that could not change anything --
// a unit scale and no movement -- must not reach the range setters, which
// clear `auto_scale` for no visible change. `scale <= 0` is not a zoom.
if (!std::isfinite(raw.x) || !std::isfinite(raw.y) || !std::isfinite(raw.scale) ||
!std::isfinite(raw.dx) || !std::isfinite(raw.dy)) {
return;
}
if (raw.scale <= 0.0f) return;
if (raw.scale == 1.0f && raw.dx == 0.0f && raw.dy == 0.0f) return;
plot_->layout();
const f64 factor = 1.0 / static_cast<f64>(raw.scale); // pinch out = zoom in
if (std::isfinite(factor) && factor > 0.0 && factor != 1.0) {
// A pinch is a two-handed instrument, so it has no modifier semantics: it
// zooms both axes about the focal point, exactly as an unmodified wheel
// notch pair would -- same setters, same live-axis behaviour.
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
zoom_x_axis(i, x_zoom_anchor(i, raw.x, raw.y), factor);
}
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
zoom_y_axis(i, plot_->y_transform(i).to_data_y(static_cast<f64>(raw.y)), factor);
}
}
// The focal point's movement is a two-finger pan: the same path a one-finger
// drag takes, deltas included, so a pinch-and-drag moves the data under the
// fingers on every axis the drag would move.
if (raw.dx != 0.0f || raw.dy != 0.0f) {
PointerEvent pan_e;
pan_e.x = raw.x;
pan_e.y = raw.y;
pan_e.dx = raw.dx;
pan_e.dy = raw.dy;
pan(pan_e);
}
plot_->layout();
if (has_pointer_) update_crosshair(pointer_x_, pointer_y_);
publish_hot();
}
void Controller::on_key(const KeyEvent& e) {
if (!e.down) return; // every action below is press-only
// Escape cancels the active drag (see cancel_drag). A key with no drag open --
// and any other key -- changes nothing and returns below without touching the
// layout, the crosshair or the published handles.
if (e.key == kKeyEscape) {
if (mode_ != DragMode::None) cancel_drag();
return;
}
// The arrow keys pan by axis class: Left/Right every x axis, Up/Down every y
// axis, 10% of that axis' own span per press (and per repeat, which is why the
// adapters forward key repeat). An arrow says where to *look*: Left walks the
// window toward smaller x, Up toward larger y -- the screen-y inversion a drag's
// dy carries does not apply, because a drag says where the data goes and a key
// says where the viewer goes.
switch (e.key) {
case kKeyLeft: shift_x_axes(-kPanPerPress); break;
case kKeyRight: shift_x_axes(kPanPerPress); break;
case kKeyUp: shift_y_axes(kPanPerPress); break;
case kKeyDown: shift_y_axes(-kPanPerPress); break;
default:
break;
}
const bool pans = e.key >= kKeyLeft && e.key <= kKeyDown;
const bool zooms = e.key == kKeyZoomIn || e.key == kKeyZoomOut;
const bool fits = e.key == kKeyFit || e.key == kKeyFitUpper;
if (!zooms && !fits) return; // a key this controller has no gesture for
if (!pans && !zooms && !fits) return; // a key this controller has no gesture for
if (zooms) {
const f64 notches = e.key == kKeyZoomIn ? kKeyZoomNotches : -kKeyZoomNotches;
const f64 factor = zoom_factor(notches);
// Ctrl restricts the zoom to x, Alt to y; bare +/- zooms both. (Shift cannot be
// used: '+' is shift+'=' on common layouts, so key_from() folds them together and
// a bare zoom-in already arrives with Mod::Shift set.) Ctrl+Alt zooms neither,
// which is harmless and needs no special case.
const bool only_x = has_mod(e.mods, Mod::Ctrl);
const bool only_y = has_mod(e.mods, Mod::Alt);
if (!only_y) {
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
const Axis& a = plot_->x_axis(i);
const f64 lo = to_space(a.range.min, a.log);
const f64 hi = to_space(a.range.max, a.log);
zoom_x_axis(i, from_space(0.5 * (lo + hi), a.log), factor);
}
}
if (!only_x) {
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
const Axis& a = plot_->y_axis(i);
const f64 lo = to_space(a.range.min, a.log);
const f64 hi = to_space(a.range.max, a.log);
zoom_y_axis(i, from_space(0.5 * (lo + hi), a.log), factor);
}
} else {
}
} else if (fits) {
plot_->auto_fit_x();
for (u32 i = 0; i < plot_->y_axis_count(); ++i) plot_->auto_fit_y(i);
}
@@ -363,14 +489,34 @@ void Controller::begin_drag(const PointerEvent& e) {
drag_cursor_ = kInvalid;
drag_region_ = kInvalid;
drag_zone_ = RegionGrab::None;
// What Escape will restore if this gesture is cancelled: the axes' state *now*,
// before the drag moves anything (see capture_ranges).
capture_ranges();
// Shift first, and only on the left: it is a modifier gesture of its own, and a
// selection box is what it means even when it starts over a handle.
if (e.button == Button::Left && has_mod(e.mods, Mod::Shift)) {
// The modifier box zoom, on the left only: a selection box is what a modified
// left press means even when it starts over a handle. The mask is resolved once,
// here, from the modifier table -- Shift is the plain both-axis selection it has
// always been, Ctrl frames the x range only, Alt the y range only, and any other
// combo falls back to both -- and `apply_box_zoom()` reads it on release, per
// axis and for the click threshold alike.
if (e.button == Button::Left) {
const bool shift = has_mod(e.mods, Mod::Shift);
const bool ctrl = has_mod(e.mods, Mod::Ctrl);
const bool alt = has_mod(e.mods, Mod::Alt);
if (shift || ctrl || alt) {
mode_ = DragMode::BoxZoom;
// The masks: Ctrl alone frames the x range only, Alt alone the y range only,
// and every other combo (Shift included) frames both -- the field says whether
// the release *applies* that axis, so the axis named by the mask is the one
// that stays true.
const bool ctrl_only = ctrl && !shift && !alt;
const bool alt_only = alt && !shift && !ctrl;
box_zoom_x_ = !alt_only;
box_zoom_y_ = !ctrl_only;
selection_ = Rect{e.x, e.y, 0.0f, 0.0f};
return;
}
}
if (e.button == Button::Middle) {
const PlotLayout& lay = std::as_const(*plot_).layout();
if (lay.x_label_area.contains(e.x, e.y)) {
@@ -472,10 +618,113 @@ void Controller::end_drag(const PointerEvent& e) {
drag_cursor_ = kInvalid;
drag_region_ = kInvalid;
drag_zone_ = RegionGrab::None;
// The capture is the cancelled gesture's undo record, and this gesture was not
// cancelled: keeping it would let a later Escape restore a state that ended long
// ago (and `capture_ranges()` only runs at a press).
saved_x_.clear();
saved_y_.clear();
}
// --- gestures -------------------------------------------------------------
void Controller::capture_ranges() {
saved_x_.clear();
saved_y_.clear();
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
const Axis& a = plot_->x_axis(i);
saved_x_.push_back(SavedX{a.range, a.offset, a.auto_scale});
}
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
const Axis& a = plot_->y_axis(i);
saved_y_.push_back(SavedY{a.range, a.auto_scale});
}
}
void Controller::cancel_drag() {
// The ranges the axes held at `begin_drag`, through the plot's own setters: an
// `Off` axis gets its range and its auto-scale flag back exactly, and a live one
// gets its span and offset restated the way the setters read them (the offset is
// written back verbatim after the range, since the live edge of *this* moment --
// not of the press -- is what the setter would otherwise derive it from). A
// cancelled gesture is as if it never ran: nothing is applied, no auto-scale is
// disarmed that was not already, and no box is framed.
for (u32 i = 0; i < plot_->x_axis_count() && i < saved_x_.size(); ++i) {
const SavedX& saved = saved_x_[i];
plot_->set_x_range(i, saved.range);
plot_->set_x_offset(i, saved.offset);
plot_->set_x_auto_scale(i, saved.auto_scale);
}
for (u32 i = 0; i < plot_->y_axis_count() && i < saved_y_.size(); ++i) {
const SavedY& saved = saved_y_[i];
plot_->set_y_range(i, saved.range);
plot_->set_y_auto_scale(i, saved.auto_scale);
}
// The gesture itself: no mode, no selection, nothing grabbed -- and the highlight
// the renderer draws follows, through the same tail every event that changes the
// geometry under the pointer runs.
mode_ = DragMode::None;
selection_ = Rect{};
drag_cursor_ = kInvalid;
drag_region_ = kInvalid;
drag_zone_ = RegionGrab::None;
plot_->layout();
if (has_pointer_) update_crosshair(pointer_x_, pointer_y_);
publish_hot();
}
void Controller::fit_all() {
plot_->auto_fit_x();
for (u32 i = 0; i < plot_->y_axis_count(); ++i) plot_->auto_fit_y(i);
// A fit moved the geometry under a stationary pointer, exactly as a wheel notch
// does -- same tail, same reason (see on_wheel).
plot_->layout();
if (has_pointer_) update_crosshair(pointer_x_, pointer_y_);
publish_hot();
}
bool Controller::double_click(const PointerEvent& e) {
const f32 radius = plot_->style().device(kDoubleClickPxLogical);
const f32 dx = e.x - click_x_;
const f32 dy = e.y - click_y_;
// Both presses must carry a host timestamp for the window to mean anything: a
// host that leaves `time_s` at 0 never produces a double-click (the field is
// optional, and two presses "0 s apart" would otherwise fire on any host).
const bool in_time = e.time_s > 0.0 && click_time_s_ > 0.0 &&
e.time_s - click_time_s_ <= kDoubleClickSeconds;
const bool in_space = dx * dx + dy * dy <= radius * radius;
const bool is_double =
has_click_ && click_button_ == static_cast<int>(e.button) && in_time && in_space;
// This press is what a third one would be measured against -- unless the two
// just made a double-click, in which case the record clears: a third press starts
// a fresh gesture rather than re-firing the reset.
has_click_ = !is_double;
click_button_ = static_cast<int>(e.button);
click_time_s_ = e.time_s;
click_x_ = e.x;
click_y_ = e.y;
return is_double;
}
void Controller::shift_x_axes(f64 fraction) {
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
const Axis& a = plot_->x_axis(i);
const f64 lo = to_space(a.range.min, a.log);
const f64 hi = to_space(a.range.max, a.log);
const f64 d = fraction * (hi - lo); // positive scrolls toward larger x
plot_->set_x_range(i, Range{from_space(lo + d, a.log), from_space(hi + d, a.log)});
}
}
void Controller::shift_y_axes(f64 fraction) {
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
const Axis& a = plot_->y_axis(i);
const f64 lo = to_space(a.range.min, a.log);
const f64 hi = to_space(a.range.max, a.log);
const f64 d = fraction * (hi - lo); // positive scrolls toward larger y
plot_->set_y_range(i, Range{from_space(lo + d, a.log), from_space(hi + d, a.log)});
}
}
void Controller::pan(const PointerEvent& e) {
// The transforms map a range onto layout().axes, so the pixel-to-data scale is
// the range span over the axes rect -- not over the whole viewport. Using the
@@ -502,6 +751,31 @@ void Controller::pan(const PointerEvent& e) {
}
}
Rect Controller::selection_preview() const noexcept {
// Nothing to preview off a box zoom: the mask bits outlive the gesture -- `end_drag()` clears the
// selection, not them -- so widening without this guard would hand out a full-band rect for a drag
// that finished minutes ago.
if (mode_ != DragMode::BoxZoom) return Rect{};
// And nothing has been framed until the drag has swept every axis the release will apply: at the
// press instant the selection is an empty rect at the pointer, and the *widened* form of that is a
// band the width of the plot, which is a claim about a range that does not exist yet. Ctrl's other
// axis is deliberately not required: a drag with no vertical movement at all is a band across the
// plot, which is exactly what Ctrl is for.
if ((box_zoom_x_ && !(selection_.w > 0.0f)) || (box_zoom_y_ && !(selection_.h > 0.0f))) return Rect{};
Rect preview = selection_;
if (box_zoom_x_ && box_zoom_y_) return preview;
const Rect& axes = plot_->layout().axes;
if (!box_zoom_x_) {
preview.x = axes.x;
preview.w = axes.w;
}
if (!box_zoom_y_) {
preview.y = axes.y;
preview.h = axes.h;
}
return preview;
}
void Controller::box_zoom_update(const PointerEvent& e) {
const f32 x = std::min(origin_x_, e.x);
const f32 y = std::min(origin_y_, e.y);
@@ -510,12 +784,17 @@ void Controller::box_zoom_update(const PointerEvent& e) {
void Controller::apply_box_zoom() {
const Rect sel = selection_;
// A selection under a few *logical* pixels on either side is a click: no zoom.
// The selection is measured in device pixels like everything the controller is
// handed, so the threshold is converted at the plot's own scale -- through the one
// place that conversion lives, so a change to its fallback is picked up here too.
// A selection under a few *logical* pixels on the axes the mask will zoom is a
// click: no zoom. The threshold is masked with the gesture -- a Ctrl drag frames
// the x range from a selection however short it is vertically, and vice versa --
// which is what makes a thin horizontal band a legal x-only box rather than a
// click no matter what the y says. The selection is measured in device pixels
// like everything the controller is handed, so the threshold is converted at the
// plot's own scale -- through the one place that conversion lives, so a change to
// its fallback is picked up here too.
const f32 min_box = plot_->style().device(kMinBoxLogical);
if (sel.w < min_box || sel.h < min_box) return;
if (box_zoom_x_ && sel.w < min_box) return;
if (box_zoom_y_ && sel.h < min_box) return;
// Clamped to the axes rect the transforms are built from, so a drag that left
// the plot area cannot ask for data outside what the box covers on screen.
@@ -528,18 +807,25 @@ void Controller::apply_box_zoom() {
const f64 r = std::clamp(static_cast<f64>(sel.right()), ax, ar);
const f64 t = std::clamp(static_cast<f64>(sel.y), ay, ab);
const f64 b = std::clamp(static_cast<f64>(sel.bottom()), ay, ab);
if (!(r > l) || !(b > t)) return;
if (box_zoom_x_ && !(r > l)) return;
if (box_zoom_y_ && !(b > t)) return;
// The ranges come straight from the transforms, so the box frames exactly the
// data under it; the selection's bottom edge is the range minimum on y.
// data under it; the selection's bottom edge is the range minimum on y. Masked
// axes are left exactly as they were -- the release applies only what the press
// promised.
if (box_zoom_x_) {
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
const Transform tx = plot_->x_transform(i);
plot_->set_x_range(i, Range{tx.to_data_x(l), tx.to_data_x(r)});
}
}
if (box_zoom_y_) {
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
const Transform ty = plot_->y_transform(i);
plot_->set_y_range(i, Range{ty.to_data_y(b), ty.to_data_y(t)});
}
}
}
void Controller::move_cursor(const PointerEvent& e) {
@@ -621,7 +907,12 @@ void Controller::axis_zoom(const PointerEvent& e) {
if (!(scale > 0.0) || !std::isfinite(scale)) return;
const f64 logical_dy = static_cast<f64>(e.dy) / scale;
const f64 factor = std::exp(-logical_dy * kZoomPerPixel);
if (!(factor > 0.0)) return;
// The same degenerate factors the wheel refuses: a factor of exactly one (a
// motion that reports no dy -- running it would still clear `auto_scale` on its
// way through the range setters), a non-finite one (an enormous dy explodes
// `exp` into a span the setters cannot store), and a non-positive one, which is
// not a zoom at all.
if (!std::isfinite(factor) || !(factor > 0.0) || factor == 1.0) return;
if (mode_ == DragMode::AxisZoomX) {
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
zoom_x_axis(i, x_zoom_anchor(i, e.x, e.y), factor);
@@ -669,7 +960,9 @@ u64 Controller::cursor_at(f32 px, f32 py) const {
f32 best_d = 0.0f;
for (u32 i = 0; i < plot_->cursor_count(); ++i) {
const Cursor& cursor = plot_->cursor(i);
if (!cursor.visible) continue;
// A readout is drawn but not offered to the pointer: skipping it here is what makes it
// undraggable, rather than fighting the drag a frame at a time.
if (!cursor.visible || !cursor.selectable) continue;
// One hit test for both orientations, because a cursor *is* one line across the axes:
// what the pointer has to be near is that line's own coordinate -- the column of a
// vertical marker, the row of a horizontal ruler -- measured with the same radius
+17 -3
View File
@@ -3,18 +3,32 @@
#include <plot/plot/axis.hpp>
#include <cmath>
namespace plot {
void Axis::format_value(f64 v, char* buf, usize n) const {
// The one place a value on this axis becomes text, so the origin is applied here and
// everything that goes through it -- tick labels, and the crosshair's readout -- reads in
// the same frame of reference as the axis itself.
// The subtraction leaves a residue when the value and the origin are the same distant number
// (a tick exactly on the origin then formatted as `1.78e-12ms` instead of `0ms`). Snapping it
// is a *relative* test, so a genuinely small value on a large axis is not swallowed -- what
// this removes is arithmetic dust, not data.
f64 shown = v - origin;
if (std::fabs(shown) < 1.0e-12 * std::max(1.0, std::fabs(v))) shown = 0.0;
if (format == TickFormat::Time) {
format_time(v, buf, n);
format_time(shown, buf, n);
return;
}
format_si(v, buf, n);
format_si(shown, buf, n);
}
void Axis::refresh(f32 px, f32 min_px, bool include_minors) {
generate_ticks(range, px, min_px, ticks, include_minors);
// The range first, so a following origin is this frame's edge and not the last one's: the
// zero tick then sits exactly on the window start, which is the whole point of asking.
if (origin_follows_range) origin = range.min;
generate_ticks(range, px, min_px, ticks, include_minors, origin);
if (format != TickFormat::Time) return;
// generate_ticks labels with format_si; a time axis relabels its majors here,
// because the tick generator has no business knowing about a clock -- through the one
+402 -51
View File
@@ -4,6 +4,7 @@
#include <plot/plot/figure.hpp>
#include <algorithm>
#include <cstdio>
#include <cmath>
#include <cstddef>
@@ -22,20 +23,172 @@ namespace {
/// wants a taller floor gives the figure a taller viewport, or gives the rows weights.
constexpr f32 kMinRowHeightLogical = 24.0f;
} // namespace
/// The width a cell never drops below, in logical pixels, converted exactly as the row
/// height floor is. It yields to the row -- capped at an equal share of the width there
/// is -- so a row too narrow for its cells still tiles instead of overflowing.
constexpr f32 kMinCellWidthLogical = 64.0f;
Figure::Figure(u32 rows) : plots_(rows) {}
Plot& Figure::plot(u32 row) noexcept {
return row < plots_.size() ? plots_[row] : fallback_;
/// The number of plots a per-row cell count asks for, each row holding at least one.
u32 total_cells(std::span<const u32> cells) noexcept {
u32 total = 0;
for (const u32 c : cells) total += std::max<u32>(c, 1u);
return total;
}
const Plot& Figure::plot(u32 row) const noexcept {
return row < plots_.size() ? plots_[row] : fallback_;
/// Splits the row rect `r` into `n` cells in `out[0..n)` and returns the gap actually used.
/// The cells tile `r` exactly: the gap is dropped when `r` cannot hold it, every cell first
/// gets the floor width and then its weight's share of what is left, and the last cell
/// closes the row on its right edge, absorbing the rounding of the ones before it.
f32 place_cells(const Rect& r, u32 n, const std::vector<f32>& weights, f32 col_gap,
f32 ui_scale, Rect* out) {
if (n == 1u) {
out[0] = r;
return col_gap;
}
f32 gap = col_gap;
f32 usable = r.w - gap * static_cast<f32>(n - 1u);
if (usable <= 0.0f) {
gap = 0.0f;
usable = r.w;
}
usable = std::max(0.0f, usable);
const f32 floor_device = ui_scale > 0.0f ? kMinCellWidthLogical * ui_scale
: kMinCellWidthLogical;
const f32 floor_px = std::min(floor_device, usable / static_cast<f32>(n));
const f32 extra = usable - floor_px * static_cast<f32>(n);
f32 total = 0.0f;
const bool weighted = weights.size() == n;
if (weighted) {
for (const f32 w : weights) total += w;
}
f32 x = r.x;
f32 consumed = 0.0f;
for (u32 i = 0; i < n; ++i) {
const f32 share = weighted && total > 0.0f ? weights[i] / total : 1.0f / static_cast<f32>(n);
const f32 end = i + 1u == n ? usable : consumed + floor_px + extra * share;
out[i] = Rect{x, r.y, end - consumed, r.h};
consumed = end;
x = out[i].right() + gap;
}
return gap;
}
/// The share of the row each of its `n` cells is given by `weights`, exactly as `place_cells`
/// reads them: an empty (or wrongly sized, or non-positive-total) vector means equal shares.
std::vector<f32> normalised_shares(const std::vector<f32>& weights, u32 n) {
std::vector<f32> shares(n, 1.0f / static_cast<f32>(n));
f32 total = 0.0f;
if (weights.size() == n) {
for (const f32 w : weights) total += w;
}
if (total > 0.0f) {
for (u32 i = 0; i < n; ++i) shares[i] = weights[i] / total;
}
return shares;
}
/// Whether two rows have the **same columns**: the same number of cells and the same
/// normalised cell weights (within 1e-4). The rows are all the figure's width, so such rows
/// get cells with identical x extents -- the same y gutter, gap, floors and right gutter
/// apply to both -- and one row's x axis serves the other. Structural, never content: it
/// reads the cell counts and the weights the host set and nothing a plot holds.
bool same_columns(const std::vector<u32>& cells_per_row,
const std::vector<std::vector<f32>>& cell_weights, u32 row_a, u32 row_b) {
const u32 n = cells_per_row[row_a];
if (n != cells_per_row[row_b]) return false;
if (n == 1u) return true;
const std::vector<f32> a = normalised_shares(cell_weights[row_a], n);
const std::vector<f32> b = normalised_shares(cell_weights[row_b], n);
for (u32 i = 0; i < n; ++i) {
if (std::fabs(a[i] - b[i]) > 1e-4f) return false;
}
return true;
}
/// A coordinate in the shared axis' **axis space**: `log10` when the figure's one
/// x axis -- the bottom row's, the row the shared strip belongs to -- is
/// logarithmic, the value itself otherwise. A following group's span and offset
/// live in this space exactly as a single `Live` axis' do (see `Axis::offset`), so
/// a log group scrolls decades and holds decades; a *linear* span there would
/// rescale every row's view as the live edge moved. The bottom row rules the
/// space because it owns the shared axis; a group whose members disagree about
/// `log` still gets one data-space range pushed into every member, computed in
/// that row's space.
f64 to_link_space(f64 v, bool log) noexcept {
return log ? Transform::log10_positive(v) : v;
}
f64 from_link_space(f64 v, bool log) noexcept {
return log ? std::pow(10.0, v) : v;
}
} // namespace
Figure::Figure(u32 rows) : plots_(rows) {
const std::vector<u32> ones(rows, 1u);
build_cells(std::span<const u32>(ones));
}
Figure::Figure(std::span<const u32> cells_per_row) : plots_(total_cells(cells_per_row)) {
build_cells(cells_per_row);
}
void Figure::build_cells(std::span<const u32> cells) {
cells_per_row_.assign(cells.begin(), cells.end());
for (u32& c : cells_per_row_) c = std::max<u32>(c, 1u);
row_first_.assign(cells_per_row_.size() + 1u, 0u);
for (std::size_t r = 0; r < cells_per_row_.size(); ++r) {
row_first_[r + 1u] = row_first_[r] + cells_per_row_[r];
}
cell_weights_.assign(cells_per_row_.size(), {});
}
u32 Figure::cells_in_row(u32 row) const noexcept {
return row < cells_per_row_.size() ? cells_per_row_[row] : 0u;
}
u32 Figure::first_plot_of_row(u32 row) const noexcept {
return row < cells_per_row_.size() ? row_first_[row] : plot_count();
}
u32 Figure::row_of_plot(u32 plot) const noexcept {
u32 r = 0;
while (r + 1u < cells_per_row_.size() && row_first_[r + 1u] <= plot) ++r;
return r;
}
Plot& Figure::plot(u32 index) noexcept {
return index < plots_.size() ? plots_[index] : fallback_;
}
const Plot& Figure::plot(u32 index) const noexcept {
return index < plots_.size() ? plots_[index] : fallback_;
}
Status Figure::set_cell_weights(u32 row, std::span<const f32> weights) {
if (row >= cells_per_row_.size() || weights.size() != cells_per_row_[row]) {
return Status{ErrorCode::InvalidArgument, "cell weights must name every cell of an existing row"};
}
for (const f32 w : weights) {
if (!std::isfinite(w) || !(w > 0.0f)) {
return Status{ErrorCode::InvalidArgument,
"cell weights must be positive finite numbers: a weight is a share of "
"the row, and a cell with none of it has no rect"};
}
}
cell_weights_[row].assign(weights.begin(), weights.end());
return Status{};
}
void Figure::clear_cell_weights(u32 row) noexcept {
if (row < cell_weights_.size()) cell_weights_[row].clear();
}
bool Figure::has_cell_weights(u32 row) const noexcept {
return row < cell_weights_.size() && !cell_weights_[row].empty();
}
Status Figure::set_row_weights(std::span<const f32> weights) {
if (weights.size() != plots_.size()) {
if (weights.size() != cells_per_row_.size()) {
return Status{ErrorCode::InvalidArgument, "row weights must name every row"};
}
f32 total = 0.0f;
@@ -151,9 +304,11 @@ Status Figure::set_x_link_range(u32 group, Range r) {
// Under a following group the argument is not a pair of positions: it states the
// span the window should have and, through its maximum, where the window sits
// behind the live edge -- the same reinterpretation `Plot::set_x_range()` makes
// on a `Live` axis, applied to the group's own edge. A span that is not a span is
// refused rather than stored, because a following group has nothing to place a
// window from otherwise.
// on a `Live` axis, applied to the group's own edge. Both the span and the
// offset are taken in the shared axis' **axis space** (log10 decades on a log
// group), which is what makes the window hold decades instead of a decaying
// linear width. A span that is not a span is refused rather than stored,
// because a following group has nothing to place a window from otherwise.
const f64 lo = std::min(r.min, r.max);
const f64 hi = std::max(r.min, r.max);
if (!(hi > lo)) {
@@ -161,8 +316,9 @@ Status Figure::set_x_link_range(u32 group, Range r) {
"a following group's range states the window's span, so its bounds "
"must differ"};
}
g.span = hi - lo;
g.offset = live.max - hi;
const bool log = !plots_.empty() && plots_.back().x_axis(0u).log;
g.span = to_link_space(hi, log) - to_link_space(lo, log);
g.offset = to_link_space(live.max, log) - to_link_space(hi, log);
g.has_range = true;
place_live_window(g);
push_link(g);
@@ -249,18 +405,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;
}
@@ -292,6 +466,81 @@ bool Figure::adopt_member_changes(XLink& group) {
return false;
}
void Figure::set_share_row_y(bool on) {
share_row_y_ = on;
row_y_.clear();
for (u32 row = 0; row < cells_per_row_.size(); ++row) {
if (cells_per_row_[row] < 2u) continue;
for (u32 i = row_first_[row]; i < row_first_[row] + cells_per_row_[row]; ++i) {
plots_[i].set_y_link_member(0u, on);
}
}
}
bool Figure::row_y_auto(u32 row) const noexcept {
return row < row_y_.size() && row_y_[row].have ? row_y_[row].auto_scale : true;
}
void Figure::sync_row_y() {
if (!share_row_y_) return;
row_y_.resize(cells_per_row_.size());
for (u32 row = 0; row < cells_per_row_.size(); ++row) {
const u32 n = cells_per_row_[row];
if (n < 2u) continue;
RowY& g = row_y_[row];
const u32 first = row_first_[row];
// Axes added after set_share_row_y() are covered here.
for (u32 i = first; i < first + n; ++i) plots_[i].set_y_link_member(0u, true);
if (!g.have) {
g.pushed = plots_[first].y_axis(0u).range;
g.auto_scale = plots_[first].y_axis(0u).auto_scale;
}
// What happened since the last push. A member's auto flag that differs from the group's
// is a request (set_y_range clears it, auto_fit_y / set_y_auto_scale(true) sets it);
// otherwise a member whose range differs from the pushed one was moved by a gesture.
bool want_auto = g.auto_scale;
Range target = g.pushed;
bool flag_request = false;
for (u32 i = first; i < first + n && !flag_request; ++i) {
const Axis& a = plots_[i].y_axis(0u);
if (a.auto_scale != g.auto_scale) {
want_auto = a.auto_scale;
if (!want_auto) target = a.range; // auto -> manual: the gesture's range
flag_request = true;
}
}
if (!flag_request && !want_auto) {
for (u32 i = first; i < first + n; ++i) {
const Range r = plots_[i].y_axis(0u).range;
if (r.min != g.pushed.min || r.max != g.pushed.max) {
target = r;
break;
}
}
}
if (want_auto) {
Range bounds{};
bool any = false;
for (u32 i = first; i < first + n; ++i) {
Range b{};
if (!plots_[i].y_data_bounds(0u, b)) continue;
bounds = any ? Range{std::min(bounds.min, b.min), std::max(bounds.max, b.max)} : b;
any = true;
}
// The first cell stands in for the row: all cells are assumed to share one y axis kind/padding (the host builds them identically).
if (any && plots_[first].fit_y_bounds(0u, bounds)) target = plots_[first].y_axis(0u).range;
// else: nothing to fit -- keep the range the group had
}
for (u32 i = first; i < first + n; ++i) {
plots_[i].set_y_range(0u, target); // clears the flag ...
plots_[i].set_y_auto_scale(0u, want_auto); // ... so restate it: every member mirrors the group
}
g.pushed = target;
g.auto_scale = want_auto;
g.have = true;
}
}
void Figure::sync_x_links() {
for (XLink& group : links_) {
if (group.rows.empty()) continue;
@@ -300,15 +549,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);
}
}
}
@@ -326,6 +580,8 @@ void Figure::sync_x_links() {
const FigureLayout& Figure::layout() {
const u32 rows = row_count();
layout_.rows.assign(rows, Rect{});
layout_.cells.clear();
layout_.col_gap = 0.0f;
layout_.row_gap = row_gap_;
layout_.x_axis_strip = Rect{};
if (rows == 0u) {
@@ -334,12 +590,13 @@ const FigureLayout& Figure::layout() {
}
const f32 figure_h = std::max(0.0f, viewport_.h);
// The rows tile the figure itself: the shared x axis is the *bottom row's own*, so
// nothing is taken off the figure for it. That is what keeps the axis reachable --
// its strip is a real `PlotLayout::x_label_area` inside the bottom row's rect, which
// is the rect the controller's axis-zoom hit test reads and the strip an x title is
// drawn in -- and it is why an interior row reserves no x chrome at all instead of
// leaving a band no one owns.
// The rows tile the figure itself: an x axis is a row's *own* strip (the bottom row's, and
// the strip of any row whose columns differ from the row below), so nothing is taken off
// the figure for it. That is what keeps the axis reachable -- its strip is a real
// `PlotLayout::x_label_area` inside a row's rect, which is the rect the controller's
// axis-zoom hit test reads and the strip an x title is drawn in -- and it is why a row
// that hands its axis to the one below reserves no x chrome at all instead of leaving a
// band no one owns.
const f32 content_h = figure_h;
// The gap is dropped, rather than squeezing the rows, when the figure cannot hold
// it: a figure one pixel tall with four rows still gives every row a positive
@@ -398,21 +655,59 @@ const FigureLayout& Figure::layout() {
// short to hold the one asked for.
layout_.row_gap = gap;
const u32 bottom_row = rows - 1u;
// Each row rect is split into its cells; a one-cell row's cell is the rect itself, so an
// unsplit figure lays out exactly as it always did.
layout_.cells.assign(plots_.size(), Rect{});
layout_.col_gap = col_gap_;
for (u32 row = 0; row < rows; ++row) {
Plot& p = plots_[row];
p.set_viewport(layout_.rows[row]);
// One x axis for the whole figure, and it is the bottom row's: every row keeps its
// x chrome -- labels, title and the strip the controller's axis zoom is found in --
// and every other row gives all of it to that one, so the labels and the title are
// 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);
const u32 n = cells_per_row_[row];
const f32 used = place_cells(layout_.rows[row], n, cell_weights_[row], col_gap_,
floor_scale, &layout_.cells[row_first_[row]]);
if (n > 1u && used < col_gap_) layout_.col_gap = 0.0f;
}
// Which rows carry their own x strip, decided structurally and before the first
// measurement pass so that pass and the final one see the same strips. The bottom row
// always has one. A row above hides its strip only when the row directly below has the
// same columns (see `same_columns()`), because then the cells of the two rows have the
// same x extents and the axis below serves this row too; the rule is transitive, since
// that row is itself either labelled or hidden in favour of one that matches it. A row
// whose columns differ from the one below -- `[1][2]` over `[3]` -- keeps its own strip,
// drawn under each of its cells, because the axis below is at another scale and width.
// A figure told to label every row keeps the strips everywhere.
std::vector<XStrip> strips(rows, XStrip::Both);
if (labels_bottom_only_) {
for (u32 row = rows - 1u; row-- > 0u;) {
// By induction the row below is labelled or itself hidden in favour of a matching
// row, so hiding this one on a match with it is the transitive rule.
if (same_columns(cells_per_row_, cell_weights_, row, row + 1u)) strips[row] = XStrip::None;
}
}
for (u32 row = 0; row < rows; ++row) {
const XStrip strip = strips[row];
// A split row whose cells share one y range also shares one y axis, drawn by its first
// cell: every other cell reserves no y chrome at all (see `YStrip::None`).
const bool shared_y = share_row_y_ && cells_per_row_[row] > 1u;
// ... and its right gutter is the last cell's alone, so the gap between two plots of the
// row is the column gap and not a gutter more (see the placement block below).
const u32 last = row_first_[row + 1u] - 1u;
for (u32 i = row_first_[row]; i < row_first_[row + 1u]; ++i) {
Plot& p = plots_[i];
p.set_viewport(layout_.cells[i]);
p.set_x_strip_mode(strip);
p.set_y_strip_mode(shared_y && i != row_first_[row] ? YStrip::None : YStrip::Both);
// Before either pass below, and the same value for every plot: nothing is measured on
// the right, so unlike the left gutter this needs no measurement pass -- the plots
// end on a common column because they are all told the one width.
p.set_right_gutter(shared_y && i != last ? 0.0f : 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
// out below, so a following group's window is the range they lay out with.
sync_x_links();
// Before any `Plot::layout()`, where a plot would autoscale its own y axis.
sync_row_y();
// Every row is laid out against its own rect, so after this call each row's
// chrome is current: `plot(i).layout()` reads back what the figure computed rather
// than what the row last held. That is the contract `Renderer::draw(Figure&)`
@@ -435,26 +730,67 @@ 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;
for (u32 row = 0; row < rows; ++row) {
plots_[row].set_min_y_gutter(0.0f);
const PlotLayout& row_layout = plots_[row].layout();
//
// `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.
//
// Over the flattened plots: every cell of every row takes part, so the y axes of a split
// row line up with each other and with the rows above and below.
f32 gutter = min_y_gutter_;
//
// A `YStrip::None` cell draws no y axis, so it has no width to contribute and takes no part
// in the maximum: the gutter is the widest of the cells that do draw one.
for (Plot& p : plots_) {
p.set_min_y_gutter(0.0f);
const PlotLayout& row_layout = p.layout();
if (p.y_strip_mode() == YStrip::None) continue;
gutter = std::max(gutter, row_layout.y_axis_width + row_layout.y_title_width);
}
// Rows that share one y axis divide their *plot* widths, not their cell widths: the first
// cell's y strip would otherwise make its plot narrower than its neighbours'. The gutter is
// taken off the row first, the remainder is split by `place_cells` (so the weights, the gap
// and the floor all apply to plot widths), and the first cell then grows left by the gutter
// to hold the axis. The gutter is capped at half the row, so a row too narrow for it
// degrades to ordinary cells instead of producing a negative rect. The cells still tile the
// row: the first starts on its left edge and the last still ends on its right edge.
//
// The right gutter is taken off the same way and belongs to the **last** cell alone (the
// other cells were told none above): left to every cell, the visible gap between two
// plots would be the column gap plus a whole right gutter. Together with the y gutter it is
// capped at half the row, and never exceeds the figure's own right gutter.
for (u32 row = 0; row < rows; ++row) {
const u32 n = cells_per_row_[row];
if (!share_row_y_ || n <= 1u) continue;
const Rect& r = layout_.rows[row];
const f32 g_eff = std::max(0.0f, std::min(gutter, r.w / 2.0f));
const f32 rg_eff = std::max(0.0f, std::min(right_gutter_, r.w / 2.0f - g_eff));
Rect* cells = &layout_.cells[row_first_[row]];
const f32 used = place_cells(Rect{r.x + g_eff, r.y, r.w - g_eff - rg_eff, r.h}, n,
cell_weights_[row], col_gap_, floor_scale, cells);
cells[0].x -= g_eff;
cells[0].w += g_eff;
cells[n - 1u].w += rg_eff;
layout_.col_gap = used < col_gap_ ? 0.0f : layout_.col_gap;
for (u32 i = row_first_[row]; i < row_first_[row + 1u]; ++i) plots_[i].set_viewport(layout_.cells[i]);
// What the cell actually reserved, so a row too narrow for the whole gutter is not asked
// for more than it was given (the same value whenever the row has room).
plots_[row_first_[row + 1u] - 1u].set_right_gutter(rg_eff);
}
// A one-row figure imposes exactly what that row measured, so it lays out as the plot
// would on its own; a figure whose rows all need the same strip imposes that, which is
// also what each would have done alone.
for (u32 row = 0; row < rows; ++row) {
plots_[row].set_min_y_gutter(gutter);
plots_[row].layout();
for (Plot& p : plots_) {
p.set_min_y_gutter(p.y_strip_mode() == YStrip::None ? 0.0f : gutter);
p.layout();
}
// The figure's one x axis is the bottom row's, so the strip it is drawn in is that
// row's own label area -- the rect the controller hit-tests and the renderer stages
// its labels in, read back here rather than derived again, so a host and the frame
// cannot disagree about where the shared axis is. It is zero-sized only when there
// are no rows at all: with `set_x_labels_only_on_bottom(false)` it is simply the last
// row's own strip, one of several.
// `x_axis_strip` is the bottom row's own label area (the last plot's) -- the rect the
// controller hit-tests and the renderer stages its labels in, read back here rather than
// derived again, so a host and the frame cannot disagree about where it is. Rows above
// that carry their own strip (their columns differ from the row below, or every row is
// labelled) have it in their plots' own `layout().x_label_area`. It is zero-sized only
// when there are no rows at all.
layout_.x_axis_strip = plots_.back().layout().x_label_area;
return layout_;
}
@@ -483,6 +819,21 @@ void Figure::set_crosshair(f64 x, u32 hovered_row, f64 y_in_row, bool active) {
// than clearing a hover the pointer still has.
if (!active || hovered_row >= plots_.size()) return;
plots_[hovered_row].set_crosshair(x, y_in_row, true);
// **The cells of a split row share one y**, so the horizontal line means the same value in
// every one of them: it spans the row rather than stopping at the divider between two cells
// that read the same scale. Only that row -- rows do not share y with each other, so a
// sibling row's line would be a value on a scale it does not have -- and only when the row
// really is split (`set_share_row_y`, which is what makes the y common in the first place).
if (!share_row_y_) return;
const u32 row = row_of_plot(hovered_row);
if (row >= cells_per_row_.size() || cells_per_row_[row] <= 1u) return;
const u32 first = row_first_[row];
for (u32 k = 0; k < cells_per_row_[row]; ++k) {
const u32 cell = first + k;
if (cell != hovered_row && cell < plots_.size()) {
plots_[cell].set_crosshair(x, y_in_row, true);
}
}
}
void Figure::clear_crosshair() {
+722 -96
View File
File diff suppressed because it is too large. Load diff
+102 -16
View File
@@ -98,15 +98,42 @@ void format_time(f64 seconds, char* buf, usize n) {
const char* const sign = seconds < 0.0 ? "-" : "";
const f64 t = std::fabs(seconds);
if (t < 1.0) {
// Sub-second: milliseconds. The 999.5 cut keeps %.3g out of scientific
// notation (it would print "1e+03ms") right below the one-second boundary.
const f64 ms = t * 1000.0;
if (ms < 999.5) {
std::snprintf(buf, n, "%s%.3gms", sign, ms);
} else {
std::snprintf(buf, n, "%s%.3gs", sign, t);
// `0.9995` and not `1.0`: three significant figures of 0.9999 s are `1e+03ms`, so a value
// that *rounds* to a second belongs to the whole-second branch below, which writes it `1s`.
// The same spill one rung down is handled inside the ladder.
if (t < 0.9995) {
// Sub-second: the prefix that keeps the mantissa in [1, 1000) -- the same rule the SI
// formatter uses on a y axis, and the reading a zoomed-in window needs. A fixed
// millisecond unit printed `0.005ms` where `5us` is what the axis is showing, and
// `1e-05ms` where `10ns` is. The micro sign is the UTF-8 sequence the embedded font has a
// glyph for (see `format_si`), not a Latin-1 byte.
// Exactly zero carries no unit, the same convention `format_si` states: every prefixed
// form of it is "0" with a unit the value does not have.
if (t == 0.0) {
std::snprintf(buf, n, "0");
return;
}
static const struct {
f64 scale;
const char* unit;
} kUnits[] = {
{1.0e-3, "ms"},
{1.0e-6, "\u00b5s"},
{1.0e-9, "ns"},
{1.0e-12, "ps"},
};
// The rung is picked by the *rounded* mantissa, which is what `format_si`'s own 999.5
// check buys there: 0.9999 ms is `999.9µs` before rounding and `1e+03µs` after, so it is
// the millisecond rung's value and reads `1ms`. Testing the raw `t < u.scale` let the
// mantissa reach 1000 at every rung boundary.
for (const auto& u : kUnits) {
if (t < u.scale * 0.9995) continue;
std::snprintf(buf, n, "%s%.3g%s", sign, t / u.scale, u.unit);
return;
}
// Below a picosecond nothing on a time axis reads in prefixed units; plain seconds keep
// the fallback's own precision rather than inventing one.
std::snprintf(buf, n, "%s%.3gs", sign, t);
return;
}
@@ -131,7 +158,8 @@ void format_time(f64 seconds, char* buf, usize n) {
}
}
void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out, bool include_minors) {
void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out, bool include_minors,
f64 origin) {
out.clear();
const f64 lo = range.min;
@@ -157,7 +185,10 @@ void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out, boo
: 10.0);
if (!std::isfinite(step) || !(step > 0.0)) return; // span so large the step overflowed
const f64 first_major = std::ceil(lo / step) * step;
// The major walk starts on the *origin's* grid: with an origin the ticks a host reads are
// round numbers relative to it (0, 0.5ms, 1ms of the window it zoomed into), which is the
// whole point of having one. `origin = 0` is the plain absolute grid.
const f64 first_major = origin + std::ceil((lo - origin) / step) * step;
if (!std::isfinite(first_major)) return;
// Minor ticks subdivide the major step: by 4 when the step's leading digit is
@@ -167,22 +198,77 @@ void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out, boo
const f64 minor_step = step / minor_ratio;
if (!std::isfinite(minor_step) || !(minor_step > 0.0)) return;
const f64 stride = include_minors ? minor_step : step;
// Minors subdivide the major step an exact integer number of times, so the
// major/minor split is an *integer-index* test: a tick is a major when its
// minor index, counted from `first_major`, is a multiple of the ratio. Both
// operands of the index are exact for the windows axes actually show --
// `v - first_major` is the difference of two near doubles (Sterbenz-exact), and
// rounding the small quotient lands on the index the grid defines -- so no float
// tolerance is needed and none is applied. The old `|v - round(v/step)*step| <=
// step*1e-9` test fought a ULP-sized residue that an epoch origin produces
// whenever the step is not a power of two (v/step carries a rounding error of a
// few ULP of 1.75e9, i.e. far more than step*1e-9) and misclassified its majors;
// the index here has no residue to fight.
const auto minor_index_is_major = [&](f64 v) {
const f64 index = std::round((v - first_major) / minor_step);
return std::fmod(index, minor_ratio) == 0.0;
};
// Mutable: once the minors are capped the walk steps the major stride (see the
// cap branch below).
f64 stride = include_minors ? minor_step : step;
const f64 first = include_minors ? std::ceil(lo / minor_step) * minor_step : first_major;
if (!std::isfinite(first)) return;
// The walk starts at `ceil(lo / stride) * stride`, which is not representable
// whenever the stride is not a power of two -- 0.1 and 0.2 are the common ones --
// so a range that straddles the origin never lands *on* it: [-1, 1] stepped by 0.1
// reaches -1.39e-16 where it means zero, and that renders as `-139a`, a label from
// the far end of the SI table sitting between `-500m` and `500m`. Deriving `v` by
// multiplication instead of accumulation does not help, because the error is
// already in `first`.
//
// Every tick is an integer multiple of the stride, so the smallest magnitude a
// *non-zero* tick can have is the stride itself, while the residue is a few ULP of
// the range -- a relative gap of some ten orders of magnitude. Anything inside a
// millionth of the stride is therefore the zero tick and nothing else, and it is
// stored as exactly `0.0` rather than merely labelled as such: `Axis::refresh()`
// re-formats a time axis from `tick.value`, where the same residue reads
// `3.19e-13ms`.
const f64 zero_tol = (include_minors ? minor_step : step) * 1e-6;
char label[kLabelBuf];
usize minors = 0;
for (f64 v = first; v <= hi + stride * 1e-9;) {
if (out.size() >= kMaxTicks) break;
const bool major = !include_minors ||
std::fabs(v - std::round(v / step) * step) <= step * 1e-9;
const bool major = !include_minors || minor_index_is_major(v);
if (!major && minors >= kMaxTicks) {
// The cap counts minors only, and the majors are never truncated: an axis
// dense enough to want more subdivisions than fit loses its *subdivisions*,
// not its labels, so the far end of the axis keeps its numbers. From here on
// the walk steps the major stride; the next major at or after `v` is
// re-seated onto the major grid explicitly, because continuing from an
// off-grid minor with the new stride would visit only off-grid points and
// silently drop every remaining label. `minors >= kMaxTicks` is also the
// guard that keeps a hostile request (px towering over min_px) from stepping
// a minor stride across the whole range after the cap; the majors-only walk
// is as long as the caller's own pixel count asked it to be.
stride = step;
const f64 major_index = std::ceil((v - first_major) / step - 1e-9);
f64 reseat = first_major + major_index * step;
if (reseat < v) reseat = first_major + (major_index + 1.0) * step;
v = reseat;
continue;
}
if (!major) ++minors;
Tick tick;
tick.value = v;
// The walk variable itself is left alone: `minor_index_is_major` counts indices
// from `first_major` and both operands have to stay on the walk's own grid.
tick.value = std::fabs(v) < zero_tol ? 0.0 : v;
tick.major = major;
if (major) {
// format_si owns both label guards: it never writes past the buffer, and it
// falls back to %.3g for a non-finite value or a label over 12 characters.
format_si(v, label, sizeof label);
format_si(tick.value, label, sizeof label);
tick.label = label;
}
out.push_back(std::move(tick));
+14 -5
View File
@@ -9,12 +9,17 @@
namespace plot::render {
namespace {
#ifndef NDEBUG
/// Two declared cells agree when neither size differs by more than the atlas'
/// tolerance: the test `glyph_is_cell()` makes, for a cell against a cell.
///
/// Guarded because its one caller is an `assert` (see `TextBatch::set_cell`), so a
/// release build has no use for it and would warn that it is unused.
bool same_cell(Vec2 a, Vec2 b) noexcept {
const auto near = [](f32 p, f32 q) { return std::fabs(p - q) <= kAtlasCellTolerance; };
return near(a.x, b.x) && near(a.y, b.y);
}
#endif
/// `axis` as a unit vector: the cell's local +x, which is what the instance and
/// the shader both take (see `TextInstance`). A zero, infinite or NaN direction
@@ -93,7 +98,7 @@ void TextBatch::set_cell(Vec2 cell_uv) noexcept {
cell_declared_ = true;
}
void TextBatch::add_cell(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 unit_ux) {
void TextBatch::add_cell(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 unit_ux, Color color) {
if (!cell_declared_) {
// Nobody declared a cell: a caller staging raw glyphs, where the glyph's own
// rect is the only authority there is. `add_text` and the renderer both
@@ -120,14 +125,18 @@ void TextBatch::add_cell(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 unit_ux)
v_.push_back(glyph.v0);
v_.push_back(unit_ux.x);
v_.push_back(unit_ux.y);
v_.push_back(color.r);
v_.push_back(color.g);
v_.push_back(color.b);
v_.push_back(color.a);
}
void TextBatch::add_glyph(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 axis) {
add_cell(pen_px, glyph, px, unit_axis(axis));
void TextBatch::add_glyph(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 axis, Color color) {
add_cell(pen_px, glyph, px, unit_axis(axis), color);
}
Vec2 TextBatch::add_text(const char* utf8, Vec2 pen_px, f32 px, const TextBackend& font,
Vec2 axis) {
Vec2 axis, Color color) {
if (utf8 == nullptr) {
return pen_px;
}
@@ -145,7 +154,7 @@ Vec2 TextBatch::add_text(const char* utf8, Vec2 pen_px, f32 px, const TextBacken
if (glyph == nullptr) {
continue; // no artwork for this codepoint: nothing to draw, nothing to advance
}
add_cell(pen_px, *glyph, px, ux);
add_cell(pen_px, *glyph, px, ux, color);
pen_px = pen_px + ux * (glyph->advance * px);
}
return pen_px;
+28 -1
View File
@@ -32,6 +32,12 @@ void LineProgram::set_dash(Vec2 on_off) const noexcept {
void LineProgram::set_color(Color color) const noexcept { program_.set_uniform("u_color", color); }
void LineProgram::set_aa(f32 px) const noexcept { program_.set_uniform("u_aa", px); }
void LineProgram::set_snap(bool on) const noexcept {
program_.set_uniform("u_snap", on ? 1.0f : 0.0f);
}
bool TextProgram::create(Status* st) noexcept {
program_ = gl::Program::from_source(kTextVS, kTextFS, st);
if (program_.id() == 0) {
@@ -54,6 +60,27 @@ void TextProgram::set_texture_size(Vec2 texels) const noexcept {
program_.set_uniform("u_tex_size", texels);
}
void TextProgram::set_color(Color color) const noexcept { program_.set_uniform("u_color", color); }
bool DensityProgram::create(Status* st) noexcept {
program_ = gl::Program::from_source(kDensityVS, kDensityFS, st);
if (program_.id() == 0) {
return false;
}
// The grid texture is bound to texture unit 0 by plot::gl::Texture::bind(), so the sampler
// has to point there. Set once, here, not per frame -- the same shape as the text program's.
program_.use();
program_.set_uniform_int("u_density", 0);
return true;
}
void DensityProgram::set_viewport(Vec2 px) const noexcept { program_.set_uniform("u_viewport", px); }
void DensityProgram::set_rect(Vec2 origin, Vec2 size) const noexcept {
program_.set_uniform("u_origin", origin);
program_.set_uniform("u_size", size);
}
void DensityProgram::set_color(Color color) const noexcept {
program_.set_uniform("u_color", color);
}
} // namespace plot::render
+1277 -150
View File
File diff suppressed because it is too large. Load diff
+145 -11
View File
@@ -19,6 +19,8 @@ uniform vec2 u_scale; // data -> pixels
uniform vec2 u_offset; // pixels
uniform vec2 u_viewport; // render target size, pixels
uniform float u_width; // stroke width, pixels
uniform float u_aa; // AA fringe half-width, pixels
uniform float u_snap; // non-zero: put an axis-aligned segment's ink on whole pixels
out float v_dist; // signed distance to the segment axis, pixels
// Distance travelled from a_p0, pixels -- and the dash's phase anchor. Both of the
@@ -31,9 +33,43 @@ out float v_along;
void main() {
vec2 p0 = a_p0 * u_scale + u_offset;
vec2 p1 = a_p1 * u_scale + u_offset;
// The pixel-grid snap, for the chrome that *frames* the plot: a grid line, an
// axis line, a panel or legend border. Those are staged in data space like
// everything else, so a 1 px stroke's centre lands wherever the mapping puts it
// -- at x = 100.0 the ink spans [99.5, 100.5] and lights two columns at half
// coverage each (a grey smear), at x = 100.5 it spans [100, 101] and lights one
// crisply. Which of the two a given line got depended on the view, so a frame's
// gridlines were a mix of both and panning made each shimmer between them.
//
// Snapping the ink's *leading edge* to a pixel boundary -- rather than the
// centre to a half pixel -- is the form that holds for any width: it reduces to
// integer + 0.5 for an odd stroke and to an integer for an even one, with no
// case analysis. `u_aa` is deliberately not in `ink`: the fringe is symmetric
// about the ink and snapping with it folded in would bias the line by half a
// fringe.
//
// Only for an axis-aligned segment, and only on the axis it is constant along:
// a diagonal has no single perpendicular to round, and moving either end of one
// by a part of a pixel would tilt it. That restriction is also why this is safe
// to leave on for a whole draw -- the grid and the axis lines are nothing *but*
// axis-aligned segments. Traces, cursors, regions and the crosshair pass 0: a
// line that reports a data position may not be moved off it, however little.
if (u_snap != 0.0) {
float ink = u_width * 0.5;
vec2 snapped = floor(p0 - vec2(ink) + vec2(0.5)) + vec2(ink);
if (abs(p1.x - p0.x) < 1e-4) { // vertical: round x, leave y
p0.x = snapped.x;
p1.x = snapped.x;
} else if (abs(p1.y - p0.y) < 1e-4) { // horizontal: round y, leave x
p0.y = snapped.y;
p1.y = snapped.y;
}
}
vec2 d = p1 - p0;
float len = length(d);
float half_w = u_width * 0.5 + 1.0; // + one pixel of AA fringe
float half_w = u_width * 0.5 + u_aa; // + the AA fringe
// A degenerate segment has no direction: pin the normal, and give the quad
// area by stretching its two ends apart along x. Collapsing all four corners
// onto p0 would rasterise nothing, and a flat trace staged as p == p (the
@@ -64,6 +100,7 @@ in float v_dist; // signed distance to the segment axis, pixels
in float v_along; // distance travelled from a_p0, pixels
uniform float u_width; // stroke width, pixels
uniform float u_aa; // AA fringe half-width, pixels
uniform vec4 u_color;
uniform vec2 u_dash; // dash pattern in pixels: x = ink, y = gap; y <= 0 is solid
@@ -85,12 +122,15 @@ void main() {
}
float half_w = u_width * 0.5;
float d = abs(v_dist);
if (d > half_w + 1.0) {
if (d > half_w + u_aa) {
discard; // outside the fringe; nothing to blend
}
// Ramp over the last pixel of the half width so a thin stroke keeps its
// weight instead of aliasing into a dashed line.
float alpha = 1.0 - smoothstep(half_w - 0.5, half_w + 0.5, d);
// Ramp over the fringe so a thin stroke keeps its weight instead of aliasing
// into a dashed line: the ramp is centred on `half_w` and spans `u_aa`, so
// `u_aa == 1.0` -- the style default -- reproduces the historical single-pixel
// ramp exactly. `u_aa` is the style's `line_aa_px` in device pixels, clamped
// at 0.25 by the renderer.
float alpha = 1.0 - smoothstep(half_w - 0.5 * u_aa, half_w + 0.5 * u_aa, d);
o_color = vec4(u_color.rgb, u_color.a * alpha);
}
)";
@@ -99,12 +139,18 @@ const char* const kTextVS = R"(#version 330 core
layout(location = 0) in vec2 a_pos; // per-instance glyph cell top-left, pixels
layout(location = 1) in vec2 a_uv; // per-instance glyph rect top-left, normalised
layout(location = 2) in vec2 a_axis; // per-instance cell +x axis, unit, pixels
layout(location = 3) in vec4 a_colour; // per-instance tint, straight alpha
uniform vec2 u_viewport; // render target size, pixels
uniform vec2 u_px_range; // glyph cell on screen, pixels (width, height)
uniform vec2 u_glyph_uv; // glyph cell in the atlas, normalised (width, height)
out vec2 v_uv; // normalised atlas coordinate of this fragment
out vec4 v_colour; // the instance's tint
// This glyph's cell origin, flat: the fragment shader filters inside the cell and
// has to know where it starts. Interpolating it would make the bound it clamps
// against drift across the quad, which is the one thing the clamp is there to stop.
flat out vec2 v_cell0;
void main() {
// The cell's two axes: the run direction the instance carries and its
@@ -123,25 +169,113 @@ void main() {
// The same handedness as the cell, so the glyph's rect lands on it the right
// way round: u runs along the basis, v down the glyph.
v_uv = a_uv + corner * u_glyph_uv;
v_colour = a_colour;
v_cell0 = a_uv;
}
)";
const char* const kTextFS = R"(#version 330 core
in vec2 v_uv; // normalised atlas coordinate of this fragment
in vec4 v_colour; // the instance's tint
flat in vec2 v_cell0; // this glyph's cell origin, normalised
uniform sampler2D u_atlas; // GL_R8 glyph atlas, GL_NEAREST
uniform vec2 u_tex_size; // atlas size in texels
uniform vec2 u_glyph_uv; // the cell's size in normalised atlas coordinates
out vec4 o_color;
/// Coverage of one texel, addressed by its integer index. Every fetch in here is
/// an explicit texel centre, which is why the sampler can stay `GL_NEAREST`: the
/// filtering below is this shader's, not the sampler's, so no fetch can tie at a
/// texel boundary and no fetch can be steered outside the cell clamped for it.
float tap(vec2 texel) {
return texture(u_atlas, (texel + vec2(0.5)) / u_tex_size).r;
}
void main() {
// Bilinear over the four texels the fragment sits between, **clamped to this
// glyph's own cell**.
//
// It used to be a single point sample of the texel the fragment landed in, and
// the two halves of that -- a point sample, and `AxisFont` drawing the atlas at
// `requested_px / em_px` -- are each reasonable and jointly wrong: a point
// sample at a non-integer scale *deletes* texels rather than blending them. The
// library's own default is a 12 px style on the 13 px embedded bake, i.e. 12/13,
// which dropped one of every 8 columns and one of every 17 rows of every glyph
// -- whole strokes, not softened edges, and which stroke went depended on where
// the unsnapped pen happened to fall. Filtering costs four fetches of a texture
// that is one row of cells and is in cache for the whole pass.
//
// The clamp is what keeps the old guarantee: the atlas is a tight strip of cells
// with no padding between them, so a footprint reaching past a cell edge would
// blend in the neighbouring glyph. Clamping to the cell's own texel range
// replicates its edge instead, and a cell's border texels are blank (the cell is
// the face's widest advance by its full ascent-to-descent extent), so the
// replication is of nothing.
//
// `floor(x + 0.5)` on both bounds, not a raw product: they come back from a
// normalised coordinate times a size, so an exact texel index arrives a few ULP
// either side of itself, and a `floor` of 191.99999 is off by a whole texel.
vec2 lo = floor(v_cell0 * u_tex_size + vec2(0.5));
vec2 hi = lo + floor(u_glyph_uv * u_tex_size + vec2(0.5)) - vec2(1.0);
// Texel *centres* are at integer + 0.5 in normalised-times-size space, so
// subtracting the half puts the sample on a grid whose integers are the centres:
// `f` is then the distance between the two taps, and a fragment that lands on a
// centre -- every fragment, when the scale is 1 and the pen is whole -- gets
// `f == 0` and exactly one texel, as crisp as the point sample ever was.
vec2 t = v_uv * u_tex_size - vec2(0.5);
vec2 t0 = floor(t);
vec2 f = t - t0;
vec2 a = clamp(t0, lo, hi);
vec2 b = clamp(t0 + vec2(1.0), lo, hi);
float coverage = mix(mix(tap(vec2(a.x, a.y)), tap(vec2(b.x, a.y)), f.x),
mix(tap(vec2(a.x, b.y)), tap(vec2(b.x, b.y)), f.x), f.y);
o_color = vec4(v_colour.rgb, v_colour.a * coverage);
}
)";
const char* const kDensityVS = R"(#version 330 core
// No vertex attributes at all: the image is one axis-aligned rectangle whose corners are
// uniform state, so the quad comes from gl_VertexID alone and there is no vertex buffer to
// fill. The density's own bytes go to a texture (see kDensityFS), never to a buffer.
uniform vec2 u_viewport; // render target size, pixels
uniform vec2 u_origin; // the quad's top-left corner in Rect pixels
uniform vec2 u_size; // the quad's size in pixels -- the axes rect, which the image fills
out vec2 v_uv;
void main() {
// A four-vertex triangle strip, like the line and text pipelines: the corner index is the
// vertex id's two bits, so 0,1,2,3 are (0,0), (1,0), (0,1), (1,1).
vec2 corner = vec2(float(gl_VertexID & 1), float((gl_VertexID >> 1) & 1));
vec2 px = u_origin + corner * u_size;
// Rect is top-left origin, GL is bottom-left: the one and only y flip.
vec2 ndc = vec2(px.x / u_viewport.x * 2.0 - 1.0, 1.0 - px.y / u_viewport.y * 2.0);
gl_Position = vec4(ndc, 0.0, 1.0);
// uv runs 0..1 across the whole image, so a fragment lands wherever the axes rect puts it
// between the grid's cells: one texel a cell while the rect is under the cap (a cell a device
// pixel, the sampling all but a point sample) and a linear magnification of the image past it.
v_uv = corner;
}
)";
const char* const kDensityFS = R"(#version 330 core
in vec2 v_uv; // cell coordinate in [0,1] across the quad
uniform sampler2D u_density; // GL_R8 luminance, one texel per cell, GL_LINEAR
uniform vec4 u_color;
out vec4 o_color;
void main() {
// Sample the centre of the texel the fragment lands in. GL_NEAREST alone
// would tie at a texel boundary, where the driver may pick the neighbouring
// glyph cell; rounding to a centre removes the tie by construction.
vec2 texel = clamp(floor(v_uv * u_tex_size), vec2(0.0), u_tex_size - vec2(1.0));
float coverage = texture(u_atlas, (texel + vec2(0.5)) / u_tex_size).r;
o_color = vec4(u_color.rgb, u_color.a * coverage);
// The cell's count was already turned into a luminance on the CPU, where the count, the
// decay and the gamma live -- so this stage has no scale of its own that a host reading the
// grid's numbers back could disagree with. All that is left is the tint: the density is drawn
// once per trace, in that trace's own colour (the caller's `u_color`), so a saturated cell is
// the trace at its own brightness and a faint one is the same hue at a lower alpha.
float ink = texture(u_density, v_uv).r;
o_color = vec4(u_color.rgb, u_color.a * ink);
}
)";
+6
View File
@@ -71,6 +71,12 @@ bool TextAtlas::sync(const TextBackend& backend, Status* st) noexcept {
// no second create -- and it keeps the filter, which is why the filter is set
// on every upload rather than once at creation.
texture_.upload_r8(width, height, pixels);
// `GL_NEAREST` on purpose, and it is not the absence of filtering: `kTextFS`
// filters, with four fetches it aims at explicit texel centres and clamps to
// the instance's own cell. `GL_LINEAR` here would do the blending with the
// *sampler's* footprint instead, which knows nothing of cell bounds -- and the
// atlas is a tight strip of cells with no padding, so a glyph at a cell edge
// would pick up its neighbour.
texture_.set_filter(gl::GL_NEAREST, gl::GL_NEAREST);
}
gl::glPixelStorei(gl::GL_UNPACK_ALIGNMENT, host_alignment);
+7 -3
View File
@@ -72,15 +72,18 @@ bool TextVertexArray::create(Status* st, UploadMode mode) noexcept {
}
vao_.bind();
buffer_.bind();
// Three attributes now: the cell's corner, its atlas rect and the basis that
// rotates it (see TextInstance). All of them advance once per instance, like
// the line pipeline's, because the corners come from gl_VertexID.
// Four attributes now: the cell's corner, its atlas rect, the basis that
// rotates it and the run's tint (see TextInstance). All of them advance once
// per instance, like the line pipeline's, because the corners come from
// gl_VertexID.
vao_.attrib(0u, 2, sizeof(TextInstance), offsetof(TextInstance, x));
vao_.attrib(1u, 2, sizeof(TextInstance), offsetof(TextInstance, u));
vao_.attrib(2u, 2, sizeof(TextInstance), offsetof(TextInstance, ax));
vao_.attrib(3u, 4, sizeof(TextInstance), offsetof(TextInstance, r));
gl::glVertexAttribDivisor(0u, kPerInstance);
gl::glVertexAttribDivisor(1u, kPerInstance);
gl::glVertexAttribDivisor(2u, kPerInstance);
gl::glVertexAttribDivisor(3u, kPerInstance);
gl::glBindVertexArray(0);
epoch_ = buffer_.epoch();
return true;
@@ -99,6 +102,7 @@ void TextVertexArray::upload(const f32* interleaved, u32 glyphs) noexcept {
vao_.attrib(0u, 2, sizeof(TextInstance), base + offsetof(TextInstance, x));
vao_.attrib(1u, 2, sizeof(TextInstance), base + offsetof(TextInstance, u));
vao_.attrib(2u, 2, sizeof(TextInstance), base + offsetof(TextInstance, ax));
vao_.attrib(3u, 4, sizeof(TextInstance), base + offsetof(TextInstance, r));
epoch_ = buffer_.epoch();
}
instances_ = glyphs;
+22 -4
View File
@@ -32,6 +32,7 @@ add_executable(plot_tests
test_ring_buffer.cpp
test_minmax_pyramid.cpp
test_series.cpp
test_xy_series.cpp
test_ticks.cpp
test_measure.cpp
test_plot.cpp
@@ -77,8 +78,8 @@ target_include_directories(plot_tests PRIVATE "${CMAKE_SOURCE_DIR}/adapters")
# The optional runtime-font backend brings its own cases, and they exist only when
# its target does: the header, the class and the archive are all absent otherwise,
# so there would be nothing to compile them against (see PLOT_BUILD_TTF_FONT in the
# top-level CMakeLists.txt). Guarded on the target rather than on the option, like
# so there would be nothing to compile them against (see PLOT_BUILD_TTF_FONT in
# cmake/plot_options.cmake). Guarded on the target rather than on the option, like
# the adapter cases above, so a target that exists for any reason gets its cases.
#
# Two paths are handed over as definitions because a case needs them and should
@@ -157,6 +158,18 @@ foreach(plot_header IN LISTS plot_public_headers)
list(APPEND plot_header_check_sources "${plot_header_check_source}")
endforeach()
# The generated version header is public (the umbrella names it) but is not
# under the source include root -- the top-level CMakeLists.txt configures it
# into <build>/include/plot/version.hpp -- so the glob above cannot see it. It
# gets the same standalone-compile TU by hand, and reaches the header through
# plot::core's build-tree include interface.
file(GENERATE
OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/header_checks/plot_version.cpp"
CONTENT
"// Generated by tests/CMakeLists.txt: <plot/version.hpp> must compile on its own.\n#include <plot/version.hpp>\n")
list(APPEND plot_header_check_sources
"${CMAKE_CURRENT_BINARY_DIR}/header_checks/plot_version.cpp")
add_library(plot_header_self_containment OBJECT ${plot_header_check_sources})
target_link_libraries(plot_header_self_containment PRIVATE plot::core)
plot_apply_warnings(plot_header_self_containment)
@@ -189,8 +202,12 @@ plot_apply_warnings(plot_umbrella_check)
# Coverage check on the umbrella. Included here so a public header missing from
# plot.hpp stops the configure; the ctest entry below runs the same script again
# so the check is also a visible test.
# so the check is also a visible test. The generated version header is passed in
# both times: the script must require it of the umbrella, and must look for the
# file in the build tree where configure_file() put it.
set(PLOT_INCLUDE_DIR "${CMAKE_SOURCE_DIR}/include")
set(PLOT_GENERATED_INCLUDE_DIR "${CMAKE_BINARY_DIR}/include")
set(PLOT_EXTRA_PUBLIC_HEADERS "plot/version.hpp")
set(PLOT_UMBRELLA "${CMAKE_SOURCE_DIR}/include/plot/plot.hpp")
include("${CMAKE_SOURCE_DIR}/cmake/plot_umbrella_check.cmake")
@@ -199,6 +216,7 @@ add_test(NAME plot_headers_self_contained
add_test(NAME plot_umbrella_covers_public_headers
COMMAND ${CMAKE_COMMAND}
-DPLOT_INCLUDE_DIR=${CMAKE_SOURCE_DIR}/include
-DPLOT_UMBRELLA=${CMAKE_SOURCE_DIR}/include/plot/plot.hpp
-DPLOT_GENERATED_INCLUDE_DIR=${CMAKE_BINARY_DIR}/include
-DPLOT_EXTRA_PUBLIC_HEADERS=plot/version.hpp
-P "${CMAKE_SOURCE_DIR}/cmake/plot_umbrella_check.cmake")
add_test(NAME plot_umbrella_check COMMAND plot_umbrella_check)
+3
View File
@@ -3,6 +3,7 @@
#pragma once
#include <cstdio>
#include <cstdlib>
#include <cmath>
#include <chrono>
#include <algorithm>
@@ -57,7 +58,9 @@ inline void check_near(double a, double b, const char* file, int line, const cha
inline int run_all() {
int failed = 0;
const char* only = std::getenv("PLOT_TEST_FILTER");
for (auto& c : cases()) {
if (only != nullptr && std::string(c.name).find(only) == std::string::npos) continue;
try {
c.fn();
std::printf("[ ok ] %s\n", c.name);
+497 -24
View File
@@ -187,21 +187,30 @@ PLOT_TEST(hovered_cursor_only_inside_plot_area) {
PLOT_CHECK(c.hovered_cursor() == cid);
}
PLOT_TEST(ctrl_drag_moves_the_nearest_cursor) {
PLOT_TEST(ctrl_drag_box_zooms_x_only_even_over_a_cursor) {
// The modifier table: Ctrl+Left is the x-only box zoom now -- it wins over the
// cursor grab a plain press would open, and the release frames the x range from
// the selection while the y range (and the cursor under the press) stay put.
plot::Plot p;
p.set_viewport({0, 0, 800, 400});
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
const u32 cid = p.add_cursor(20.0, plot::Color::hex(0xFF0000));
const u32 ax = p.add_x_axis({"t", {0.0, 800.0}, false, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
const u32 cid = p.add_cursor(200.0, plot::Color::hex(0xFF0000));
plot::input::Controller c(p);
c.set_viewport({0, 0, 800, 400});
const f32 sx = screen_x_of(p, ax, 20.0);
const f32 sx = screen_x_of(p, ax, 200.0);
const f64 want_lo = p.x_transform(ax).to_data_x(sx);
const f64 want_hi = p.x_transform(ax).to_data_x(sx + 100.0f);
c.on_pointer({sx, 200, 0, 0, plot::input::Button::Left, kCtrl, true, true, false});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::MoveCursor);
c.on_pointer({sx + 100, 200, 100, 0, plot::input::Button::Left, kCtrl, true, false, false});
c.on_pointer({sx + 100, 200, 0, 0, plot::input::Button::Left, kCtrl, false, false, true});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::BoxZoom);
c.on_pointer({sx + 100, 260, 100, 60, plot::input::Button::Left, kCtrl, true, false, false});
c.on_pointer({sx + 100, 260, 0, 0, plot::input::Button::Left, kCtrl, false, false, true});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None);
PLOT_CHECK(p.cursor(cid).x > 20.0);
PLOT_CHECK_NEAR(p.cursor(cid).x, p.x_transform(ax).to_data_x(sx + 100), 1e-9);
PLOT_CHECK_NEAR(p.x_range(ax).min, want_lo, 1e-6);
PLOT_CHECK_NEAR(p.x_range(ax).max, want_hi, 1e-6);
PLOT_CHECK_NEAR(p.y_range(ay).min, -1.0, 1e-9); // the masked axis is untouched
PLOT_CHECK_NEAR(p.y_range(ay).max, 1.0, 1e-9);
PLOT_CHECK_NEAR(p.cursor(cid).x, 200.0, 1e-9); // and no cursor was grabbed
}
PLOT_TEST(middle_drag_on_x_strip_zooms_x_only) {
@@ -254,6 +263,27 @@ PLOT_TEST(key_minus_zooms_out) {
PLOT_CHECK(f.plot.x_range(f.ax).span() > 100.0);
}
PLOT_TEST(ctrl_plus_zooms_only_x) {
Fixture f;
f.ay = f.plot.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
const f64 x_before = f.plot.x_range(f.ax).span();
const f64 y_before = f.plot.y_range(f.ay).span();
f.controller.on_key({plot::input::kKeyZoomIn, kCtrl, true});
PLOT_CHECK(f.plot.x_range(f.ax).span() < x_before);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).span(), y_before, 1e-12);
}
PLOT_TEST(alt_plus_zooms_only_y) {
Fixture f;
f.ay = f.plot.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
constexpr plot::u32 kAlt = static_cast<plot::u32>(plot::input::Mod::Alt);
const f64 x_before = f.plot.x_range(f.ax).span();
const f64 y_before = f.plot.y_range(f.ay).span();
f.controller.on_key({plot::input::kKeyZoomIn, kAlt, true});
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), x_before, 1e-12);
PLOT_CHECK(f.plot.y_range(f.ay).span() < y_before);
}
/// A host can hand the controller a coordinate that is not a number: ImGui reports
/// (-FLT_MAX, -FLT_MAX) for "no mouse position", and any host can produce a NaN out
/// of a degenerate mapping of its own. The pan arithmetic runs through the axis
@@ -391,9 +421,10 @@ PLOT_TEST(grab_radius_scales_with_dpi) {
c.on_pointer({sx + outside, 200.0f * scale, 0, 0, plot::input::Button::Left, 0, false, false,
false});
PLOT_CHECK(c.hovered_cursor() == plot::kInvalid);
// ...and a ctrl-drag at that same logical distance opens a cursor move, which is
// the gesture the radius actually gates.
c.on_pointer({sx + inside, 200.0f * scale, 0, 0, plot::input::Button::Left, kCtrl, true, true,
// ...and a press at that same logical distance opens a cursor move, which is
// the gesture the radius actually gates. (A plain press: Ctrl is the x-only
// box zoom's mask now, not a cursor modifier.)
c.on_pointer({sx + inside, 200.0f * scale, 0, 0, plot::input::Button::Left, 0, true, true,
false});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::MoveCursor);
}
@@ -878,17 +909,9 @@ PLOT_TEST(cursor_is_grabbed_without_a_modifier) {
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::None);
PLOT_CHECK(f.controller.grabbed_cursor() == plot::kInvalid);
PLOT_CHECK(f.plot.hot_handles().grabbed_cursor == plot::kInvalid);
// The same press with ctrl is the same gesture, not a different mode.
const f32 moved_to = column_of(f.plot, f.ax, f.plot.cursor(cid).x);
const f64 min_before = f.plot.x_range(f.ax).min;
press(f.controller, moved_to, 200.0f, kCtrl);
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor);
PLOT_CHECK(f.controller.grabbed_cursor() == cid);
move_to(f.controller, moved_to - 40.0f, 200.0f, kCtrl);
PLOT_CHECK_NEAR(f.plot.cursor(cid).x, data_at(f.plot, f.ax, moved_to - 40.0f), 1e-9);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, min_before, 1e-9); // still no pan
release(f.controller, moved_to - 40.0f, 200.0f, kCtrl);
// The grab is the *plain* press's gesture: Ctrl+Left is the x-only box zoom now
// (see ctrl_drag_box_zooms_x_only_even_over_a_cursor), so there is no modifier
// variant of a cursor grab to pin here.
}
/// A snapping cursor follows the pointer for the whole drag: the drag writes the free
@@ -1216,7 +1239,7 @@ PLOT_TEST(pressing_away_from_a_handle_still_pans) {
const u32 cid = f.plot.add_cursor(20.0, plot::Color::hex(0x00FF00));
const u32 rid = f.plot.add_region(free_region(40.0, 60.0));
for (const u32 mods : {0u, kCtrl}) {
for (const u32 mods : {0u}) { // the plain press: Ctrl+Left is the box zoom now
const f32 px = screen_x_of(f.plot, f.ax, 85.0);
const f64 grabbed = data_at(f.plot, f.ax, px);
const f64 min_before = f.plot.x_range(f.ax).min;
@@ -1786,6 +1809,47 @@ PLOT_TEST(a_middle_drag_on_a_figures_bottom_row_zooms_the_shared_x_axis) {
// pointer) rather than on the live edge, and the window walked back off the newest sample
// by a fifth of its width. These two cases pin the fix from the two ends: the gesture keeps
// the edge, and the hint that makes it possible exists exactly while a group follows.
/// A following group's window stays **on the data**: the offset bound is `retained - span`, not
/// `retained`. With the loose bound, scrolling back put the window's *right* edge on the oldest
/// retained sample, which leaves the window's whole span to its left over data the scope never
/// had -- an empty margin as wide as the view, reached by a pan or by a zoom-in near that edge.
/// That is the "sometimes zooming in, the left or right margin becomes empty" report, and it is
/// why the bound has to be about the window and not about one edge of it.
PLOT_TEST(a_following_group_keeps_its_whole_window_on_the_data) {
plot::data::StreamSeries series(1u << 12, 0.0, 1.0 / 1000.0);
plot::f32 samples[512];
for (u32 i = 0; i < 512u; ++i) samples[i] = static_cast<plot::f32>(i % 7u);
series.push(samples, 512u);
series.acquire();
plot::Figure figure(1u);
figure.set_viewport({0.0f, 0.0f, 800.0f, 600.0f});
figure.plot(0u).add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
figure.plot(0u).add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
figure.plot(0u).add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, 0u, 0u, "s"});
const u32 group = figure.add_x_link();
PLOT_CHECK(figure.link_x(0u, group));
PLOT_CHECK(figure.set_x_link_range(group, {0.0, 0.25}));
PLOT_CHECK(figure.set_x_link_follow(group, plot::XFollow::Live));
figure.layout();
const plot::Range data = series.x_bounds();
const f64 span = figure.x_link_range(group).span();
PLOT_CHECK(span > 0.0);
// Ask to scroll far past the oldest sample, the way a drag or a zoom-in near the left edge does.
PLOT_CHECK(figure.set_x_link_range(group, {data.min - 10.0 * span, data.min - 9.0 * span}));
figure.layout();
const plot::Range after = figure.x_link_range(group);
std::printf("[follow-bound] data=[%.4f..%.4f] span=%.4f after=[%.4f..%.4f]\n", data.min,
data.max, span, after.min, after.max);
// The window is still a window, and its *left* edge is on the data: that is the assertion the
// loose bound failed, where `after.min` came out a whole span below `data.min`.
PLOT_CHECK_NEAR(after.span(), span, 1e-9 * span);
PLOT_CHECK(after.min >= data.min - 1e-9);
PLOT_CHECK(after.max <= data.max + 1e-9);
}
PLOT_TEST(a_wheel_on_a_following_figures_axis_strip_keeps_the_live_edge) {
constexpr u32 kFigureRows = 3u;
constexpr f64 kWheelDx = 1.0 / 1000.0;
@@ -1981,3 +2045,412 @@ PLOT_TEST(crosshair_tracks_motion_and_clears_on_leave) {
PLOT_CHECK(fx.plot.crosshair().active);
PLOT_CHECK_NEAR(fx.plot.crosshair().x, fx.plot.x_transform(0u).to_data_x(far_x), 1e-9);
}
// --- hardening wave: B5 zero-notch wheel and dx pan, and the new gestures ---
namespace {
/// A plot with an x and a y axis, both auto-scaled, so every "did the gesture
/// disarm the fit" question has an armed state to start from.
struct ArmedFixture {
plot::Plot plot;
u32 ax = 0;
u32 ay = 0;
plot::input::Controller controller{plot};
ArmedFixture() {
plot.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
ax = plot.add_x_axis({"t", {0.0, 100.0}, true, plot::AxisPlace::Bottom, false});
ay = plot.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
controller.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
}
};
} // namespace
/// B5: a zero-notch wheel (`dy == 0 && dx == 0`, which hosts and trackpads really
/// forward) changes nothing at all -- most importantly it does not run the range
/// setters, which clear `auto_scale`, so one empty scroll must not disarm
/// auto-fit.
PLOT_TEST(zero_notch_wheel_disarms_nothing) {
ArmedFixture f;
f.controller.on_wheel({400.0f, 200.0f, 0.0f, 0.0f, 0});
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale);
PLOT_CHECK(f.plot.y_axis(f.ay).auto_scale);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 0.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 100.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -1.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.0, 1e-9);
}
/// B5: `dy == 0` with `dx != 0` pans x only -- 5% of the x span per notch, in axis
/// space (log-aware), positive `dx` scrolling right -- and leaves the y axis,
/// including its armed auto scale, exactly as it was.
PLOT_TEST(horizontal_wheel_pans_x_only) {
ArmedFixture f;
f.controller.on_wheel({400.0f, 200.0f, 2.0f, 0.0f, 0}); // two notches right
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 10.0, 1e-9); // 2 * 5% of 100
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 110.0, 1e-9);
PLOT_CHECK(!f.plot.x_axis(f.ax).auto_scale); // a pan is a user choice
PLOT_CHECK(f.plot.y_axis(f.ay).auto_scale); // the untouched axis keeps its fit
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -1.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.0, 1e-9);
// Log-aware: the pan runs in axis space, so one notch on a log axis is a
// fraction of a *decade*, not of a linear width.
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 lg = p.add_x_axis({"f", {1.0, 100.0}, true, plot::AxisPlace::Bottom, true});
plot::input::Controller c(p);
c.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
c.on_wheel({400.0f, 200.0f, 1.0f, 0.0f, 0});
const plot::Range r = p.x_range(lg);
PLOT_CHECK_NEAR(std::log10(r.max) - std::log10(r.min), 2.0, 1e-9); // decades kept
PLOT_CHECK_NEAR(std::log10(r.max), 2.1, 1e-9); // one notch = 5% of the 2-decade span
}
/// B5: a zoom whose factor could not be applied honestly -- non-finite (a huge
/// downward `dy` explodes `exp`) -- is refused, and leaves the ranges *and* the
/// armed auto scale exactly as they were.
PLOT_TEST(wheel_zoom_refuses_a_non_finite_factor) {
ArmedFixture f;
f.controller.on_wheel({400.0f, 200.0f, 0.0f, -1e38f, 0});
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale);
PLOT_CHECK(f.plot.y_axis(f.ay).auto_scale);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 100.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.0, 1e-9);
}
/// B5: the middle-drag axis zoom carries the same guards. A motion that reports no
/// `dy` is a factor of exactly one -- refused, so it cannot clear `auto_scale` on
/// its way through the range setters -- and one that explodes the factor is
/// refused too.
PLOT_TEST(axis_zoom_refuses_degenerate_factors) {
ArmedFixture f;
const plot::PlotLayout& l = f.plot.layout();
const f32 strip_x = l.axes.x + 0.5f * l.axes.w;
const f32 strip_y = l.x_label_area.y + 0.5f * l.x_label_area.h;
f.controller.on_pointer({strip_x, strip_y, 0, 0, plot::input::Button::Middle, 0, true, true,
false});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::AxisZoomX);
// A stationary motion: no zoom, and the fits stay armed.
f.controller.on_pointer({strip_x, strip_y, 0, 0, plot::input::Button::Middle, 0, true, false,
false});
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), 100.0, 1e-9);
// An exploding motion: refused, nothing stored, nothing disarmed.
f.controller.on_pointer({strip_x, strip_y, 0, 1e38f, plot::input::Button::Middle, 0, true,
false, false});
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), 100.0, 1e-9);
f.controller.on_pointer({strip_x, strip_y, 0, 0, plot::input::Button::Middle, 0, false, false,
true});
}
/// The arrow keys pan by axis class: Left/Right every x axis, Up/Down every y
/// axis, 10% of that axis' own span per press, in axis space (log-aware). An arrow
/// says where to look: Left walks the window toward smaller x, Up toward larger y.
PLOT_TEST(arrow_keys_pan_a_tenth_of_the_span) {
ArmedFixture f;
f.plot.set_x_auto_scale(f.ax, false);
f.controller.on_key({plot::input::kKeyRight, 0, true});
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 10.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 110.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -1.0, 1e-9); // x-only
f.controller.on_key({plot::input::kKeyLeft, 0, true});
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 0.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 100.0, 1e-9);
f.controller.on_key({plot::input::kKeyUp, 0, true});
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -0.8, 1e-9); // +10% of the 2 span
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.2, 1e-9);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 0.0, 1e-9); // y-only
f.controller.on_key({plot::input::kKeyDown, 0, true});
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -1.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.0, 1e-9);
// Log-aware: an arrow on a log axis moves decades, not units.
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 lg = p.add_x_axis({"f", {1.0, 100.0}, false, plot::AxisPlace::Bottom, true});
p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
plot::input::Controller c(p);
c.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
c.on_key({plot::input::kKeyRight, 0, true});
const plot::Range r = p.x_range(lg);
PLOT_CHECK_NEAR(std::log10(r.max) - std::log10(r.min), 2.0, 1e-9);
PLOT_CHECK_NEAR(std::log10(r.max), 2.2, 1e-9); // +10% of the 2-decade span
// A key with no gesture changes nothing.
c.on_key({'q', 0, true});
PLOT_CHECK_NEAR(std::log10(p.x_range(lg).max), 2.2, 1e-9);
}
/// Escape cancels the active drag: the ranges (and auto-scale flags) the axes held
/// at `begin_drag` are restored, and the drag is closed -- the release that
/// follows applies nothing.
PLOT_TEST(escape_cancels_a_pan_and_restores_the_captured_ranges) {
ArmedFixture f;
const f64 x_min = f.plot.x_range(f.ax).min;
const f64 x_max = f.plot.x_range(f.ax).max;
const f64 y_min = f.plot.y_range(f.ay).min;
const f64 y_max = f.plot.y_range(f.ay).max;
f.controller.on_pointer({100.0f, 100.0f, 0, 0, plot::input::Button::Left, 0, true, true,
false});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::Pan);
f.controller.on_pointer({300.0f, 200.0f, 200.0f, 100.0f, plot::input::Button::Left, 0, true,
false, false});
PLOT_CHECK(f.plot.x_range(f.ax).min < x_min); // the gesture did move things
PLOT_CHECK(!f.plot.x_axis(f.ax).auto_scale);
f.controller.on_key({plot::input::kKeyEscape, 0, true});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::None);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, x_min, 1e-9);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, x_max, 1e-9);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, y_min, 1e-9);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, y_max, 1e-9);
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale); // the fit is armed again
PLOT_CHECK(f.plot.y_axis(f.ay).auto_scale);
// The release arrives after the cancel; there is no gesture left to end.
f.controller.on_pointer({300.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
true});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::None);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, x_min, 1e-9);
}
/// Escape drops a box zoom without applying it: the selection is discarded and the
/// ranges the axes held when the drag began are still there at the end.
PLOT_TEST(escape_drops_a_box_zoom_without_applying) {
ArmedFixture f;
const f64 x_max = f.plot.x_range(f.ax).max;
f.controller.on_pointer({100.0f, 50.0f, 0, 0, plot::input::Button::Left, kShift, true, true,
false});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::BoxZoom);
f.controller.on_pointer({300.0f, 150.0f, 200.0f, 100.0f, plot::input::Button::Left, kShift,
true, false, false});
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale); // nothing applies before the release
f.controller.on_key({plot::input::kKeyEscape, 0, true});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::None);
PLOT_CHECK_NEAR(f.controller.selection_rect().w, 0.0, 1e-9); // the box is gone
f.controller.on_pointer({300.0f, 150.0f, 0, 0, plot::input::Button::Left, kShift, false,
false, true});
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, x_max, 1e-9); // and nothing was framed
PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale);
}
/// The box-zoom masks: Alt+Left frames the y range only -- from a selection too
/// thin horizontally to be a two-axis box, which is what makes the threshold
/// masked with the gesture.
PLOT_TEST(alt_drag_box_zooms_y_only) {
ArmedFixture f;
constexpr u32 kAlt = static_cast<u32>(plot::input::Mod::Alt);
const plot::Rect axes = f.plot.layout().axes;
const f64 want_lo = f.plot.y_transform(f.ay).to_data_y(axes.y + 150.0f);
const f64 want_hi = f.plot.y_transform(f.ay).to_data_y(axes.y + 50.0f);
// Two logical px wide: an unmasked box would call this a click.
f.controller.on_pointer({200.0f, axes.y + 50.0f, 0, 0, plot::input::Button::Left, kAlt, true,
true, false});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::BoxZoom);
f.controller.on_pointer({202.0f, axes.y + 150.0f, 2, 100, plot::input::Button::Left, kAlt,
true, false, false});
f.controller.on_pointer({202.0f, axes.y + 150.0f, 0, 0, plot::input::Button::Left, kAlt,
false, false, true});
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, want_lo, 1e-6);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, want_hi, 1e-6);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 0.0, 1e-9); // x untouched
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 100.0, 1e-9);
}
/// The Ctrl mask, applied at release from a selection that only makes sense
/// horizontally: the x range is framed from the box while the y range is left
/// alone, and a selection thin *vertically* is still a box under the mask.
PLOT_TEST(ctrl_mask_frames_x_from_a_thin_horizontal_band) {
ArmedFixture f;
const plot::Rect axes = f.plot.layout().axes;
const f64 want_lo = f.plot.x_transform(f.ax).to_data_x(axes.x + 100.0f);
const f64 want_hi = f.plot.x_transform(f.ax).to_data_x(axes.x + 300.0f);
f.controller.on_pointer({axes.x + 100.0f, 200.0f, 0, 0, plot::input::Button::Left, kCtrl,
true, true, false});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::BoxZoom);
// Two logical px tall: an unmasked box would call this a click.
f.controller.on_pointer({axes.x + 300.0f, 202.0f, 200, 2, plot::input::Button::Left, kCtrl,
true, false, false});
f.controller.on_pointer({axes.x + 300.0f, 202.0f, 0, 0, plot::input::Button::Left, kCtrl,
false, false, true});
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, want_lo, 1e-6);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, want_hi, 1e-6);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -1.0, 1e-9); // y untouched
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.0, 1e-9);
}
/// The double-click reset: two presses of the same button within 0.35 s and 4 px
/// (on `PointerEvent::time_s`) re-fit every axis -- auto scale re-armed with the
/// fit -- and the second press begins no drag.
PLOT_TEST(double_click_refits_every_axis_and_starts_no_drag) {
// A plot that has something to fit: the reset re-fits every axis to its traces,
// so this fixture carries one (the ArmedFixture above is deliberately empty --
// its tests are about auto-scale *state*, and an axis with no trace has nothing
// to fit). The axes start disarmed so the initial layout cannot pre-fit them.
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ax = p.add_x_axis({"t", {5.0, 60.0}, false, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {-0.5, 0.5}, false, plot::AxisPlace::Left, false});
std::vector<f32> ys(101);
for (u64 i = 0; i <= 100u; ++i) ys[static_cast<std::size_t>(i)] = (i % 2u == 0u) ? -1.0f : 1.0f;
plot::data::ArraySeries series(std::move(ys), 0.0, 1.0); // x = 0..100, y in [-1, 1]
p.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, ax, ay});
plot::input::Controller c(p);
c.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
// Zoom in first, so the window is somewhere a reset has to leave.
c.on_wheel({400.0f, 200.0f, 0.0f, 1.0f, 0});
PLOT_CHECK(p.x_range(ax).span() < 60.0);
PLOT_CHECK(p.y_range(ay).span() < 1.0);
c.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true, false,
/*time_s=*/1.0});
c.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false, true, 1.0});
c.on_pointer({402.0f, 201.0f, 2, 1, plot::input::Button::Left, 0, true, true, false,
1.2}); // 0.2 s and ~2.2 px later
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None); // no drag began
PLOT_CHECK(p.x_axis(ax).auto_scale); // the reset fit and re-armed
PLOT_CHECK(p.y_axis(ay).auto_scale);
// Both axes frame their traces' bounds (the 2% pad keeps the data inside them),
// which is what a fit means here.
PLOT_CHECK(p.x_range(ax).min < 0.0);
PLOT_CHECK(p.x_range(ax).max > 100.0);
PLOT_CHECK(p.y_range(ay).min < -1.0);
PLOT_CHECK(p.y_range(ay).max > 1.0);
// And the re-armed fit survives a layout: the frame the host draws next does not
// move the window the reset just chose.
const plot::Range xr = p.x_range(ax);
p.layout();
PLOT_CHECK_NEAR(p.x_range(ax).min, xr.min, 1e-9);
PLOT_CHECK_NEAR(p.x_range(ax).max, xr.max, 1e-9);
// A third press starts a fresh gesture (a pan), not a second reset.
c.on_pointer({402.0f, 201.0f, 0, 0, plot::input::Button::Left, 0, true, true, false, 1.4});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::Pan);
c.on_pointer({402.0f, 201.0f, 0, 0, plot::input::Button::Left, 0, false, false, true, 1.4});
}
/// A double-click needs the host's clock: presses that carry `time_s == 0` are
/// never one, whatever their spacing, and presses outside the window (too slow or
/// too far apart) are two ordinary gestures -- the second one a drag.
PLOT_TEST(double_click_needs_both_timestamps_and_the_window) {
ArmedFixture f;
f.plot.set_x_auto_scale(f.ax, false);
// No timestamps: two presses at the same spot do not reset.
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true,
false, 0.0});
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
true, 0.0});
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true,
false, 0.0});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::Pan);
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
true, 0.0});
// Too slow: 0.4 s apart is two gestures.
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true,
false, 10.0});
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
true, 10.0});
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true,
false, 10.4});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::Pan);
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
true, 10.4});
// Too far: 10 px apart is two gestures.
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true,
false, 20.0});
f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
true, 20.0});
f.controller.on_pointer({410.0f, 200.0f, 10, 0, plot::input::Button::Left, 0, true, true,
false, 20.1});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::Pan);
f.controller.on_pointer({410.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
true, 20.1});
}
// A readout cursor is drawn but not offered to the pointer: the host that keeps it is showing
// a computed value, and a drag on it would be a drag on a number rather than on an instrument.
// The hit test is where that is enforced -- re-placing the marker every frame would only fight
// the drag, which is the shape this replaces.
PLOT_TEST(an_unselectable_cursor_is_neither_hovered_nor_grabbed) {
plot::Plot p;
p.set_viewport({0, 0, 800, 400});
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
const u32 readout = p.add_cursor(50.0, plot::Color::hex(0x00FF00));
p.set_cursor_selectable(readout, false);
PLOT_CHECK(p.cursor(readout).visible); // drawn all the same
PLOT_CHECK(!p.cursor(readout).selectable);
// A normal cursor right beside it stays grabbable, so the flag is a property of one cursor
// and not a switch on the plot.
const u32 normal = p.add_cursor(50.0, plot::Color::hex(0xFF0000));
plot::input::Controller c(p);
c.set_viewport({0, 0, 800, 400});
const f32 sx = screen_x_of(p, ax, 50.0);
c.on_pointer({sx, 200, 0, 0, plot::input::Button::Left, 0, false, false, false});
PLOT_CHECK(c.hovered_cursor() == normal); // the readout is not in the running
// Press and move: the pointer grabbed the *normal* cursor (the two are at the same x, so
// the readout would otherwise be the nearer hit), and the readout's own x never moves.
const f64 before = p.cursor(readout).x;
c.on_pointer({sx, 200, 0, 0, plot::input::Button::Left, 0, true, true, false});
PLOT_CHECK(c.grabbed_cursor() == normal);
c.on_pointer({sx + 40, 200, 40, 0, plot::input::Button::Left, 0, true, false, false});
PLOT_CHECK_NEAR(p.cursor(readout).x, before, 1e-12);
PLOT_CHECK(c.grabbed_cursor() == normal);
}
PLOT_TEST(a_ctrl_box_zoom_previews_the_band_it_will_zoom) {
// What a host draws for an open box zoom is not always the area the pointer swept. Ctrl frames the
// x range only, so what its release will change is a *band* across the plot -- and a pointer dragged
// straight across sweeps a sliver a couple of pixels tall, which would be the frame showing the
// gesture instead of its effect. Shift frames both axes and gets the swept box itself; Alt is the
// other single-axis mask, and gets the other frame.
plot::Plot p;
p.set_viewport({0, 0, 800, 400});
p.add_x_axis({"t", {0.0, 800.0}, true, plot::AxisPlace::Bottom, false});
const plot::Rect axes = p.layout().axes;
constexpr plot::u32 kAlt = static_cast<plot::u32>(plot::input::Mod::Alt);
plot::input::Controller c(p);
c.set_viewport({0, 0, 800, 400});
c.on_pointer({100, 200, 0, 0, plot::input::Button::Left, kCtrl, true, true, false});
// Nothing framed until something was swept: the press alone is an empty rect at the pointer, and a
// full-width band derived from it would claim a range that does not exist yet.
PLOT_CHECK_NEAR(c.selection_preview().w, 0.0, 1e-6);
c.on_pointer({300, 202, 200, 2, plot::input::Button::Left, kCtrl, true, false, false});
const plot::Rect ctrl = c.selection_preview();
PLOT_CHECK_NEAR(ctrl.x, 100.0, 1e-6);
PLOT_CHECK_NEAR(ctrl.w, 200.0, 1e-6);
PLOT_CHECK_NEAR(ctrl.y, axes.y, 1e-6);
PLOT_CHECK_NEAR(ctrl.h, axes.h, 1e-6);
// The gesture's own geometry is untouched: the widening happens in the preview, so the range the
// release computes is still the one the pointer described.
PLOT_CHECK_NEAR(c.selection_rect().h, 2.0, 1e-6);
c.on_pointer({300, 202, 0, 0, plot::input::Button::Left, kCtrl, false, false, true});
c.on_pointer({100, 50, 0, 0, plot::input::Button::Left, kShift, true, true, false});
c.on_pointer({400, 150, 300, 100, plot::input::Button::Left, kShift, true, false, false});
const plot::Rect both = c.selection_preview();
PLOT_CHECK_NEAR(both.x, 100.0, 1e-6);
PLOT_CHECK_NEAR(both.y, 50.0, 1e-6);
PLOT_CHECK_NEAR(both.w, 300.0, 1e-6);
PLOT_CHECK_NEAR(both.h, 100.0, 1e-6);
c.on_pointer({400, 150, 0, 0, plot::input::Button::Left, kShift, false, false, true});
c.on_pointer({100, 50, 0, 0, plot::input::Button::Left, kAlt, true, true, false});
c.on_pointer({110, 150, 10, 100, plot::input::Button::Left, kAlt, true, false, false});
const plot::Rect alt = c.selection_preview();
PLOT_CHECK_NEAR(alt.x, axes.x, 1e-6);
PLOT_CHECK_NEAR(alt.w, axes.w, 1e-6);
PLOT_CHECK_NEAR(alt.y, 50.0, 1e-6);
PLOT_CHECK_NEAR(alt.h, 100.0, 1e-6);
c.on_pointer({110, 150, 0, 0, plot::input::Button::Left, kAlt, false, false, true});
// Closed: a frame on a finished gesture would claim the old selection is still open.
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None);
PLOT_CHECK_NEAR(c.selection_preview().w, 0.0, 1e-6);
}
+695 -8
View File
@@ -16,6 +16,7 @@
#include <plot/render/embedded_mono_font.hpp>
#include <cmath>
#include <span>
#include <vector>
namespace {
@@ -425,6 +426,52 @@ PLOT_TEST(figure_gutter_never_squeezes_the_axes_below_the_floor) {
}
}
/// The other side of the same band. `axes` reached the viewport's right edge, which left
/// the rightmost x tick label -- centred on it -- half outside the plot, so a figure can
/// ask for an empty band there. One value for every row, like the left gutter, because
/// the rows' axes have to end on a common column as well as start on one.
PLOT_TEST(figure_rows_share_one_right_gutter) {
plot::Figure figure = different_magnitude_figure();
figure.layout();
const plot::f32 flush = figure.plot(0u).layout().axes.right();
PLOT_CHECK_NEAR(static_cast<plot::f64>(figure.right_gutter()), 0.0, 1e-12);
// The default is the layout as it always was: nothing between the data and the frame.
PLOT_CHECK_NEAR(static_cast<plot::f64>(flush), static_cast<plot::f64>(kW), 1e-4);
figure.set_right_gutter(30.0f);
figure.layout();
const plot::Rect first = figure.plot(0u).layout().axes;
for (plot::u32 row = 0; row < 3u; ++row) {
const plot::PlotLayout& rl = figure.plot(row).layout();
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.right_gutter), 30.0, 1e-4);
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.axes.right()), static_cast<plot::f64>(kW) - 30.0,
1e-4);
// The band comes off the right alone: the left gutter the rows agreed on is untouched,
// so a host can balance the two sides without the one moving when it sets the other.
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.axes.x), static_cast<plot::f64>(first.x), 1e-4);
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.axes.w), static_cast<plot::f64>(first.w), 1e-4);
// ... and the x strip follows the data area rather than the viewport, or the shared
// axis would be labelled over a band that holds no data.
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.x_label_area.w), static_cast<plot::f64>(rl.axes.w),
1e-4);
}
// Clamped by the same cap as the strips: a band wider than the whole figure cannot
// squeeze `axes` below the floor `Plot::layout()` guarantees.
figure.set_right_gutter(10.0f * kW);
figure.layout();
for (plot::u32 row = 0; row < 3u; ++row) {
const plot::Rect& axes = figure.plot(row).layout().axes;
PLOT_CHECK(axes.w >= 0.5f * kW - 1e-3f);
}
// Negative means "none", like the gutter and the row gap, and the data area goes back
// to the viewport's edge.
figure.set_right_gutter(-5.0f);
PLOT_CHECK_NEAR(static_cast<plot::f64>(figure.right_gutter()), 0.0, 1e-12);
figure.layout();
PLOT_CHECK_NEAR(static_cast<plot::f64>(figure.plot(0u).layout().axes.right()),
static_cast<plot::f64>(flush), 1e-4);
}
/// The degenerate neighbour: a figure of one row imposes exactly what that row
/// measured, so it lays out as the plot would on its own -- the gutter cannot move a
/// row that has no sibling to line up with.
@@ -786,14 +833,99 @@ PLOT_TEST(a_following_group_advances_all_rows_together) {
}
}
/// B4, on a following *log* group: the group's span and offset live in the shared
/// axis' axis space -- decades -- exactly as a single `Live` axis' do, so the
/// window slides with the newest sample without rescaling. The visible decades
/// stay what was asked for on every row, and a member panned mid-follow restates
/// its scroll state in the same decades rather than in a linear width that would
/// decay as the edge moved.
PLOT_TEST(a_following_log_group_holds_decades) {
plot::data::StreamSeries left(1024u, 1.0, 10.0); // samples at x = 1, 11, 21, ...
plot::data::StreamSeries right(1024u, 1.0, 10.0);
for (int i = 0; i < 30; ++i) left.push(0.5f);
for (int i = 0; i < 20; ++i) right.push(0.5f);
left.acquire();
right.acquire();
plot::Figure figure(2u);
figure.set_viewport(whole_figure());
for (plot::u32 row = 0; row < 2u; ++row) {
plot::Plot& p = figure.plot(row);
p.add_x_axis({"t", {1.0, 1000.0}, false, plot::AxisPlace::Bottom, /*log=*/true});
p.add_y_axis({"v", {0.0, 1.0}, false, plot::AxisPlace::Left, false});
}
figure.plot(0u).add_trace({&left, plot::Color::hex(0x4FA3FF), 1.5f, 0u, 0u});
figure.plot(1u).add_trace({&right, plot::Color::hex(0xFF8080), 1.5f, 0u, 0u});
const plot::u32 group = figure.add_x_link();
PLOT_CHECK(figure.link_x(0u, group));
PLOT_CHECK(figure.link_x(1u, group));
PLOT_CHECK(figure.set_x_link_follow(group, plot::XFollow::Live));
const auto decades_of = [](const plot::Plot& p) {
const plot::Range r = p.x_range(0u);
return std::log10(r.max) - std::log10(r.min);
};
// One decade wide, ending at the fastest row's edge.
const plot::f64 edge0 = left.x_bounds().max;
PLOT_CHECK(figure.set_x_link_range(group, {edge0 / 10.0, edge0}));
figure.layout();
PLOT_CHECK_NEAR(decades_of(figure.plot(0u)), 1.0, 1e-9);
PLOT_CHECK_NEAR(decades_of(figure.plot(1u)), 1.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).max, edge0, 1e-8);
// The streams advance at different rates; the window follows the group's edge --
// the faster row's -- and holds its decade on every row.
for (int step = 1; step <= 8; ++step) {
for (int i = 0; i < 3; ++i) left.push(0.5f);
for (int i = 0; i < 1; ++i) right.push(0.5f);
left.acquire();
right.acquire();
figure.layout();
PLOT_CHECK_NEAR(decades_of(figure.plot(0u)), 1.0, 1e-9);
PLOT_CHECK_NEAR(decades_of(figure.plot(1u)), 1.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).max, left.x_bounds().max, 1e-8);
PLOT_CHECK_NEAR(figure.plot(1u).x_range(0u).max, left.x_bounds().max, 1e-8);
}
// A member panned while the group follows: the adopted scroll state is the
// adopted range's span *in decades* -- two here (edge/1000 to edge/10 is a
// factor of 100) -- and the offset as far behind the edge as the retained data
// allows, both of which survive the next frames' advances.
//
// The pan *asks* for a full decade back, and the group may not take it: the offset is bounded by
// `retained - span`, so the window stops with its left edge on the oldest sample. Asserting
// `max == edge/10` here was asserting the *old* bound, which put a whole span of the window over
// data the members never held -- the empty-left-margin defect this bound exists for. The
// log-space assertions above are this test's subject and are unchanged.
const plot::f64 edge = left.x_bounds().max;
figure.plot(1u).set_x_range(0u, {edge / 1000.0, edge / 10.0});
figure.layout();
PLOT_CHECK_NEAR(decades_of(figure.plot(0u)), 2.0, 1e-9);
PLOT_CHECK_NEAR(decades_of(figure.plot(1u)), 2.0, 1e-9);
const plot::f64 oldest = std::min(left.x_bounds().min, right.x_bounds().min);
PLOT_CHECK(figure.plot(0u).x_range(0u).min >= oldest - 1e-6);
PLOT_CHECK(figure.plot(0u).x_range(0u).max <= edge + 1e-6);
for (int step = 0; step < 3; ++step) {
left.push(0.5f);
left.acquire();
figure.layout();
PLOT_CHECK_NEAR(decades_of(figure.plot(0u)), 2.0, 1e-9);
PLOT_CHECK_NEAR(decades_of(figure.plot(1u)), 2.0, 1e-9);
PLOT_CHECK(figure.plot(0u).x_range(0u).min >= oldest - 1e-6);
}
PLOT_CHECK(figure.x_link_follow(group) == plot::XFollow::Live);
}
// ------------------------------------------------- the crosshair (spec §15)
namespace {
/// The figure the crosshair cases hover: three rows at deliberately *different* sample rates on
/// the one shared x, plus a fourth whose data does not reach the hovered x at all. Rows that
/// sample at different rates are the point -- one x, three different interpolations -- and the
/// last row is the case a readout must not invent a value for.
/// sample at different rates are the point -- one x, and each row reporting a sample off its own
/// grid -- and the last row is the case a readout must not invent a value for.
plot::Figure crosshair_figure() {
plot::Figure figure(4u);
figure.set_viewport(whole_figure());
@@ -821,7 +953,7 @@ plot::Figure crosshair_figure() {
/// Spec §15, the sync: `Figure::set_crosshair()` applies **one** data x to every row, so one
/// hover is one instant and N readouts rather than N independent ones. Rows at different sample
/// rates therefore report *their own* interpolations of the same x -- the shared axis is x, and
/// rates therefore report *their own* nearest sample to the same x -- the shared axis is x, and
/// each row's series is read on its own grid -- while a row whose series does not reach that x
/// reports no value at all instead of a fabricated one.
PLOT_TEST(figure_crosshair_applies_one_x_to_every_row) {
@@ -845,14 +977,15 @@ PLOT_TEST(figure_crosshair_applies_one_x_to_every_row) {
PLOT_CHECK(first.rows[0].label == "fast");
PLOT_CHECK_NEAR(first.rows[0].y, 30.0, 1e-9);
// Row 1 samples every *two* units: x = 3 is halfway between sample 1 (y = 100) and sample 2
// (y = 200), so it reports 150 -- a different number for the same x, which is what makes this
// case about the rows rather than about one series read four times.
// Row 1 samples every *two* units: x = 3 is halfway between sample 1 (x = 2, y = 100) and
// sample 2 (x = 4, y = 200), which is the tie, so it reports sample 1's 100 -- a different
// number for the same x, which is what makes this case about the rows rather than about one
// series read four times. Not 150: a readout quotes a sample, and this row has none at x = 3.
const plot::CrosshairLayout& second = figure.plot(1u).layout().crosshair;
PLOT_CHECK(second.rows.size() == 1u);
PLOT_CHECK(second.rows[0].valid);
PLOT_CHECK(second.rows[0].label == "slow");
PLOT_CHECK_NEAR(second.rows[0].y, 150.0, 1e-9);
PLOT_CHECK_NEAR(second.rows[0].y, 100.0, 1e-9);
PLOT_CHECK(second.rows[0].value != first.rows[0].value);
// Row 2's trace has no series yet: no row at all -- it is not a signal under the pointer.
@@ -887,7 +1020,9 @@ PLOT_TEST(figure_crosshair_applies_one_x_to_every_row) {
PLOT_CHECK_NEAR(figure.plot(row).crosshair().x, 1.0, 1e-12);
}
PLOT_CHECK_NEAR(figure.plot(0u).layout().crosshair.rows[0].y, 10.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(1u).layout().crosshair.rows[0].y, 50.0, 1e-9); // 0 + 100 * 0.5
// Not 50: x = 1 is the tie between this row's samples at x = 0 (y = 0) and x = 2 (y = 100),
// and a readout quotes one of them.
PLOT_CHECK_NEAR(figure.plot(1u).layout().crosshair.rows[0].y, 0.0, 1e-9);
PLOT_CHECK(!figure.plot(3u).layout().crosshair.rows[0].valid);
// On *every* row at once, and only the rows free it.
@@ -1007,3 +1142,555 @@ PLOT_TEST(figure_crosshair_draws_a_horizontal_line_only_in_the_hovered_row) {
PLOT_CHECK(figure.plot(1u).layout().crosshair.vertical.w > 0.0f);
PLOT_CHECK(figure.plot(1u).layout().crosshair.horizontal.h == 0.0f);
}
// ------------------------------------------------------------------ cell grid
namespace {
/// A figure whose rows hold `cells[r]` plots each (the flattened plots in row-major
/// order), with an x and a y axis on every plot unless `axes` is off.
plot::Figure split_figure(const std::vector<plot::u32>& cells, bool axes = true) {
plot::Figure figure{std::span<const plot::u32>(cells)}; // braces: parentheses would be a vexing parse
figure.set_viewport(whole_figure());
if (axes) {
for (plot::u32 i = 0; i < figure.plot_count(); ++i) {
figure.plot(i).add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
figure.plot(i).add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
}
}
return figure;
}
} // namespace
PLOT_TEST(figure_cells_all_ones_equals_rows) {
plot::Figure split = split_figure({1, 1, 1});
plot::Figure plain = figure_with_axes(3u);
const plot::FigureLayout& sl = split.layout();
const plot::FigureLayout& pl = plain.layout();
PLOT_CHECK(split.row_count() == 3u && split.plot_count() == 3u);
PLOT_CHECK(sl.cells.size() == 3u);
for (plot::u32 i = 0; i < 3u; ++i) {
PLOT_CHECK_NEAR(sl.cells[i].y, pl.rows[i].y, 0.01);
PLOT_CHECK_NEAR(sl.cells[i].h, pl.rows[i].h, 0.01);
PLOT_CHECK_NEAR(sl.cells[i].w, pl.rows[i].w, 0.01);
}
}
PLOT_TEST(figure_cells_tile_exactly) {
plot::Figure figure = split_figure({1, 2, 1});
figure.set_col_gap(10.0f);
const plot::FigureLayout& fl = figure.layout();
PLOT_CHECK(figure.row_count() == 3u && figure.plot_count() == 4u);
PLOT_CHECK(figure.cells_in_row(1u) == 2u);
PLOT_CHECK(figure.first_plot_of_row(1u) == 1u && figure.first_plot_of_row(3u) == 4u);
PLOT_CHECK(figure.row_of_plot(2u) == 1u);
PLOT_CHECK_NEAR(fl.cells[2].x - fl.cells[1].right(), 10.0, 0.01);
PLOT_CHECK_NEAR(fl.cells[2].right(), fl.rows[1].right(), 0.001);
PLOT_CHECK_NEAR(fl.cells[1].x, fl.rows[1].x, 0.001);
PLOT_CHECK_NEAR(fl.cells[1].y, fl.cells[2].y, 0.001);
PLOT_CHECK_NEAR(fl.cells[1].h, fl.cells[2].h, 0.001);
PLOT_CHECK_NEAR(fl.cells[1].w, fl.cells[2].w, 0.01);
PLOT_CHECK_NEAR(fl.col_gap, 10.0, 0.001);
}
PLOT_TEST(figure_cell_weights_and_floor) {
plot::Figure figure = split_figure({2});
figure.set_col_gap(0.0f);
const std::vector<plot::f32> w{3.0f, 1.0f};
PLOT_CHECK(figure.set_cell_weights(0u, w));
PLOT_CHECK(figure.has_cell_weights(0u));
{
const auto& c = figure.layout().cells;
PLOT_CHECK_NEAR((c[0].w - 64.0), (c[1].w - 64.0) * 3.0, 0.01);
}
const std::vector<plot::f32> tiny{1000.0f, 0.001f};
PLOT_CHECK(figure.set_cell_weights(0u, tiny));
PLOT_CHECK(figure.layout().cells[1].w >= 64.0f - 0.001f);
const std::vector<plot::f32> three{1.0f, 1.0f, 1.0f};
const std::vector<plot::f32> zero{1.0f, 0.0f};
PLOT_CHECK(!figure.set_cell_weights(0u, three));
PLOT_CHECK(!figure.set_cell_weights(0u, zero));
PLOT_CHECK(!figure.set_cell_weights(5u, w));
figure.clear_cell_weights(0u);
PLOT_CHECK(!figure.has_cell_weights(0u));
const auto& c = figure.layout().cells;
PLOT_CHECK_NEAR(c[0].w, c[1].w, 0.01);
}
PLOT_TEST(figure_cells_narrow_degrades) {
plot::Figure figure = split_figure({4}, false);
figure.set_viewport(plot::Rect{0.0f, 0.0f, 20.0f, 100.0f});
for (plot::u32 i = 0; i < 4u; ++i) {
figure.plot(i).add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
figure.plot(i).add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
}
figure.set_col_gap(10.0f);
const plot::FigureLayout& fl = figure.layout();
double sum = 0.0;
for (const plot::Rect& c : fl.cells) {
PLOT_CHECK(c.w >= 0.0f && c.h >= 0.0f);
PLOT_CHECK_NEAR(c.h, 100.0, 0.01);
sum += c.w;
}
PLOT_CHECK_NEAR(sum, 20.0, 0.01);
PLOT_CHECK_NEAR(fl.col_gap, 0.0, 0.001);
}
PLOT_TEST(figure_cells_share_x_link_and_crosshair) {
plot::Figure figure = split_figure({2, 1});
const plot::u32 group = figure.add_x_link();
for (plot::u32 i = 0; i < 3u; ++i) PLOT_CHECK(figure.link_x(i, group));
figure.plot(1u).set_x_range(0u, {3.0, 9.0});
figure.layout();
for (plot::u32 i = 0; i < 3u; ++i) {
PLOT_CHECK_NEAR(figure.plot(i).x_range(0u).min, 3.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(i).x_range(0u).max, 9.0, 1e-9);
}
figure.set_crosshair(4.0, true);
for (plot::u32 i = 0; i < 3u; ++i) {
PLOT_CHECK(figure.plot(i).crosshair().active);
PLOT_CHECK_NEAR(figure.plot(i).crosshair().x, 4.0, 1e-9);
}
figure.clear_crosshair();
for (plot::u32 i = 0; i < 3u; ++i) PLOT_CHECK(!figure.plot(i).crosshair().active);
}
PLOT_TEST(figure_cells_x_labels_bottom_row_only) {
// Rows with the same columns share the bottom one's axis: {1,1,1} labels the last row only.
plot::Figure figure = split_figure({1, 1, 1});
const plot::FigureLayout& fl = figure.layout();
PLOT_CHECK(figure.plot(0u).layout().x_label_area.h == 0.0f);
PLOT_CHECK(figure.plot(1u).layout().x_label_area.h == 0.0f);
PLOT_CHECK(figure.plot(2u).layout().x_label_area.h > 0.0f);
PLOT_CHECK_NEAR(fl.x_axis_strip.h, figure.plot(2u).layout().x_label_area.h, 0.001);
}
PLOT_TEST(figure_cells_common_y_gutter) {
plot::Figure figure = split_figure({2}, false);
give_magnitude_row(figure.plot(0u), 0u);
give_magnitude_row(figure.plot(1u), 1u);
const plot::FigureLayout& fl = figure.layout();
const double g0 = figure.plot(0u).layout().axes.x - fl.cells[0].x;
const double g1 = figure.plot(1u).layout().axes.x - fl.cells[1].x;
PLOT_CHECK(g0 > 0.0);
PLOT_CHECK_NEAR(g0, g1, 0.01);
}
PLOT_TEST(figure_row_y_cells_share_a_range_after_a_gesture) {
plot::Figure figure = split_figure({2, 1});
figure.set_share_row_y(true);
figure.layout();
figure.plot(1u).set_y_range(0u, {-3.0, 7.0}); // what a wheel / drag / pan ends in
figure.layout();
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).min, -3.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).max, 7.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(1u).y_range(0u).min, -3.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(1u).y_range(0u).max, 7.0, 1e-9);
// The unsplit row is its own thing.
PLOT_CHECK_NEAR(figure.plot(2u).y_range(0u).min, -1.0, 1e-9);
PLOT_CHECK(!figure.row_y_auto(0u));
}
/// The horizontal line is one *value*, and a split row's cells share one y scale: the line
/// spans the whole row instead of stopping at the divider between two cells that read the same
/// numbers. The rows of the figure still do not share y, so no other row gets one.
PLOT_TEST(figure_crosshair_horizontal_spans_a_split_rows_cells) {
plot::Figure figure = split_figure({2, 1});
figure.set_share_row_y(true);
figure.layout();
// The pointer in the *second* cell of the split row, half-way down it.
const plot::Rect cell1 = figure.layout().cells[1];
const plot::Transform ty = figure.plot(1u).y_transform(0u);
const plot::f32 y = static_cast<plot::f32>(ty.to_data_y(cell1.y + 0.5f * cell1.h));
figure.set_crosshair(50.0, 1u, y, true);
figure.layout();
const plot::CrosshairLayout& left = figure.plot(0u).layout().crosshair;
const plot::CrosshairLayout& right = figure.plot(1u).layout().crosshair;
const plot::CrosshairLayout& other = figure.plot(2u).layout().crosshair;
// Both cells of the row carry the line, at one screen row: they share the y scale, so the
// row's own y is the same value's height in each.
PLOT_CHECK(left.horizontal.w > 0.0f);
PLOT_CHECK(right.horizontal.w > 0.0f);
PLOT_CHECK_NEAR(left.horizontal.y + 0.5f * left.horizontal.h,
right.horizontal.y + 0.5f * right.horizontal.h, 0.75);
// The unsplit row keeps the shared-x vertical line and no horizontal one -- a line there
// would mean a value on a scale that row does not have.
PLOT_CHECK(other.vertical.w > 0.0f);
PLOT_CHECK(other.horizontal.h == 0.0f);
// The spread does not touch the x: both cells keep the one shared column.
PLOT_CHECK(left.vertical.w > 0.0f);
PLOT_CHECK(right.vertical.w > 0.0f);
// Without `set_share_row_y` the cells are separate scales again, and the line is the
// hovered cell's alone.
figure.set_share_row_y(false);
figure.set_crosshair(50.0, 1u, y, true);
figure.layout();
PLOT_CHECK(figure.plot(1u).layout().crosshair.horizontal.w > 0.0f);
PLOT_CHECK(figure.plot(0u).layout().crosshair.horizontal.h == 0.0f);
}
PLOT_TEST(figure_row_y_auto_fits_the_union_and_ignores_empty_cells) {
plot::Figure figure = split_figure({3});
for (plot::u32 i = 0; i < 3u; ++i) figure.plot(i).set_y_auto_scale(0u, true);
plot::data::ArraySeries a(std::vector<plot::f32>{0.0f, 10.0f, 5.0f}, 0.0, 1.0);
plot::data::ArraySeries b(std::vector<plot::f32>{-20.0f, 5.0f, 0.0f}, 0.0, 1.0);
figure.plot(0u).add_trace({&a, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
figure.plot(1u).add_trace({&b, plot::Color::hex(0xFF8080), 1.5f, 0u, 0u});
figure.set_share_row_y(true); // cell 2 has no trace
figure.layout();
PLOT_CHECK(figure.row_y_auto(0u));
const plot::Range r0 = figure.plot(0u).y_range(0u);
PLOT_CHECK(r0.min <= -20.0 && r0.max >= 10.0);
PLOT_CHECK(r0.max - r0.min < 40.0); // padded, not blown up
for (plot::u32 i = 1; i < 3u; ++i) {
PLOT_CHECK_NEAR(figure.plot(i).y_range(0u).min, r0.min, 1e-9);
PLOT_CHECK_NEAR(figure.plot(i).y_range(0u).max, r0.max, 1e-9);
}
}
PLOT_TEST(figure_row_y_manual_then_fit_returns_to_auto) {
plot::Figure figure = split_figure({2});
for (plot::u32 i = 0; i < 2u; ++i) figure.plot(i).set_y_auto_scale(0u, true);
plot::data::ArraySeries a(std::vector<plot::f32>{0.0f, 10.0f}, 0.0, 1.0);
plot::data::ArraySeries b(std::vector<plot::f32>{-4.0f, 4.0f}, 0.0, 1.0);
figure.plot(0u).add_trace({&a, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
figure.plot(1u).add_trace({&b, plot::Color::hex(0xFF8080), 1.5f, 0u, 0u});
figure.set_share_row_y(true);
figure.layout();
figure.plot(1u).set_y_range(0u, {1.0, 2.0});
figure.layout();
PLOT_CHECK(!figure.row_y_auto(0u));
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).min, 1.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).max, 2.0, 1e-9);
figure.layout(); // manual holds: no fight with the plots' own autoscale
PLOT_CHECK_NEAR(figure.plot(1u).y_range(0u).max, 2.0, 1e-9);
figure.plot(0u).auto_fit_y(0u); // what fit-all / double-click does to one cell
figure.layout();
PLOT_CHECK(figure.row_y_auto(0u));
PLOT_CHECK(figure.plot(1u).y_range(0u).min <= -4.0 && figure.plot(1u).y_range(0u).max >= 10.0);
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).min, figure.plot(1u).y_range(0u).min, 1e-9);
}
PLOT_TEST(figure_row_y_sharing_off_keeps_cells_independent) {
plot::Figure figure = split_figure({2});
figure.layout();
figure.plot(1u).set_y_range(0u, {-3.0, 7.0});
figure.layout();
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).max, 1.0, 1e-9);
PLOT_CHECK(!figure.share_row_y());
}
PLOT_TEST(figure_row_y_only_the_first_cell_has_a_gutter) {
plot::Figure figure = split_figure({1, 3}, false);
for (plot::u32 i = 0; i < figure.plot_count(); ++i) give_magnitude_row(figure.plot(i), 0u);
figure.set_share_row_y(true);
figure.layout();
PLOT_CHECK(figure.plot(0u).layout().y_axis_width > 0.0f); // unsplit row keeps its axis
PLOT_CHECK(figure.plot(1u).layout().y_axis_width > 0.0f); // first cell of the split row
for (plot::u32 i = 2; i < 4u; ++i) {
const plot::PlotLayout& l = figure.plot(i).layout();
PLOT_CHECK(l.y_axis_width == 0.0f && l.y_title_width == 0.0f && l.y_label_area.w == 0.0f);
PLOT_CHECK(l.axes.w > 0.0f);
}
// The gutter is one figure-wide value: the unsplit row and the split row's first cell agree.
PLOT_CHECK_NEAR(figure.plot(0u).layout().axes.x - figure.layout().cells[0].x,
figure.plot(1u).layout().axes.x - figure.layout().cells[1].x, 0.01);
}
PLOT_TEST(figure_row_y_equal_weights_give_equal_plot_widths_and_cells_tile) {
plot::Figure figure = split_figure({3}, false);
for (plot::u32 i = 0; i < 3u; ++i) give_magnitude_row(figure.plot(i), 0u);
figure.set_col_gap(8.0f);
figure.set_share_row_y(true);
const plot::FigureLayout& fl = figure.layout();
const double w0 = figure.plot(0u).layout().axes.w;
PLOT_CHECK_NEAR(figure.plot(1u).layout().axes.w, w0, 0.6);
PLOT_CHECK_NEAR(figure.plot(2u).layout().axes.w, w0, 0.6);
PLOT_CHECK_NEAR(fl.cells[0].x, fl.rows[0].x, 0.001);
PLOT_CHECK_NEAR(fl.cells[2].right(), fl.rows[0].right(), 0.001);
PLOT_CHECK_NEAR(fl.cells[1].x - fl.cells[0].right(), 8.0, 0.01);
PLOT_CHECK_NEAR(fl.cells[2].x - fl.cells[1].right(), 8.0, 0.01);
}
PLOT_TEST(figure_row_y_weights_apply_to_plot_widths) {
plot::Figure figure = split_figure({2}, false);
for (plot::u32 i = 0; i < 2u; ++i) give_magnitude_row(figure.plot(i), 0u);
figure.set_col_gap(0.0f);
figure.set_share_row_y(true);
const std::vector<plot::f32> w{3.0f, 1.0f};
PLOT_CHECK(figure.set_cell_weights(0u, w));
figure.layout();
const double a0 = figure.plot(0u).layout().axes.w;
const double a1 = figure.plot(1u).layout().axes.w;
PLOT_CHECK_NEAR(a0 - 64.0, (a1 - 64.0) * 3.0, 1.0);
}
PLOT_TEST(figure_row_y_narrow_row_degrades_without_negative_rects) {
plot::Figure figure = split_figure({4}, false);
figure.set_viewport(plot::Rect{0.0f, 0.0f, 30.0f, 100.0f});
for (plot::u32 i = 0; i < 4u; ++i) give_magnitude_row(figure.plot(i), 0u);
figure.set_col_gap(10.0f);
figure.set_share_row_y(true);
double sum = 0.0;
for (const plot::Rect& c : figure.layout().cells) {
PLOT_CHECK(c.w >= 0.0f && c.h >= 0.0f);
sum += c.w;
}
PLOT_CHECK_NEAR(sum, 30.0, 0.05);
}
PLOT_TEST(figure_row_y_sharing_off_still_gives_every_cell_a_gutter) {
plot::Figure figure = split_figure({2}, false);
give_magnitude_row(figure.plot(0u), 0u);
give_magnitude_row(figure.plot(1u), 1u);
figure.layout();
PLOT_CHECK(figure.plot(1u).layout().y_axis_width > 0.0f);
}
// ------------------------------------------------- x axes follow the columns
namespace {
/// A split figure with a text backend and a y axis of a real magnitude on every plot (so the
/// shared y gutter is non-zero), the host's configuration: row-shared y, a 6 px column gap and
/// a 35 px right gutter.
plot::Figure host_like_figure(const std::vector<plot::u32>& cells, bool share_y = true) {
plot::Figure figure = split_figure(cells, false);
for (plot::u32 i = 0; i < figure.plot_count(); ++i) give_magnitude_row(figure.plot(i), i);
figure.set_col_gap(6.0f);
figure.set_right_gutter(35.0f);
figure.set_share_row_y(share_y);
return figure;
}
bool shows_x_axis(plot::Figure& figure, plot::u32 plot_index) {
const plot::PlotLayout& l = figure.plot(plot_index).layout();
return l.x_axis_height > 0.0f && l.x_label_area.h > 0.0f;
}
bool hides_x_axis(plot::Figure& figure, plot::u32 plot_index) {
const plot::PlotLayout& l = figure.plot(plot_index).layout();
return l.x_axis_height == 0.0f && l.x_label_area.h == 0.0f;
}
} // namespace
PLOT_TEST(figure_x_axis_a_split_row_over_an_unsplit_one_keeps_its_own) {
plot::Figure figure = host_like_figure({2, 1});
figure.layout();
PLOT_CHECK(shows_x_axis(figure, 0u));
PLOT_CHECK(shows_x_axis(figure, 1u));
PLOT_CHECK(shows_x_axis(figure, 2u));
// ... and the other way round.
plot::Figure other = host_like_figure({1, 2});
other.layout();
PLOT_CHECK(shows_x_axis(other, 0u));
PLOT_CHECK(shows_x_axis(other, 1u));
PLOT_CHECK(shows_x_axis(other, 2u));
}
PLOT_TEST(figure_x_axis_rows_with_the_same_columns_share_the_bottom_axis) {
plot::Figure figure = host_like_figure({2, 2});
figure.layout();
PLOT_CHECK(hides_x_axis(figure, 0u));
PLOT_CHECK(hides_x_axis(figure, 1u));
PLOT_CHECK(shows_x_axis(figure, 2u));
PLOT_CHECK(shows_x_axis(figure, 3u));
// The hidden row's cells have exactly the x extents of the cells below, so the axis below
// serves them.
const plot::FigureLayout& fl = figure.layout();
for (plot::u32 c = 0; c < 2u; ++c) {
PLOT_CHECK_NEAR(fl.cells[c].x, fl.cells[2u + c].x, 1e-4);
PLOT_CHECK_NEAR(fl.cells[c].w, fl.cells[2u + c].w, 1e-4);
const plot::PlotLayout& up = figure.plot(c).layout();
const plot::PlotLayout& down = figure.plot(2u + c).layout();
PLOT_CHECK_NEAR(up.axes.x, down.axes.x, 1e-4);
PLOT_CHECK_NEAR(up.axes.w, down.axes.w, 1e-4);
PLOT_CHECK_NEAR(up.y_axis_width, down.y_axis_width, 1e-4);
}
// The same without row-shared y (every cell has its own y strip).
plot::Figure own_y = host_like_figure({2, 2}, false);
own_y.layout();
PLOT_CHECK(hides_x_axis(own_y, 0u) && hides_x_axis(own_y, 1u));
PLOT_CHECK(shows_x_axis(own_y, 2u) && shows_x_axis(own_y, 3u));
for (plot::u32 c = 0; c < 2u; ++c) {
PLOT_CHECK_NEAR(own_y.plot(c).layout().axes.x, own_y.plot(2u + c).layout().axes.x, 1e-4);
PLOT_CHECK_NEAR(own_y.plot(c).layout().axes.w, own_y.plot(2u + c).layout().axes.w, 1e-4);
}
}
PLOT_TEST(figure_x_axis_matching_compares_normalised_weights) {
const std::vector<plot::f32> one_three{1.0f, 3.0f};
const std::vector<plot::f32> two_six{2.0f, 6.0f};
const std::vector<plot::f32> halves{1.0f, 1.0f};
{ // 1:3 over 1:3
plot::Figure figure = host_like_figure({2, 2});
PLOT_CHECK(figure.set_cell_weights(0u, one_three));
PLOT_CHECK(figure.set_cell_weights(1u, one_three));
figure.layout();
PLOT_CHECK(hides_x_axis(figure, 0u) && hides_x_axis(figure, 1u));
PLOT_CHECK(shows_x_axis(figure, 2u) && shows_x_axis(figure, 3u));
const plot::FigureLayout& fl = figure.layout();
PLOT_CHECK_NEAR(fl.cells[0].w, fl.cells[2].w, 1e-3);
PLOT_CHECK_NEAR(figure.plot(1u).layout().axes.x, figure.plot(3u).layout().axes.x, 1e-3);
}
{ // 1:3 over 2:6 -- normalised, so the same columns
plot::Figure figure = host_like_figure({2, 2});
PLOT_CHECK(figure.set_cell_weights(0u, one_three));
PLOT_CHECK(figure.set_cell_weights(1u, two_six));
figure.layout();
PLOT_CHECK(hides_x_axis(figure, 0u) && hides_x_axis(figure, 1u));
}
{ // 1:3 over 1:1
plot::Figure figure = host_like_figure({2, 2});
PLOT_CHECK(figure.set_cell_weights(0u, one_three));
PLOT_CHECK(figure.set_cell_weights(1u, halves));
figure.layout();
PLOT_CHECK(shows_x_axis(figure, 0u) && shows_x_axis(figure, 1u));
PLOT_CHECK(shows_x_axis(figure, 2u) && shows_x_axis(figure, 3u));
}
{ // 1:3 over (no weights = equal)
plot::Figure figure = host_like_figure({2, 2});
PLOT_CHECK(figure.set_cell_weights(0u, one_three));
figure.layout();
PLOT_CHECK(shows_x_axis(figure, 0u) && shows_x_axis(figure, 1u));
}
{ // explicit 1:1 over (no weights = equal)
plot::Figure figure = host_like_figure({2, 2});
PLOT_CHECK(figure.set_cell_weights(0u, halves));
figure.layout();
PLOT_CHECK(hides_x_axis(figure, 0u) && hides_x_axis(figure, 1u));
}
{ // a different number of cells never matches, whatever the weights
plot::Figure figure = host_like_figure({3, 2});
figure.layout();
PLOT_CHECK(shows_x_axis(figure, 0u) && shows_x_axis(figure, 2u));
}
}
PLOT_TEST(figure_x_axis_unsplit_rows_are_unchanged) {
plot::Figure figure = host_like_figure({1, 1, 1});
figure.layout();
PLOT_CHECK(hides_x_axis(figure, 0u));
PLOT_CHECK(hides_x_axis(figure, 1u));
PLOT_CHECK(shows_x_axis(figure, 2u));
PLOT_CHECK_NEAR(figure.layout().x_axis_strip.h, figure.plot(2u).layout().x_label_area.h, 1e-4);
}
PLOT_TEST(figure_x_axis_follows_a_chain_of_matching_rows) {
{ // [2]/[2]/[1]: row 0 matches row 1 and hides; row 1 does not match row 2 and shows.
plot::Figure figure = host_like_figure({2, 2, 1});
figure.layout();
PLOT_CHECK(hides_x_axis(figure, 0u) && hides_x_axis(figure, 1u));
PLOT_CHECK(shows_x_axis(figure, 2u) && shows_x_axis(figure, 3u));
PLOT_CHECK(shows_x_axis(figure, 4u));
}
{ // [2]/[2]/[2]: transitive -- only the last row carries the axis.
plot::Figure figure = host_like_figure({2, 2, 2});
figure.layout();
for (plot::u32 i = 0; i < 4u; ++i) PLOT_CHECK(hides_x_axis(figure, i));
PLOT_CHECK(shows_x_axis(figure, 4u) && shows_x_axis(figure, 5u));
}
{ // [1]/[2]/[2]: the unsplit row matches nothing below it.
plot::Figure figure = host_like_figure({1, 2, 2});
figure.layout();
PLOT_CHECK(shows_x_axis(figure, 0u));
PLOT_CHECK(hides_x_axis(figure, 1u) && hides_x_axis(figure, 2u));
PLOT_CHECK(shows_x_axis(figure, 3u) && shows_x_axis(figure, 4u));
}
{ // the figure's x_axis_strip is still the last plot's label area
plot::Figure figure = host_like_figure({2, 1});
const plot::FigureLayout& fl = figure.layout();
PLOT_CHECK(fl.x_axis_strip.h > 0.0f);
PLOT_CHECK_NEAR(fl.x_axis_strip.h, figure.plot(2u).layout().x_label_area.h, 1e-4);
}
}
PLOT_TEST(figure_x_axis_labels_on_every_row_when_asked) {
plot::Figure figure = host_like_figure({2, 2, 1});
figure.set_x_labels_only_on_bottom(false);
figure.layout();
for (plot::u32 i = 0; i < figure.plot_count(); ++i) PLOT_CHECK(shows_x_axis(figure, i));
}
// -------------------------------------------- the gap between cells of a row
PLOT_TEST(figure_split_row_gap_between_plot_frames_is_the_column_gap) {
for (const plot::u32 n : {2u, 3u, 4u}) {
plot::Figure figure = host_like_figure({n});
const plot::FigureLayout& fl = figure.layout();
PLOT_CHECK_NEAR(fl.col_gap, 6.0, 1e-4);
for (plot::u32 k = 0; k + 1u < n; ++k) {
PLOT_CHECK_NEAR(figure.plot(k + 1u).layout().axes.x - figure.plot(k).layout().axes.right(),
6.0, 1e-3);
PLOT_CHECK_NEAR(fl.cells[k + 1u].x - fl.cells[k].right(), 6.0, 1e-3);
// Only the last cell holds the right gutter.
PLOT_CHECK_NEAR(figure.plot(k).layout().right_gutter, 0.0, 1e-6);
}
// Equal weights give equal axes widths, the first cell included.
for (plot::u32 k = 1; k < n; ++k) {
PLOT_CHECK_NEAR(figure.plot(k).layout().axes.w, figure.plot(0u).layout().axes.w, 0.6);
}
// The cells tile the row exactly.
PLOT_CHECK_NEAR(fl.cells[0].x, fl.rows[0].x, 1e-4);
PLOT_CHECK_NEAR(fl.cells[n - 1u].right(), fl.rows[0].right(), 1e-3);
// The right gutter is still there, on the last cell.
const plot::PlotLayout& last = figure.plot(n - 1u).layout();
PLOT_CHECK_NEAR(fl.cells[n - 1u].right() - last.axes.right(), 35.0, 1e-3);
PLOT_CHECK_NEAR(last.right_gutter, 35.0, 1e-3);
// The y gutter still sits on the first cell alone.
PLOT_CHECK(figure.plot(0u).layout().axes.x > fl.cells[0].x);
PLOT_CHECK_NEAR(figure.plot(1u).layout().axes.x, fl.cells[1].x, 1e-4);
}
}
PLOT_TEST(figure_split_row_gap_with_weights_and_a_y_gutter) {
plot::Figure figure = host_like_figure({1, 2});
const std::vector<plot::f32> w{3.0f, 1.0f};
PLOT_CHECK(figure.set_cell_weights(1u, w));
const plot::FigureLayout& fl = figure.layout();
const plot::Rect a0 = figure.plot(1u).layout().axes;
const plot::Rect a1 = figure.plot(2u).layout().axes;
PLOT_CHECK_NEAR(a1.x - a0.right(), 6.0, 1e-3);
PLOT_CHECK_NEAR(a0.w - 64.0, (a1.w - 64.0) * 3.0, 1.0);
PLOT_CHECK_NEAR(fl.cells[2].right(), fl.rows[1].right(), 1e-3);
PLOT_CHECK_NEAR(fl.cells[1].x, fl.rows[1].x, 1e-4);
PLOT_CHECK_NEAR(fl.cells[2].right() - a1.right(), 35.0, 1e-3);
// The unsplit row above keeps its own right gutter on its one plot.
PLOT_CHECK_NEAR(fl.cells[0].right() - figure.plot(0u).layout().axes.right(), 35.0, 1e-3);
}
PLOT_TEST(figure_split_row_gutters_degrade_on_a_narrow_row) {
plot::Figure figure = host_like_figure({3});
figure.set_viewport(plot::Rect{0.0f, 0.0f, 60.0f, 100.0f});
const plot::FigureLayout& fl = figure.layout();
double sum = 0.0;
for (const plot::Rect& c : fl.cells) {
PLOT_CHECK(c.w >= 0.0f && c.h >= 0.0f);
sum += c.w;
}
PLOT_CHECK_NEAR(sum + fl.col_gap * 2.0, 60.0, 0.05);
PLOT_CHECK_NEAR(fl.cells[2].right(), 60.0, 0.05);
for (plot::u32 i = 0; i < 3u; ++i) PLOT_CHECK(figure.plot(i).layout().axes.w >= 0.0f);
}
PLOT_TEST(figure_row_gutter_without_sharing_is_the_old_layout) {
plot::Figure figure = host_like_figure({2}, false);
const plot::FigureLayout& fl = figure.layout();
// Plain cells: equal halves of the row less the gap, every plot with its own right gutter.
PLOT_CHECK_NEAR(fl.cells[0].w, (fl.rows[0].w - 6.0) / 2.0, 1e-3);
PLOT_CHECK_NEAR(fl.cells[1].w, (fl.rows[0].w - 6.0) / 2.0, 1e-3);
PLOT_CHECK_NEAR(fl.cells[1].x - fl.cells[0].right(), 6.0, 1e-4);
for (plot::u32 i = 0; i < 2u; ++i) {
PLOT_CHECK_NEAR(figure.plot(i).layout().right_gutter, 35.0, 1e-3);
PLOT_CHECK_NEAR(fl.cells[i].right() - figure.plot(i).layout().axes.right(), 35.0, 1e-3);
}
// An unsplit row is unchanged by sharing being on.
plot::Figure unsplit = host_like_figure({1, 1});
const plot::FigureLayout& ul = unsplit.layout();
for (plot::u32 i = 0; i < 2u; ++i) {
PLOT_CHECK_NEAR(ul.cells[i].w, ul.rows[i].w, 1e-4);
PLOT_CHECK_NEAR(ul.cells[i].right() - unsplit.plot(i).layout().axes.right(), 35.0, 1e-3);
}
}
+9 -3
View File
@@ -494,13 +494,16 @@ PLOT_TEST(render_programs_are_inert_until_created) {
line.set_offset({0.0f, 0.0f});
line.set_viewport({256.0f, 256.0f});
line.set_width(2.0f);
// The AA fringe is a uniform now, not a hardcoded pixel: 1.0 is the fringe the
// renderer's own flush sets by default, and the same value the pixel bounds
// below were derived from.
line.set_aa(1.0f);
line.set_color(plot::Color::hex(0xFFFFFF));
text.bind();
text.set_viewport({256.0f, 256.0f});
text.set_px_range({8.0f, 8.0f});
text.set_glyph_uv({1.0f / 16.0f, 1.0f / 7.0f});
text.set_texture_size({128.0f, 56.0f});
text.set_color(plot::Color::hex(0xFFFFFF));
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
ctx.destroy();
}
@@ -541,6 +544,10 @@ PLOT_TEST(line_pipeline_draws_an_aa_strip) {
program.set_offset({64.0f, 100.0f});
program.set_viewport({static_cast<f32>(ctx.width), static_cast<f32>(ctx.height)});
program.set_width(3.0f);
// The AA fringe is a uniform now, not a hardcoded pixel: 1.0 is the fringe the
// renderer's own flush sets by default, and the value the pixel bounds below
// were derived from.
program.set_aa(1.0f);
program.set_color(plot::Color::hex(0xFF00FF));
lines.bind();
plot::gl::glDrawArraysInstanced(plot::gl::GL_TRIANGLE_STRIP, 0, 4, 3);
@@ -619,7 +626,7 @@ PLOT_TEST(text_pipeline_draws_a_glyph) {
// an identity.
batch.add_text("A", {64.0f, 100.0f}, 1.0f, font);
PLOT_CHECK(batch.glyph_count() == 1);
PLOT_CHECK(batch.data().size() == 6);
PLOT_CHECK(batch.data().size() == 10);
glyphs.upload(batch.data().data(), batch.glyph_count());
plot::gl::glViewport(0, 0, static_cast<plot::gl::GLsizei>(ctx.width),
@@ -640,7 +647,6 @@ PLOT_TEST(text_pipeline_draws_a_glyph) {
// White, so the blended pixel value *is* the coverage byte: the shader emits
// `vec4(rgb, a * coverage)`, and a white tint over the black clear lands at
// `1.0 * coverage` in every channel.
program.set_color(plot::Color::hex(0xFFFFFF));
atlas.bind();
glyphs.bind();
plot::gl::glDrawArraysInstanced(plot::gl::GL_TRIANGLE_STRIP, 0, 4, 1);
+158
View File
@@ -14,6 +14,9 @@
#include <glfw/glfw_host.hpp>
#include <plot/plot/figure.hpp>
#include <span>
#include <vector>
namespace {
/// A figure of `rows` rows over a known rect, with an x and a y axis in every row (so
@@ -168,6 +171,133 @@ PLOT_TEST(a_drag_that_crosses_rows_is_ended_by_the_release_in_the_other_row) {
PLOT_CHECK(row0.drag_mode() == plot::input::DragMode::None);
}
// --- a second button does not steal or end the captured row -----------------
namespace {
/// A two-row figure handed to a host: the fixture for the capture's *button*. No
/// region this time -- what the test observes is which controller a press lands
/// on, and a pan is the easiest gesture to watch a plot's range move with.
struct TwoRows {
TwoRows() : figure(2u) {
figure.set_viewport({0.0f, 0.0f, 300.0f, 600.0f});
for (plot::u32 row = 0; row < 2u; ++row) {
plot::Plot& p = figure.plot(row);
ax[row] = p.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
}
host.set_figure(&figure);
figure.layout();
}
plot::f32 row_y(plot::u32 row) const {
const plot::Rect r = figure.layout().rows[row];
return r.y + 0.5f * r.h;
}
plot::Figure figure;
plot::glfw::Host host;
plot::u32 ax[2] = {0u, 0u};
};
} // namespace
/// The finding (R5): the capture remembered only the *row*, so a second button
/// pressed while a drag ran stole nothing but its release still ended the capture
/// -- `pointer_target()` cleared `captured_` on **any** release. A chord (or a
/// trackpad that reports a second finger as a button) therefore stranded the first
/// button's gesture in its row: the next event of the *first* button was routed by
/// the pointer's position again, to a controller that never saw the gesture begin.
/// The capture now records the button as well, and only that button's release ends
/// it.
///
/// What the test observes is purely the host's routing -- which controller a fresh
/// press reaches -- and not the controller's drag modes, which a second button's
/// press/release pair legitimately rewrites: press-and-move pairs pan whichever
/// controller they are delivered to, so "whose range moved" says who got the events.
PLOT_TEST(a_second_button_neither_steals_nor_ends_the_captured_row) {
TwoRows r;
plot::input::Controller& row1 = *r.host.row_controller(1u);
const plot::f32 x = 150.0f;
// Left press in row 0: the capture is row 0's.
r.host.feed_pointer_event({x, r.row_y(0u), 0.0f, 0.0f, plot::input::Button::Left, 0u, true,
true, false});
// Right press in row 1 while the left gesture runs: the single-gesture policy --
// the capture is not stolen, so this press reaches row 0's controller too, and
// row 1's controller sees nothing at all.
r.host.feed_pointer_event({x, r.row_y(1u), 0.0f, 0.0f, plot::input::Button::Right, 0u, true,
true, false});
PLOT_CHECK(row1.drag_mode() == plot::input::DragMode::None);
// The right release must not end the left button's capture (the finding above):
// only the owning button's release does.
r.host.feed_pointer_event({x, r.row_y(1u), 0.0f, 0.0f, plot::input::Button::Right, 0u, false,
false, true});
// The proof that the capture survived: a fresh LEFT press landing physically in
// row 1 is still routed to the captured row 0...
const plot::Range row0_before = r.figure.plot(0u).x_range(r.ax[0]);
const plot::Range row1_before = r.figure.plot(1u).x_range(r.ax[1]);
r.host.feed_pointer_event({x, r.row_y(1u), 0.0f, 0.0f, plot::input::Button::Left, 0u, true,
true, false});
r.host.feed_pointer_event({x + 40.0f, r.row_y(1u), 40.0f, 0.0f, plot::input::Button::Left, 0u,
true, false, false});
// ... so the drag it begins pans row 0 and leaves row 1 alone. (With the capture
// gone, this press-and-move belongs to row 1 and the two assertions swap.)
PLOT_CHECK(std::abs(r.figure.plot(0u).x_range(r.ax[0]).min - row0_before.min) > 1e-6);
PLOT_CHECK(std::abs(r.figure.plot(1u).x_range(r.ax[1]).min - row1_before.min) < 1e-9);
// The owning button's release ends the capture, wherever it lands...
r.host.feed_pointer_event({x + 40.0f, r.row_y(1u), 0.0f, 0.0f, plot::input::Button::Left, 0u,
false, false, true});
// ... and the next press belongs to the row it lands in again: a fresh press in
// row 1 now pans row 1, not row 0.
const plot::Range row0_after = r.figure.plot(0u).x_range(r.ax[0]);
r.host.feed_pointer_event({x, r.row_y(1u), 0.0f, 0.0f, plot::input::Button::Left, 0u, true,
true, false});
r.host.feed_pointer_event({x + 40.0f, r.row_y(1u), 40.0f, 0.0f, plot::input::Button::Left, 0u,
true, false, false});
PLOT_CHECK(std::abs(r.figure.plot(1u).x_range(r.ax[1]).min - row1_before.min) > 1e-6);
PLOT_CHECK(std::abs(r.figure.plot(0u).x_range(r.ax[0]).min - row0_after.min) < 1e-9);
r.host.feed_pointer_event({x + 40.0f, r.row_y(1u), 0.0f, 0.0f, plot::input::Button::Left, 0u,
false, false, true});
}
// --- the row-rect cache ------------------------------------------------------
/// The finding (R7): `controller_at()` and `pointer_target()` read
/// `Figure::layout()` *per event*, and a non-const `layout()` recomputes -- every
/// pointer move on a multi-row figure re-laid every row out (ticks, grid, chrome
/// and all), twice for the readers that share a handler. The host now keeps the
/// rects of the last `refresh_row_rects()` and every handler reads that cache: the
/// run loop refreshes it once per frame, `set_figure()` fills it immediately (so a
/// scripted run routes against the figure it was just handed), and in between the
/// rows the handlers answer from are the rows the last frame drew -- at most one
/// frame stale across a resize, never a second layout.
PLOT_TEST(row_routing_reads_the_cache_until_it_is_refreshed) {
Routed r(3u);
const plot::Rect row1_before = r.figure.layout().rows[1];
const plot::f32 y = row1_before.y + 0.5f * row1_before.h;
PLOT_CHECK(&r.host.controller_at(150.0f, y) == r.host.row_controller(1u));
// Re-shape the figure without telling the host: the geometry the *next* frame
// would draw. The cache still holds the 600-pixel-tall rows, and the handler
// must keep answering from them -- with the old per-event layout the same pixel
// already answered for the new geometry (row 2), which is exactly what made
// every event pay for a relayout.
r.figure.set_viewport({0.0f, 0.0f, 300.0f, 300.0f});
r.figure.layout();
PLOT_CHECK(&r.host.controller_at(150.0f, y) == r.host.row_controller(1u));
// The scripted-run equivalent of the frame: the loop's one refresh per frame.
// From here the handlers answer with the new rects, and the pixel -- the old
// row 1's centre, now the bottom edge of a 300-pixel figure -- is row 2's.
r.host.refresh_row_rects();
PLOT_CHECK(&r.host.controller_at(150.0f, y) == r.host.row_controller(2u));
}
PLOT_TEST(the_host_keeps_its_own_controller_while_no_figure_is_set) {
plot::glfw::Host host;
PLOT_CHECK(host.figure() == nullptr);
@@ -188,3 +318,31 @@ PLOT_TEST(the_host_keeps_its_own_controller_while_no_figure_is_set) {
PLOT_CHECK(host.row_controller(0u) == nullptr);
PLOT_CHECK(&host.controller_at(10.0f, 10.0f) == &host.controller());
}
PLOT_TEST(glfw_host_routes_by_cell) {
// A figure of two rows, the first of one cell and the second of two side by side: the
// pointer's x as well as its y picks the plot, and the controllers are indexed by the
// flattened plot.
std::vector<plot::u32> cells{1, 2};
plot::Figure figure{std::span<const plot::u32>(cells)};
figure.set_viewport(plot::Rect{0, 0, 300, 600});
figure.set_col_gap(6.0f);
for (plot::u32 i = 0; i < 3; ++i) {
figure.plot(i).add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
figure.plot(i).add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
}
plot::glfw::Host host;
host.set_figure(&figure);
const std::vector<plot::Rect> c = figure.layout().cells;
PLOT_CHECK(c.size() == 3u);
for (plot::u32 i = 0; i < 3; ++i) {
PLOT_CHECK(host.row_controller(i) != nullptr);
const double cx = c[i].x + c[i].w * 0.5, cy = c[i].y + c[i].h * 0.5;
PLOT_CHECK(&host.controller_at(static_cast<plot::f32>(cx), static_cast<plot::f32>(cy)) ==
host.row_controller(i));
}
PLOT_CHECK(host.row_controller(3u) == nullptr);
// in the column gap: the nearest cell wins
PLOT_CHECK(&host.controller_at(c[1].right() + 1.0f, c[1].y + 5.0f) == host.row_controller(1));
PLOT_CHECK(&host.controller_at(c[2].x - 1.0f, c[2].y + 5.0f) == host.row_controller(2));
}
+105
View File
@@ -26,7 +26,9 @@ using plot::u32;
using plot::gtk4::DeviceMapping;
using plot::gtk4::DragAnchor;
using plot::gtk4::ScrollUnit;
using plot::gtk4::ZoomAnchor;
using plot::gtk4::button_from;
using plot::gtk4::key_from;
using plot::gtk4::kSurfacePixelsPerNotch;
using plot::gtk4::mods_from;
using plot::gtk4::scroll_notches;
@@ -212,3 +214,106 @@ PLOT_TEST(gtk_buttons_map_to_the_controllers_three_or_to_nothing) {
PLOT_CHECK(!button_from(8u).has_value()); // a navigation button
PLOT_CHECK(!button_from(9u).has_value());
}
// --- the pinch --------------------------------------------------------------
// `GtkGestureZoom` reports a cumulative scale since the gesture began, while the
// controller's `GestureEvent` wants this frame's zoom factor; `ZoomAnchor` is the
// fold, and it is the same fold the Qt adapter's `PinchTracker` does -- a
// cumulative 1.0 -> 1.25 -> 1.5 spread is a 1.25 frame then a 1.2 frame, so the
// zoom the plot applies is the zoom the fingers made.
PLOT_TEST(gtk_zoom_anchor_folds_the_cumulative_scale_into_increments) {
// A 2x widget: the focal point and its movement are reported in *device* pixels,
// like every coordinate the controller sees.
const DeviceMapping at_2 = DeviceMapping::from_sizes({400.0f, 300.0f}, {800.0f, 600.0f});
plot::gtk4::ZoomAnchor zoom;
zoom.begin(1.0f, {100.0f, 75.0f});
PLOT_CHECK(zoom.active);
// The begin itself folds to "nothing happened yet", whenever the first
// scale-changed arrives with the scale it began at.
const plot::input::GestureEvent started = zoom.update(1.0f, {100.0f, 75.0f}, at_2);
PLOT_CHECK_NEAR(started.scale, 1.0, 1e-6);
PLOT_CHECK_NEAR(started.dx, 0.0, 1e-6);
PLOT_CHECK_NEAR(started.dy, 0.0, 1e-6);
// Fingers spread to 1.25 about a steady centre: one 1.25 zoom frame, anchored
// at the centre in device pixels (100 * 2, 75 * 2).
const plot::input::GestureEvent spread = zoom.update(1.25f, {100.0f, 75.0f}, at_2);
PLOT_CHECK_NEAR(spread.scale, 1.25, 1e-6);
PLOT_CHECK_NEAR(spread.x, 200.0, 1e-6);
PLOT_CHECK_NEAR(spread.y, 150.0, 1e-6);
PLOT_CHECK_NEAR(spread.dx, 0.0, 1e-6);
PLOT_CHECK_NEAR(spread.dy, 0.0, 1e-6);
// Spread on to 1.5 while the fingers drift 10 logical pixels right: the
// increment is 1.2 and the pan is 20 device pixels.
const plot::input::GestureEvent drifted = zoom.update(1.5f, {110.0f, 75.0f}, at_2);
PLOT_CHECK_NEAR(drifted.scale, 1.2, 1e-6);
PLOT_CHECK_NEAR(drifted.dx, 20.0, 1e-6);
PLOT_CHECK_NEAR(drifted.dy, 0.0, 1e-6);
}
// The focal point is cached in logical widget pixels, so a factor change between
// two updates (the window dragged to another display mid-pinch, GTK re-emitting
// `::resize`) cannot read the ratio change as a focal jump; and an update outside
// a gesture -- before any begin, or a straggler after the end -- zooms and moves
// nothing rather than zooming from a made-up anchor.
PLOT_TEST(gtk_zoom_anchor_stays_logical_and_ignores_stragglers) {
const DeviceMapping at_1 = DeviceMapping::from_sizes({400.0f, 300.0f}, {400.0f, 300.0f});
const DeviceMapping at_125 = DeviceMapping::from_sizes({400.0f, 300.0f}, {500.0f, 375.0f});
plot::gtk4::ZoomAnchor zoom;
// The orphan update, before any begin.
const plot::input::GestureEvent orphan = zoom.update(2.0f, {10.0f, 10.0f}, at_1);
PLOT_CHECK_NEAR(orphan.scale, 1.0, 1e-9);
PLOT_CHECK_NEAR(orphan.dx, 0.0, 1e-9);
PLOT_CHECK_NEAR(orphan.dy, 0.0, 1e-9);
zoom.begin(1.0f, {100.0f, 100.0f});
// Four logical pixels of drift across a 1.0 -> 1.25 factor change: 4 * 1.25 =
// 5 device pixels, and the anchor for the next frame is the new logical point.
const plot::input::GestureEvent rescaled = zoom.update(1.0f, {104.0f, 100.0f}, at_125);
PLOT_CHECK_NEAR(rescaled.scale, 1.0, 1e-6);
PLOT_CHECK_NEAR(rescaled.dx, 5.0, 1e-6);
PLOT_CHECK_NEAR(rescaled.x, 104.0 * 1.25, 1e-6);
// The gesture ends; a straggler scale-changed does nothing.
zoom.end();
PLOT_CHECK(!zoom.active);
const plot::input::GestureEvent straggler = zoom.update(2.0f, {120.0f, 100.0f}, at_125);
PLOT_CHECK_NEAR(straggler.scale, 1.0, 1e-9);
PLOT_CHECK_NEAR(straggler.dx, 0.0, 1e-9);
}
// --- the keyboard -----------------------------------------------------------
// The whole key translation, now that it names arrows and Escape too. The
// function keys are matched on the keyval, which for them is layout- and
// level-independent; the printable keys on the Unicode the keyval decodes to,
// which is what makes `+` and shift+`=` (and the keypad's plus) one key to the
// controller. Anything else is `0` -- the caller hands the event back to GTK.
PLOT_TEST(gtk_keys_map_by_keyval_first_and_unicode_second) {
PLOT_CHECK(key_from(0xFF1Bu, 0u) == plot::input::kKeyEscape); // GDK_KEY_Escape
PLOT_CHECK(key_from(0xFF51u, 0u) == plot::input::kKeyLeft); // GDK_KEY_Left
PLOT_CHECK(key_from(0xFF53u, 0u) == plot::input::kKeyRight); // GDK_KEY_Right
PLOT_CHECK(key_from(0xFF52u, 0u) == plot::input::kKeyUp);
PLOT_CHECK(key_from(0xFF54u, 0u) == plot::input::kKeyDown);
// Printable keys, by code point -- the keyval is irrelevant to this branch.
PLOT_CHECK(key_from(0x002Bu, static_cast<u32>('+')) == plot::input::kKeyZoomIn);
PLOT_CHECK(key_from(0x003Du, static_cast<u32>('=')) == plot::input::kKeyZoomIn);
PLOT_CHECK(key_from(0x002Du, static_cast<u32>('-')) == plot::input::kKeyZoomOut);
PLOT_CHECK(key_from(0x005Fu, static_cast<u32>('_')) == plot::input::kKeyZoomOut);
PLOT_CHECK(key_from(0x0066u, static_cast<u32>('f')) == plot::input::kKeyFit);
PLOT_CHECK(key_from(0x0046u, static_cast<u32>('F')) == plot::input::kKeyFit);
// The keyval wins when both could match: the order is the seam's contract, not
// an accident of GDK's tables.
PLOT_CHECK(key_from(0xFF51u, static_cast<u32>('x')) == plot::input::kKeyLeft);
// And nothing a plot has no gesture for maps to a key: an unmapped keyval with
// an unmapped code point is refused, and so is a bare zero.
PLOT_CHECK(key_from(0x0078u, static_cast<u32>('x')) == 0);
PLOT_CHECK(key_from(0u, 0u) == 0);
}
+118
View File
@@ -352,7 +352,17 @@ PLOT_TEST(imgui_widget_invalid_position_does_not_move_the_range) {
// and not from the sentinel. This is asserted with the drag alive on purpose:
// nothing can move a range once the button is up, so the same check after the
// release could not fail and would prove nothing.
//
// The quiet frame before the second press is aged past the double-click window on
// purpose: this press lands in the same spot within the window of the first one,
// and the widget stamps presses with ImGui's clock, so without the age the two
// presses *are* a double-click -- the controller re-fits and begins no drag, which
// is the gesture the next test pins, not the sentinel behaviour this one is here
// for.
ImGuiIO& io = ImGui::GetIO();
io.DeltaTime = 0.4f;
f.frame(ImVec2(200.0f, 200.0f), false);
io.DeltaTime = 1.0f / 60.0f;
f.frame(ImVec2(200.0f, 200.0f), true);
const plot::Range second_press = f.x_range();
f.frame(ImVec2(-kMaxFloat, -kMaxFloat), true);
@@ -492,3 +502,111 @@ PLOT_TEST(imgui_widget_states_the_display_scale_to_the_renderer) {
PLOT_CHECK_NEAR(f.renderer.axis_font_px(), cell_1x, 1e-3);
PLOT_CHECK_NEAR(f.plot.layout().y_axis_width, strip_1x, 1e-3);
}
// --- keys -------------------------------------------------------------------
// A held key repeats. This is the adapter's own policy decision, not ImGui's
// default: ImGui edge-detects a press once, and `IsKeyPressed` only re-fires
// while the key is held when it is asked with repeat = true -- which the widget
// now does for every key it maps, because GLFW/Qt/GTK each deliver the OS's
// auto-repeat as extra presses and a held `+` or arrow must behave the same here
// (docs/embedding.md's shared policy). Held with `repeat = false`, the zoom
// happens exactly once; with `repeat = true`, once per repeat ImGui's timer
// fires, so the span keeps growing for as long as the key is down.
PLOT_TEST(imgui_widget_repeats_a_held_key) {
Fixture f;
PLOT_CHECK(f.init());
ImGuiIO& io = ImGui::GetIO();
// A repeat clock a test can afford: first repeat one frame after the press,
// then one per frame, each frame a twentieth of a second of ImGui time.
io.DeltaTime = 0.05f;
io.KeyRepeatDelay = 0.05f;
io.KeyRepeatRate = 0.05f;
// Hover the plot (the keys are gated on hover) and press `-` once.
f.frame(ImVec2(200.0f, 200.0f), false);
io.AddKeyEvent(ImGuiKey_Minus, true);
f.frame(ImVec2(200.0f, 200.0f), false);
const double span_after_press = f.x_range().span();
PLOT_CHECK(span_after_press > 20.0); // one zoom-out detent on the {0, 20} fixture
// Hold it for half a second of ImGui time. Each repeat zooms again, so the
// span ends up several detents past the single press -- a single-fire widget
// would leave it exactly where the press left it.
for (int i = 0; i < 10; ++i) f.frame(ImVec2(200.0f, 200.0f), false);
PLOT_CHECK(f.x_range().span() > span_after_press * 1.5);
io.AddKeyEvent(ImGuiKey_Minus, false);
f.frame(ImVec2(200.0f, 200.0f), false);
}
// The arrows pan and Escape cancels a drag, mapped to the controller's
// host-translated constants (`kKeyLeft`, `kKeyEscape`): one press of Left moves
// every x axis 10% of its span, and Escape hands the axes back the ranges they
// had when the drag began.
PLOT_TEST(imgui_widget_maps_arrows_and_escape) {
Fixture f;
PLOT_CHECK(f.init());
ImGuiIO& io = ImGui::GetIO();
f.frame(ImVec2(200.0f, 200.0f), false);
const double span = f.x_range().span();
const double min_before = f.x_range().min;
// One Left press: every x axis slides by 10% of its span, and the span itself
// is untouched (a pan, not a zoom).
io.AddKeyEvent(ImGuiKey_LeftArrow, true);
f.frame(ImVec2(200.0f, 200.0f), false);
io.AddKeyEvent(ImGuiKey_LeftArrow, false);
f.frame(ImVec2(200.0f, 200.0f), false);
PLOT_CHECK_NEAR(f.x_range().span(), span, 1e-9);
PLOT_CHECK(std::abs(f.x_range().min - min_before) > 0.05 * span);
PLOT_CHECK(std::abs(f.x_range().min - min_before) < 0.15 * span);
// A drag that has panned, then Escape while the button is still down: the
// ranges go back to where they were when the drag began, and the drag is over.
const double before_drag = f.x_range().min;
f.frame(ImVec2(200.0f, 200.0f), true);
PLOT_CHECK(f.panning());
f.frame(ImVec2(260.0f, 200.0f), true);
PLOT_CHECK(std::abs(f.x_range().min - before_drag) > 1e-6); // the pan moved it
io.AddKeyEvent(ImGuiKey_Escape, true);
f.frame(ImVec2(260.0f, 200.0f), true);
io.AddKeyEvent(ImGuiKey_Escape, false);
PLOT_CHECK(std::abs(f.x_range().min - before_drag) < 1e-9);
f.frame(ImVec2(260.0f, 200.0f), false); // the release ends nothing: already cancelled
PLOT_CHECK(std::abs(f.x_range().min - before_drag) < 1e-9);
}
// The widget stamps every pointer event with ImGui's clock, which is what makes
// the double-click -- two presses of the same button within 0.35 s and 4 px --
// exist on this host: re-fitting every axis is the controller's gesture, and the
// widget is the only supplier of the times it differences. Two quick presses on
// the fixture (whose x range was pinned to {0, 20}) must hand the axes back to
// the data -- the fit's own bounds -- and the second press must not begin a drag.
PLOT_TEST(imgui_widget_stamps_presses_so_a_double_click_can_reset) {
Fixture f;
PLOT_CHECK(f.init());
// One quiet hovered frame first. The very first frame's events are not the
// widget's yet: ImGui has not decided the hover (and its input queue trickles),
// so a press queued on frame 1 is swallowed by the hover gate and the controller
// never sees it. Every press this test means to deliver needs the hover live,
// which is the same reason the other tests here warm up with a hovered frame.
f.frame(ImVec2(200.0f, 200.0f), false);
f.frame(ImVec2(200.0f, 200.0f), true); // press 1
f.frame(ImVec2(200.0f, 200.0f), false); // release
// Press 2 in the same spot one frame later: ~1/60 s apart with the default
// DeltaTime, well inside the 0.35 s and 4 px window.
f.frame(ImVec2(200.0f, 200.0f), true);
const double fitted = f.x_range().span();
const double fitted_min = f.x_range().min;
PLOT_CHECK(fitted > 40.0); // the data's own span, not the fixture's {0, 20}
f.frame(ImVec2(220.0f, 200.0f), true); // a move with the second press's button
// ... begins no drag: the fit's values stand exactly.
PLOT_CHECK_NEAR(f.x_range().span(), fitted, 1e-9);
PLOT_CHECK_NEAR(f.x_range().min, fitted_min, 1e-9);
f.frame(ImVec2(220.0f, 200.0f), false);
}
+39
View File
@@ -3,6 +3,7 @@
#include "harness.hpp"
#include <plot/plot/measure.hpp>
#include <plot/plot/plot.hpp>
#include <plot/data/array_series.hpp>
#include <plot/data/detail/cover_count.hpp>
#include <algorithm>
@@ -65,3 +66,41 @@ PLOT_TEST(empty_and_single_sample_intervals_are_valid_false) {
PLOT_CHECK(!plot::Measure::evaluate(s, 0, 0).valid);
PLOT_CHECK(!plot::Measure::evaluate(s, 1, 1).valid);
}
PLOT_TEST(a_measure_over_a_ruler_reports_invalid) {
// R8: a measurement is read from two *x* coordinates, and a horizontal ruler has
// no x -- it is the dead field of its orientation. Evaluating the pair anyway
// silently measured from x=0; now the id `add_measure` returns is real but
// reports an invalid measurement, and names no band to shade either.
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
std::vector<f32> ys(100);
for (u64 i = 0; i < 100u; ++i) ys[static_cast<std::size_t>(i)] = f32(i) * 0.01f;
plot::data::ArraySeries series(std::move(ys), 0.0, 1.0);
p.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
const u32 ra = p.add_h_ruler(0.5, plot::Color::hex(0xFFE080));
const u32 rb = p.add_h_ruler(-0.5, plot::Color::hex(0xFFE080));
const u32 rulers = p.add_measure(ra, rb);
PLOT_CHECK(!p.measurement(rulers).valid);
PLOT_CHECK(!p.measure_span(rulers).valid); // and no band between dead x's to shade
// A ruler paired with a real marker is refused for the same reason: one half of
// the pair has no x to measure from.
const u32 ca = p.add_cursor(10.0, plot::Color::hex(0xFFFFFF));
const u32 mixed = p.add_measure(ca, ra);
PLOT_CHECK(!p.measurement(mixed).valid);
PLOT_CHECK(!p.measure_span(mixed).valid);
// Sanity: a marker pair over the same series still measures. The extremes are
// the stored f32 samples' values, so the expectation is derived from the same
// arithmetic the store used, not from the decimal literals.
const u32 cb = p.add_cursor(20.0, plot::Color::hex(0xFFFFFF));
const u32 markers = p.add_measure(ca, cb);
const plot::Measurement& m = p.measurement(markers);
PLOT_CHECK(m.valid);
PLOT_CHECK_NEAR(m.ymin, static_cast<f64>(f32(10) * 0.01f), 1e-12);
PLOT_CHECK_NEAR(m.ymax, static_cast<f64>(f32(20) * 0.01f), 1e-12);
}
+119
View File
@@ -6,6 +6,7 @@
#include <plot/data/detail/merge_count.hpp>
#include <plot/data/minmax_pyramid.hpp>
#include <algorithm>
#include <limits>
#include <random>
using plot::data::kBucket;
@@ -290,3 +291,121 @@ PLOT_TEST(pyramid_append_cost_is_amortised_constant) {
const u64 slid = plot::data::detail::merge_count().load(std::memory_order_relaxed);
PLOT_CHECK(slid <= kAppends / 16);
}
// --- B7: non-finite samples are excluded from every aggregate ----------------
PLOT_TEST(pyramid_excludes_non_finite_samples_from_aggregates) {
// NaN at the first, middle and last positions of the window, and a whole
// all-NaN bucket in the middle: the aggregates never see them.
MinMaxPyramid p;
p.append(std::nanf("")); // first
for (int i = 0; i < 63; ++i) p.append(1.0f);
for (int i = 0; i < kBucket; ++i) p.append(std::nanf("")); // bucket 1: all NaN
for (int i = 0; i < 63; ++i) p.append(2.0f);
p.append(std::nanf("")); // last
PLOT_CHECK(p.size() == 64 + kBucket + 64);
// Bucket 0: 63 ones; bucket 1: empty; bucket 2: 63 twos (the final NaN is
// the pending bucket's only sample, never a whole bucket).
PLOT_CHECK(p.bucket_count(0, 0) == 63);
PLOT_CHECK(p.bucket_count(0, 1) == 0);
PLOT_CHECK(p.bucket_count(0, 2) == 63);
const Range r0 = p.reduce_buckets(0, kBucket);
PLOT_CHECK_NEAR(r0.min, 1.0, 1e-6);
PLOT_CHECK_NEAR(r0.max, 1.0, 1e-6);
PLOT_CHECK(p.reduce_buckets(kBucket, 2 * kBucket).span() == 0.0); // the NaN bucket
PLOT_CHECK(p.sum_buckets(kBucket, 2 * kBucket) == 0.0);
PLOT_CHECK(p.sumsq_buckets(kBucket, 2 * kBucket) == 0.0);
// The merge above an empty child keeps the other child's extremes: level 1
// bucket 0 covers buckets 0 and 1, and must report the ones, not a 0.
PLOT_CHECK(p.level_size(1) >= 1);
const MinMax top = p.bucket(1, p.level_base(1));
PLOT_CHECK_NEAR(top.min, 1.0, 1e-6);
PLOT_CHECK_NEAR(top.max, 1.0, 1e-6);
PLOT_CHECK(p.bucket_count(1, p.level_base(1)) == 63);
// A range spanning the finite buckets on both sides of the empty one.
const Range all = p.reduce_buckets(0, 3 * kBucket);
PLOT_CHECK_NEAR(all.min, 1.0, 1e-6);
PLOT_CHECK_NEAR(all.max, 2.0, 1e-6);
PLOT_CHECK_NEAR(p.sum_buckets(0, 3 * kBucket), 63.0 + 126.0, 1e-6);
// bucket_sum / bucket_count is the mean the Mean gather mode folds.
PLOT_CHECK_NEAR(p.bucket_sum(0, 0) / f64(p.bucket_count(0, 0)), 1.0, 1e-9);
}
// --- R3: the deepest level is capped where the span shift still fits ---------
PLOT_TEST(pyramid_level_cap_keeps_the_span_shift_defined) {
// Level 57 is the last whose bucket span (kBucket << 57 == 2^63) fits a u64;
// past it the shift would be undefined, so the constant pins the cap and
// reserve() never materialises more levels than it.
PLOT_CHECK((u64{kBucket} << plot::data::kMaxPyramidLevel) == (u64{1} << 63));
MinMaxPyramid p;
p.reserve(~u64{0}); // a window no sample count can reach: still just the cap
p.append(1.0f);
PLOT_CHECK(p.level_count() >= 1);
}
PLOT_TEST(amend_folds_a_backfilled_sample_into_every_covering_level) {
// A hole is appended as NaN -- which the pyramid already stores as an empty aggregate --
// and amended later when the packet that owned it finally arrives. The amendment has to
// reach every level that covers the index, because the renderer reads whichever level the
// zoom picked: repairing level 0 alone would make a trace whole when zoomed in and still
// broken when zoomed out.
MinMaxPyramid p;
p.reserve(8 * kBucket);
for (u64 i = 0; i < 8 * kBucket; ++i) {
// One NaN, at an index comfortably inside the first bucket of every level.
p.append(i == 5 ? std::numeric_limits<f32>::quiet_NaN() : 1.0f);
}
// Before: every covering bucket aggregates kBucket-1 ones and knows nothing of index 5.
PLOT_CHECK(p.bucket_count(0, 0) == kBucket - 1);
PLOT_CHECK_NEAR(p.bucket(0, 0).max, 1.0);
p.amend(5, 7.0f);
// Level 0 gained the sample.
PLOT_CHECK(p.bucket_count(0, 0) == kBucket);
PLOT_CHECK_NEAR(p.bucket(0, 0).max, 7.0);
PLOT_CHECK_NEAR(p.bucket_sum(0, 0), f64(kBucket - 1) + 7.0);
PLOT_CHECK_NEAR(p.bucket_sumsq(0, 0), f64(kBucket - 1) + 49.0);
// And so did every coarser level that covers it.
for (u32 l = 1; l < p.level_count(); ++l) {
if (p.level_size(l) == 0) continue;
const u64 span = u64{kBucket} << l;
const u64 b = 5 / span;
if (b < p.level_base(l) || b - p.level_base(l) >= p.level_size(l)) continue;
PLOT_CHECK_NEAR(p.bucket(l, b).max, 7.0);
PLOT_CHECK(p.bucket_count(l, b) == span);
}
}
PLOT_TEST(amend_makes_an_all_nan_bucket_present_again) {
// A bucket whose every sample was non-finite is stored with count 0, which every reader
// treats as absent. One amendment has to make it present -- min and max become the value
// rather than widening from a meaningless zero, which is the case a plain fold gets wrong.
MinMaxPyramid p;
p.reserve(4 * kBucket);
const f32 nan = std::numeric_limits<f32>::quiet_NaN();
for (u64 i = 0; i < 2 * kBucket; ++i) p.append(i < kBucket ? nan : 1.0f);
PLOT_CHECK(p.bucket_count(0, 0) == 0);
p.amend(3, -2.0f);
PLOT_CHECK(p.bucket_count(0, 0) == 1);
PLOT_CHECK_NEAR(p.bucket(0, 0).min, -2.0);
PLOT_CHECK_NEAR(p.bucket(0, 0).max, -2.0);
}
PLOT_TEST(amend_outside_the_window_is_a_no_op) {
// An index the window has already dropped, or one not yet appended, has nowhere to go.
// Silently ignoring it is what lets the caller amend without first proving the ring still
// holds the slot -- a race it cannot win, because the window slides on the other thread.
MinMaxPyramid p;
p.reserve(4 * kBucket);
for (u64 i = 0; i < 4 * kBucket; ++i) p.append(1.0f);
p.drop_before(2 * kBucket);
const u64 before = p.bucket_count(0, 2);
p.amend(5, 99.0f); // dropped
p.amend(100 * kBucket, 99.0f); // never appended
PLOT_CHECK(p.bucket_count(0, 2) == before);
PLOT_CHECK_NEAR(p.bucket(0, 2).max, 1.0);
}
+947 -30
View File
File diff suppressed because it is too large. Load diff
+41 -14
View File
@@ -536,21 +536,21 @@ PLOT_TEST(text_batch_stages_one_quad_per_glyph) {
b.begin();
const plot::Vec2 end = b.add_text("12.5 ms", {0.0f, 0.0f}, 1.0f, f);
PLOT_CHECK(b.glyph_count() == 7);
PLOT_CHECK(b.data().size() == 42); // x,y,u,v,ax,ay per glyph
PLOT_CHECK(b.data().size() == 70); // x,y,u,v,ax,ay,r,g,b,a per glyph
PLOT_CHECK_NEAR(end.x, f.measure("12.5 ms"), 1e-6);
PLOT_CHECK_NEAR(end.y, 0.0, 1e-6);
PLOT_CHECK_NEAR(b.data()[0], 0.0, 1e-6); // first cell at the pen
PLOT_CHECK_NEAR(b.data()[1], 0.0, 1e-6);
PLOT_CHECK_NEAR(b.data()[6], f.glyph('1')->advance, 1e-6); // pen advanced per glyph
PLOT_CHECK_NEAR(b.data()[10], f.glyph('1')->advance, 1e-6); // pen advanced per glyph
// The staged cell's UV is the glyph's own atlas rect (see `Glyph`), and the
// basis of an unrotated run is the screen's +x. '.' is the string's third
// glyph, so its instance is the third: floats 12..17.
// glyph, so its instance is the third: floats 20..29.
const plot::render::Glyph* dot = f.glyph(static_cast<plot::u32>('.'));
PLOT_CHECK(dot != nullptr);
PLOT_CHECK_NEAR(b.data()[14], dot->u0, 1e-6);
PLOT_CHECK_NEAR(b.data()[15], dot->v0, 1e-6);
PLOT_CHECK_NEAR(b.data()[16], 1.0, 1e-6); // ax
PLOT_CHECK_NEAR(b.data()[17], 0.0, 1e-6); // ay
PLOT_CHECK_NEAR(b.data()[22], dot->u0, 1e-6);
PLOT_CHECK_NEAR(b.data()[23], dot->v0, 1e-6);
PLOT_CHECK_NEAR(b.data()[24], 1.0, 1e-6); // ax
PLOT_CHECK_NEAR(b.data()[25], 0.0, 1e-6); // ay
// Every glyph a batch stages is read through one cell, and the batch publishes
// it for the draw (`u_glyph_uv`).
PLOT_CHECK_NEAR(b.glyph_cell_uv().x, dot->u1 - dot->u0, 1e-6);
@@ -571,7 +571,7 @@ PLOT_TEST(text_batch_stages_one_quad_per_glyph) {
PLOT_CHECK(b.glyph_count() == 2);
PLOT_CHECK_NEAR(scaled.x, 10.0f + 2.0f * f.measure("AW"), 1e-6);
PLOT_CHECK_NEAR(scaled.y, 4.0, 1e-6);
PLOT_CHECK_NEAR(b.data()[6], 10.0f + 2.0f * f.glyph('A')->advance, 1e-6); // second cell's pen
PLOT_CHECK_NEAR(b.data()[10], 10.0f + 2.0f * f.glyph('A')->advance, 1e-6); // second cell's pen
}
// The pen follows the basis, not the screen axes. A run staged with `axis` has to
@@ -599,13 +599,13 @@ PLOT_TEST(text_batch_advances_along_its_basis) {
PLOT_CHECK_NEAR(up.x, pen.x, 1e-6);
PLOT_CHECK_NEAR(up.y, pen.y - width, 1e-4);
for (plot::u32 i = 0; i < b.glyph_count(); ++i) {
PLOT_CHECK_NEAR(b.data()[6u * i + 4u], 0.0, 1e-6);
PLOT_CHECK_NEAR(b.data()[6u * i + 5u], -1.0, 1e-6);
PLOT_CHECK_NEAR(b.data()[10u * i + 4u], 0.0, 1e-6);
PLOT_CHECK_NEAR(b.data()[10u * i + 5u], -1.0, 1e-6);
}
// The second cell is one advance further up than the pen, which is the same rule
// the unrotated case follows in x.
PLOT_CHECK_NEAR(b.data()[6], pen.x, 1e-6);
PLOT_CHECK_NEAR(b.data()[7], pen.y - f.glyph('1')->advance, 1e-6);
PLOT_CHECK_NEAR(b.data()[10], pen.x, 1e-6);
PLOT_CHECK_NEAR(b.data()[11], pen.y - f.glyph('1')->advance, 1e-6);
// A diagonal direction is normalised, so a caller can hand over any direction
// and the basis the shader receives is still unit.
@@ -616,6 +616,33 @@ PLOT_TEST(text_batch_advances_along_its_basis) {
PLOT_CHECK_NEAR(b.data()[5], 0.8, 1e-5);
}
// The tint is per instance (`a_colour`, the last four floats of every glyph),
// not a uniform: one batch can carry runs of several colours, which is what the
// crosshair readout needs (values in each row's trace colour, labels in the axis
// text colour, one draw call). The staged colour is exactly what the caller
// passed, straight alpha, on every glyph of the run and nothing else.
PLOT_TEST(text_batch_stages_its_colour_per_instance) {
plot::render::EmbeddedMonoFont f;
plot::render::TextBatch b;
const plot::Color tint{0.25f, 0.5f, 0.75f, 0.9f};
b.begin();
b.add_text("AB", {0.0f, 0.0f}, 1.0f, f, {1.0f, 0.0f}, tint);
PLOT_CHECK(b.glyph_count() == 2);
for (plot::u32 i = 0; i < 2; ++i) {
PLOT_CHECK_NEAR(b.data()[10u * i + 6u], tint.r, 1e-6);
PLOT_CHECK_NEAR(b.data()[10u * i + 7u], tint.g, 1e-6);
PLOT_CHECK_NEAR(b.data()[10u * i + 8u], tint.b, 1e-6);
PLOT_CHECK_NEAR(b.data()[10u * i + 9u], tint.a, 1e-6);
}
// A second run in another colour keeps its own tint per instance -- the old
// uniform-tint pipeline could not express this in one batch.
b.add_text("CD", {20.0f, 0.0f}, 1.0f, f, {1.0f, 0.0f},
plot::Color{1.0f, 1.0f, 1.0f, 1.0f});
PLOT_CHECK(b.glyph_count() == 4);
PLOT_CHECK_NEAR(b.data()[26u], 1.0, 1e-6); // 'C' is white where 'A' was not
PLOT_CHECK_NEAR(b.data()[6u], 0.25, 1e-6); // ... and 'A' still carries its own
}
/// A backend whose atlas is *not* a fixed-cell grid: it declares the cell its
/// pipeline needs and then publishes one glyph trimmed to its ink, which is what
/// a naive TrueType packing produces (`stbtt_GetCodepointBitmap` hands back an ink
@@ -844,8 +871,8 @@ PLOT_TEST(add_text_skips_a_codepoint_the_backend_cannot_draw) {
// glyph of its own, which the second instance's atlas rect says outright.
PLOT_CHECK_NEAR(batch.data()[0], pen.x, 1e-6);
PLOT_CHECK_NEAR(batch.data()[2], a->u0, 1e-6);
PLOT_CHECK_NEAR(batch.data()[6], pen.x + a->advance, 1e-6);
PLOT_CHECK_NEAR(batch.data()[8], b->u0, 1e-6);
PLOT_CHECK_NEAR(batch.data()[10], pen.x + a->advance, 1e-6);
PLOT_CHECK_NEAR(batch.data()[12], b->u0, 1e-6);
// A run of nothing but undrawable codepoints stages nothing at all and leaves
// the pen where it was, rather than drawing at the pen.
+90
View File
@@ -57,3 +57,93 @@ PLOT_TEST(pointer_anchor_reports_no_movement_before_its_first_sample) {
PLOT_CHECK_NEAR(delta.x, 0.0, 1e-9);
PLOT_CHECK_NEAR(delta.y, 0.0, 1e-9);
}
// --- the pinch --------------------------------------------------------------
// Qt reports a pinch as cumulative numbers -- `totalScaleFactor()` since the
// gesture began, `centerPoint()` where the fingers are now -- while the
// controller's `GestureEvent` wants the increments: this frame's zoom factor
// (1.0 = no change) and the focal point's movement since the previous event.
// `PinchTracker` is the fold, and the ratios it produces are what a pinch feels
// like through: a cumulative 1.0 -> 1.25 -> 1.5 spread is a 1.25 frame followed
// by a 1.2 frame, never a 1.25 then a 1.5 (which would zoom twice as far as the
// fingers did).
PLOT_TEST(pinch_tracker_folds_cumulative_scale_into_incremental_zoom) {
plot::qt::PinchTracker pinch;
// The begin anchors the fold and zooms nothing: a pinch that just started has
// moved no scale yet, so the first event the controller could see is 1.0.
pinch.begin(1.0f, {200.0f, 150.0f});
PLOT_CHECK(pinch.active);
const plot::input::GestureEvent started = pinch.update(1.0f, {200.0f, 150.0f}, 1.0);
PLOT_CHECK_NEAR(started.scale, 1.0, 1e-6);
PLOT_CHECK_NEAR(started.dx, 0.0, 1e-6);
PLOT_CHECK_NEAR(started.dy, 0.0, 1e-6);
// Fingers spread to 1.25 cumulative, focal steady: one 1.25 zoom about the
// focal point, and no pan.
const plot::input::GestureEvent spread = pinch.update(1.25f, {200.0f, 150.0f}, 1.0);
PLOT_CHECK_NEAR(spread.scale, 1.25, 1e-6);
PLOT_CHECK_NEAR(spread.dx, 0.0, 1e-6);
PLOT_CHECK_NEAR(spread.dy, 0.0, 1e-6);
// Spread on to 1.5: the *increment* is 1.5 / 1.25, not 1.5 again.
const plot::input::GestureEvent more = pinch.update(1.5f, {200.0f, 150.0f}, 1.0);
PLOT_CHECK_NEAR(more.scale, 1.2, 1e-6);
// And a pinch in (0.75 of the cumulative): an incremental shrink.
const plot::input::GestureEvent pinch_in = pinch.update(0.75f, {200.0f, 150.0f}, 1.0);
PLOT_CHECK_NEAR(pinch_in.scale, 0.5, 1e-6);
}
// The focal point's movement is the two-finger pan, and it is mapped through the
// ratio in force *now* from an anchor kept in **logical** pixels -- the same
// convention as PointerAnchor above, because the same defect applies: a pinch
// that crosses onto a display with another scale factor must not read the ratio
// change as a focal jump (which the controller would apply as a pan).
PLOT_TEST(pinch_tracker_maps_focal_movement_through_the_current_dpr) {
plot::qt::PinchTracker pinch;
pinch.begin(1.0f, {200.0f, 150.0f});
// A plain pan at 1.0: ten logical pixels right, no zoom.
const plot::input::GestureEvent moved = pinch.update(1.0f, {210.0f, 150.0f}, 1.0);
PLOT_CHECK_NEAR(moved.scale, 1.0, 1e-6);
PLOT_CHECK_NEAR(moved.dx, 10.0, 1e-6);
PLOT_CHECK_NEAR(moved.dy, 0.0, 1e-6);
PLOT_CHECK_NEAR(moved.x, 210.0, 1e-6); // the focal point the zoom anchors on
// Mid-pinch the window lands on the 1.25 display. The fingers move 4 more
// logical pixels: the movement is 4 * 1.25 = 5 device pixels, and the anchor
// the next frame is measured from is the new logical point -- not the old one
// rescaled, which would read the ratio change itself as a jump.
const plot::input::GestureEvent rescaled = pinch.update(1.0f, {214.0f, 150.0f}, 1.25);
PLOT_CHECK_NEAR(rescaled.scale, 1.0, 1e-6);
PLOT_CHECK_NEAR(rescaled.dx, 5.0, 1e-6);
PLOT_CHECK_NEAR(rescaled.dy, 0.0, 1e-6);
PLOT_CHECK_NEAR(rescaled.x, 214.0 * 1.25, 1e-6);
PLOT_CHECK_NEAR(rescaled.y, 150.0 * 1.25, 1e-6);
// A diagonal movement, both axes mapped.
const plot::input::GestureEvent diagonal = pinch.update(1.0f, {214.0f, 154.0f}, 1.25);
PLOT_CHECK_NEAR(diagonal.dx, 0.0, 1e-6);
PLOT_CHECK_NEAR(diagonal.dy, 5.0, 1e-6);
}
// An update outside a gesture -- before any begin, or after the end/cancel --
// zooms and moves nothing: a host that missed the start (or a straggler event
// Qt delivers out of order) must not zoom from a made-up anchor.
PLOT_TEST(pinch_tracker_ignores_updates_outside_a_gesture) {
plot::qt::PinchTracker pinch;
const plot::input::GestureEvent orphan = pinch.update(2.0f, {10.0f, 10.0f}, 1.0);
PLOT_CHECK_NEAR(orphan.scale, 1.0, 1e-9);
PLOT_CHECK_NEAR(orphan.dx, 0.0, 1e-9);
PLOT_CHECK_NEAR(orphan.dy, 0.0, 1e-9);
pinch.begin(1.0f, {0.0f, 0.0f});
pinch.end();
PLOT_CHECK(!pinch.active);
const plot::input::GestureEvent straggler = pinch.update(2.0f, {10.0f, 10.0f}, 1.0);
PLOT_CHECK_NEAR(straggler.scale, 1.0, 1e-9);
PLOT_CHECK_NEAR(straggler.dx, 0.0, 1e-9);
PLOT_CHECK_NEAR(straggler.dy, 0.0, 1e-9);
}
File diff suppressed because it is too large. Load diff
+76
View File
@@ -20,3 +20,79 @@ PLOT_TEST(ring_clear_resets) {
PLOT_CHECK(r.size() == 1);
PLOT_CHECK_NEAR(r[0], 42.0);
}
PLOT_TEST(ring_clear_to_resumes_the_absolute_count) {
// A recompute: a derived ring is emptied and refilled from the absolute origin its source
// retains, so it has to come back numbered as the source is. `clear()` restarts at 0, which
// leaves a refilled ring reporting a produced() a whole window behind an identically fed one.
plot::data::RingBuffer<f32> src(8);
plot::data::RingBuffer<f32> derived(8);
for (int i = 0; i < 20; ++i) {
src.push(f32(i));
derived.push(f32(i) * 2.0f);
}
PLOT_CHECK(src.base() == 12);
derived.clear_to(src.base());
for (u64 i = 0; i < src.size(); ++i) derived.push(src[i] * 3.0f);
// Numbered as the source: same produced(), same base(), and the retained windows pair up.
PLOT_CHECK(derived.produced() == src.produced());
PLOT_CHECK(derived.base() == src.base());
PLOT_CHECK(derived.size() == src.size());
PLOT_CHECK_NEAR(derived[0], 36.0); // 12 * 3
PLOT_CHECK_NEAR(derived[7], 57.0); // 19 * 3
// And the absolute reader agrees with the windowed one, which is what a snapshot reads.
f32 at = 0.0f;
derived.copy_from(19, 1, &at);
PLOT_CHECK_NEAR(at, 57.0);
}
PLOT_TEST(ring_clear_to_zero_is_clear) {
plot::data::RingBuffer<f32> r(8);
for (int i = 0; i < 5; ++i) r.push(f32(i));
r.clear_to(0);
PLOT_CHECK(r.size() == 0);
PLOT_CHECK(r.produced() == 0);
r.push(42.0f);
PLOT_CHECK_NEAR(r[0], 42.0);
}
PLOT_TEST(ring_capacity_rounds_up_to_a_power_of_two) {
// The capacity is rounded up so slot addressing stays a mask, and capacity()
// reports the rounded value -- the one callers must size for (R4).
PLOT_CHECK(plot::data::RingBuffer<f32>(100).capacity() == 128);
PLOT_CHECK(plot::data::RingBuffer<f32>(1).capacity() == 1);
PLOT_CHECK(plot::data::RingBuffer<f32>(0).capacity() == 1);
PLOT_CHECK(plot::data::RingBuffer<f32>(64).capacity() == 64); // already a power
plot::data::RingBuffer<f32> r(100);
for (int i = 0; i < 1000; ++i) r.push(f32(i));
PLOT_CHECK(r.size() == 128); // size() saturates at the rounded capacity
PLOT_CHECK_NEAR(r[0], 872.0); // oldest retained
PLOT_CHECK_NEAR(r[127], 999.0); // newest
}
PLOT_TEST(ring_amend_rewrites_a_retained_slot) {
// Backfill: a slot pushed as a placeholder is rewritten once the data that belongs there
// arrives. The index is absolute, because that is the only coordinate a late packet can
// compute -- a retained-window index would have moved by the time it got here.
plot::data::RingBuffer<f32> r(8);
for (int i = 0; i < 6; ++i) r.push(f32(i));
PLOT_CHECK(r.amend(3, 42.0f));
PLOT_CHECK_NEAR(r[3], 42.0);
PLOT_CHECK(r.produced() == 6); // an amend is not a push
}
PLOT_TEST(ring_amend_refuses_an_index_the_ring_no_longer_holds) {
// The window slides on the producer, so a late amend may arrive after its slot was
// overwritten. Writing it anyway would corrupt whatever sample now owns that slot, so the
// bounds test is the whole safety of the operation and the caller reads the result.
plot::data::RingBuffer<f32> r(8);
for (int i = 0; i < 20; ++i) r.push(f32(i));
PLOT_CHECK(r.base() == 12);
PLOT_CHECK(!r.amend(3, 42.0f)); // aged out
PLOT_CHECK(!r.amend(20, 42.0f)); // never produced
PLOT_CHECK_NEAR(r[0], 12.0); // and nothing moved
PLOT_CHECK_NEAR(r[7], 19.0);
}
+475 -45
View File
@@ -24,11 +24,12 @@ PLOT_TEST(array_series_bounds_and_gather) {
PLOT_CHECK_NEAR(s.x_bounds().max, 11.5, 1e-9);
PLOT_CHECK_NEAR(s.y_bounds().min, -2.0, 1e-6);
std::vector<f32> out;
s.gather(0, 0, 4, out);
s.gather(0, 0, 4, s.x0(), out);
PLOT_CHECK(out.size() == 8); // interleaved x,y
PLOT_CHECK_NEAR(out[0], 10.0, 1e-6);
// x is relative to x0(): sample 0 emits 0, not 10 (the relative-x contract).
PLOT_CHECK_NEAR(out[0], 0.0, 1e-6);
PLOT_CHECK_NEAR(out[1], 1.0, 1e-6);
PLOT_CHECK_NEAR(out[6], 11.5, 1e-6);
PLOT_CHECK_NEAR(out[6], 1.5, 1e-6); // sample 3: x_at(3) - x0() = 3 * 0.5
}
PLOT_TEST(stream_series_acquire_publishes_snapshot) {
@@ -103,11 +104,13 @@ PLOT_TEST(stream_series_exact_queries_survive_ring_wrap) {
PLOT_CHECK_NEAR(s.sumsq(0, n), sumsq, 1e-1);
std::vector<f32> out;
s.gather(0, 0, n, out); // level 0: raw samples
s.gather(0, 0, n, s.x0(), out); // level 0: raw samples
PLOT_CHECK(out.size() == 2 * n);
PLOT_CHECK_NEAR(out[0], s.x0(), 1e-6);
// x is relative to x0(): the oldest retained sample emits 0, the newest
// (n - 1) * dx, never the absolute data coordinates (relative-x contract).
PLOT_CHECK_NEAR(out[0], 0.0, 1e-6);
PLOT_CHECK_NEAR(out[1], f32(kFirstRetained % 97), 1e-6);
PLOT_CHECK_NEAR(out[2 * n - 2], s.x0() + f64(n - 1), 1e-6);
PLOT_CHECK_NEAR(out[2 * n - 2], f64(n - 1) * 1.0, 1e-6);
}
PLOT_TEST(stream_series_push_and_read_are_race_free) {
@@ -154,7 +157,7 @@ PLOT_TEST(stream_series_push_and_read_are_race_free) {
(void)s.sum(0, n);
(void)s.sumsq(0, n);
std::vector<f32> out;
s.gather(0, 0, n, out); // reads the oldest edge
s.gather(0, 0, n, s.x0(), out); // reads the oldest edge
if (out.size() != 2 * n) sizes_ok = false;
}
++reads;
@@ -250,38 +253,43 @@ PLOT_TEST(stream_series_sliding_window_queries_match_bruteforce) {
// level-1 gather: the envelope pairs of the retained buckets, bracketed by the
// two partial edge buckets as raw samples, so the geometry spans the range
// exactly. This is the mapping the renderer draws from.
// exactly. This is the mapping the renderer draws from. Bucket selection is
// ceil/floor on the range edges (B3): `first` is the first level-1 bucket
// wholly at or after window index 0, `last` one past the last wholly inside
// the window -- so every emitted bucket's samples lie inside the window.
std::vector<f32> out;
s.gather(1, 0, n, out);
const u64 first = std::max(base / span, lbase);
const u64 last = std::min((base + n - 1) / span + 1, lbase + lsize);
s.gather(1, 0, n, s.x0(), out);
const u64 first =
std::max(base / span + (base % span == 0 ? u64{0} : u64{1}), lbase); // ceil, then clamp
const u64 last = std::min((base + n) / span, lbase + lsize);
const u64 lead = first * span - base; // raw pairs before the first whole bucket
const u64 trail = last * span - base; // first window index after the last one
const u64 tail = trail < n ? n - trail : 0;
const u64 env = last - first;
PLOT_CHECK(out.size() == 2 * (lead + 2 * env + tail)); // each bucket is two pairs
for (u64 i = 0; i < lead; ++i) { // the partial leading bucket, raw
PLOT_CHECK_NEAR(out[2 * i], f32(f64(base + i) * 1e-6), 1e-9);
// x relative to x0(): window index i emits i * dx, not the absolute
// (base + i) * dx (relative-x contract).
PLOT_CHECK_NEAR(out[2 * i], f32(f64(i) * 1e-6), 1e-9);
PLOT_CHECK_NEAR(out[2 * i + 1], value(base + i), 1e-6);
}
for (u64 j = 0; j < env; ++j) {
// Each whole bucket contributes two pairs: (first sample, min) and (last
// sample, max). x is in data coordinates -- x0() is the retained window's
// first sample -- so window index i plots at (base + i) * dx.
// sample, max). x is relative to x0(): window index i plots at i * dx.
const u64 bi = first + j;
const std::array<f64, 4> want = brute(bi * span - base, (bi + 1) * span - base);
const u64 first_sample = bi * span - base;
const u64 last_sample = std::min((bi + 1) * span, base + n) - 1 - base;
const plot::usize lo = static_cast<plot::usize>(2 * (lead + 2 * j));
const plot::usize hi = lo + 2;
PLOT_CHECK_NEAR(out[lo], f32(f64(base + first_sample) * 1e-6), 1e-9);
PLOT_CHECK_NEAR(out[lo], f32(f64(first_sample) * 1e-6), 1e-9);
PLOT_CHECK_NEAR(out[lo + 1], want[0], 1e-6);
PLOT_CHECK_NEAR(out[hi], f32(f64(base + last_sample) * 1e-6), 1e-9);
PLOT_CHECK_NEAR(out[hi], f32(f64(last_sample) * 1e-6), 1e-9);
PLOT_CHECK_NEAR(out[hi + 1], want[1], 1e-6);
}
for (u64 i = 0; i < tail; ++i) { // the partial trailing bucket: the newest samples
const plot::usize k = static_cast<plot::usize>(2 * (lead + 2 * env + i));
PLOT_CHECK_NEAR(out[k], f32(f64(base + trail + i) * 1e-6), 1e-9);
PLOT_CHECK_NEAR(out[k], f32(f64(trail + i) * 1e-6), 1e-9);
PLOT_CHECK_NEAR(out[k + 1], value(base + trail + i), 1e-6);
}
}
@@ -318,29 +326,6 @@ PLOT_TEST(stream_series_steady_state_acquire_is_proportional_to_produced) {
PLOT_CHECK(big <= 2 * small + kBucket);
}
PLOT_TEST(stream_series_steady_state_acquire_is_fast) {
// Wall-clock backstop for the merge count above: rebuilding the 1M-sample
// window measured ~44 ms, so 128 such frames would need ~5.6 s. The sliding
// path does the same work in a few milliseconds; the bound is two orders of
// magnitude clear of both, so it is not a thin timing margin.
plot::data::StreamSeries s(1u << 20, 0.0, 1.0);
constexpr u64 kSaturate = 1u << 20;
for (u64 i = 0; i < kSaturate; ++i) s.push(f32(i % 977));
s.acquire();
constexpr u64 kFrames = 128;
constexpr u64 kFrame = 1u << 13;
const auto t0 = std::chrono::steady_clock::now();
for (u64 f = 0; f < kFrames; ++f) {
for (u64 k = 0; k < kFrame; ++k) s.push(f32((kSaturate + f * kFrame + k) % 977));
s.acquire();
}
const f64 ms =
std::chrono::duration<f64>(std::chrono::steady_clock::now() - t0).count() * 1.0e3;
PLOT_CHECK(s.size() == 1u << 20);
PLOT_CHECK(ms < 250.0);
}
PLOT_TEST(stream_series_pyramid_memory_is_bounded_by_the_window) {
// 50M samples through a 1M-sample ring: the pyramid's footprint must track the
// retained window (n/64 level-0 buckets plus the levels over them), not the
@@ -389,15 +374,16 @@ PLOT_TEST(gather_at_a_lod_level_reaches_both_ends_of_the_window) {
const auto check_ends = [&](u32 level) {
std::vector<f32> out;
s.gather(level, 0, n, out);
s.gather(level, 0, n, s.x0(), out);
PLOT_CHECK(out.size() >= 8u);
// Oldest: the first emitted pair is the retained sample at index 0, not the
// first whole bucket (which starts `lead` samples later).
PLOT_CHECK_NEAR(out[0], f32(s.x0()), 1e-9);
// first whole bucket (which starts `lead` samples later). x is relative to
// x0(), so it emits 0 (relative-x contract).
PLOT_CHECK_NEAR(out[0], 0.0, 1e-9);
PLOT_CHECK_NEAR(out[1], 25.0, 1e-6);
// Newest: the last emitted pair is the sample at index n-1, not the last
// complete bucket.
PLOT_CHECK_NEAR(out[out.size() - 2], f32(s.x0() + f64(n - 1) * 1e-6), 1e-9);
PLOT_CHECK_NEAR(out[out.size() - 2], f64(n - 1) * 1e-6, 1e-9);
PLOT_CHECK_NEAR(out[out.size() - 1], -25.0, 1e-6);
};
PLOT_CHECK(s.pyramid().level_size(1) >= 2);
@@ -417,9 +403,453 @@ PLOT_TEST(gather_at_a_lod_level_reaches_both_ends_of_the_window) {
PLOT_CHECK((ny % 256u) != 0u); // 5000 = 19 * 256 + 136 at level 2
plot::data::ArraySeries a(y, 0.0, 1e-6);
std::vector<f32> out;
a.gather(2, 0, ny, out);
a.gather(2, 0, ny, a.x0(), out);
PLOT_CHECK(out.size() >= 8u);
PLOT_CHECK_NEAR(out[0], 0.0, 1e-9); // level 2 starts on the grid
PLOT_CHECK_NEAR(out[out.size() - 2], f32(f64(ny - 1) * 1e-6), 1e-9);
PLOT_CHECK_NEAR(out[out.size() - 1], -25.0, 1e-6);
}
// --- B3: gather never emits geometry from outside [begin, end) ---------------
PLOT_TEST(neighbours_exact_hits_collapse_to_one_index) {
// The contract says the bracket degenerates to (k, k) when x lands exactly on
// a sample -- at the first sample too, where the old floor-only path answered
// (0, 1) -- and stays (k, k+1) strictly between two samples.
plot::data::ArraySeries s(std::vector<f32>{1, 2, 3, 4, 5}, 10.0, 0.25);
u64 i0 = 9, i1 = 9;
PLOT_CHECK(s.neighbours(s.x_at(0), i0, i1)); // first: was (0, 1)
PLOT_CHECK(i0 == 0 && i1 == 0);
PLOT_CHECK(s.neighbours(s.x_at(2), i0, i1)); // middle
PLOT_CHECK(i0 == 2 && i1 == 2);
PLOT_CHECK(s.neighbours(s.x_at(4), i0, i1)); // last
PLOT_CHECK(i0 == 4 && i1 == 4);
PLOT_CHECK(s.neighbours(s.x_at(1) + 0.1, i0, i1)); // strictly between
PLOT_CHECK(i0 == 1 && i1 == 2);
// A step whose k*dx does not round-trip through the division: the exact-hit
// test must compare samples, not trust t's fraction being an integer.
plot::data::ArraySeries t(std::vector<f32>{0, 1, 2, 3, 4, 5, 6, 7}, -1.0, 0.1);
for (u64 i = 0; i < 8; ++i) {
PLOT_CHECK(t.neighbours(t.x_at(i), i0, i1));
PLOT_CHECK(i0 == i && i1 == i);
}
PLOT_CHECK(t.neighbours((t.x_at(3) + t.x_at(4)) / 2.0, i0, i1));
PLOT_CHECK(i0 == 3 && i1 == 4);
}
PLOT_TEST(neighbours_outside_and_empty_are_false) {
plot::data::ArraySeries s(std::vector<f32>{1, 2, 3}, 0.0, 1.0);
u64 i0 = 7, i1 = 7;
PLOT_CHECK(!s.neighbours(-0.5, i0, i1)); // left of the window
PLOT_CHECK(!s.neighbours(3.5, i0, i1)); // right of the window
PLOT_CHECK(!s.neighbours(std::nan(""), i0, i1));
PLOT_CHECK(i0 == 7 && i1 == 7); // untouched on false
plot::data::ArraySeries e({}, 0.0, 1.0);
PLOT_CHECK(!e.neighbours(1.0, i0, i1));
PLOT_CHECK(e.snap_index(1.0) == 0); // empty series snaps to 0
}
PLOT_TEST(gather_envelope_stays_inside_an_unaligned_range) {
// The defect this pins: the first bucket was floor-selected and the bucket
// containing `end - 1` was emitted whole, so an unaligned range drew
// aggregates covering samples outside it. The samples outside [begin, end)
// here are extreme on purpose: any envelope value beyond the exact reduce is
// geometry that leaked in from outside the range.
constexpr u64 kN = 4096;
std::vector<f32> y(kN);
for (u64 i = 0; i < kN; ++i) {
y[i] = i < 1000 || i >= 3000 ? (i % 2 == 0 ? 1000.0f : -1000.0f)
: 0.25f * f32(i % 4) - 0.375f;
}
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
const u64 b = 1000 + 37, e = 3000 - 61; // unaligned on every level's grid
const plot::Range exact = s.reduce(b, e); // |values| <= ~0.375 inside the range
PLOT_CHECK(exact.max < 1.0 && exact.min > -1.0);
const auto check_range = [&](u32 level) {
std::vector<f32> out;
s.gather(level, b, e, s.x0(), out);
PLOT_CHECK(out.size() >= 4u);
for (plot::usize i = 0; i + 1 < out.size(); i += 2) {
PLOT_CHECK(f64(out[i]) >= f64(b)); // emitted x inside [begin, end)
PLOT_CHECK(f64(out[i]) < f64(e));
}
for (plot::usize i = 1; i < out.size(); i += 2) {
PLOT_CHECK(f64(out[i]) >= exact.min - 1e-6);
PLOT_CHECK(f64(out[i]) <= exact.max + 1e-6);
}
};
check_range(1);
check_range(2);
check_range(3);
check_range(6);
// And the envelope values are not merely *inside* the range: each emitted
// (min, max) pair equals the exact reduce over the samples that pair's two x
// positions bracket. Walk the layout the contract fixes -- raw lead pairs,
// two pairs per complete bucket, raw trail pairs -- so the raw edge spans
// are not mistaken for envelope pairs.
const u32 level = 2;
const u64 span = u64{kBucket} << level;
const u64 lend = s.pyramid().level_size(level);
const u64 first = std::min(b / span + (b % span == 0 ? u64{0} : u64{1}), lend);
const u64 last = std::min(e / span, lend);
const u64 lead = first * span;
const u64 buckets = last - first;
PLOT_CHECK(lead > b && last * span < e); // both edge spans really are partial
std::vector<f32> out;
s.gather(level, b, e, s.x0(), out);
PLOT_CHECK(out.size() == 2 * ((lead - b) + 2 * buckets + (e - last * span)));
plot::usize o = 0;
for (u64 i = b; i < lead; ++i, o += 2) { // the partial leading bucket, raw
PLOT_CHECK_NEAR(out[o], f32(f64(i)), 1e-6);
PLOT_CHECK(f64(out[o + 1]) >= exact.min - 1e-6 && f64(out[o + 1]) <= exact.max + 1e-6);
}
for (u64 j = 0; j < buckets; ++j) {
const u64 bi = first + j;
const u64 lo = bi * span;
const u64 hi = std::min((bi + 1) * span, e) - 1;
const plot::Range r = s.reduce(lo, hi + 1);
PLOT_CHECK_NEAR(out[o], f32(f64(lo)), 1e-6);
PLOT_CHECK_NEAR(out[o + 1], r.min, 1e-6);
PLOT_CHECK_NEAR(out[o + 2], f32(f64(hi)), 1e-6);
PLOT_CHECK_NEAR(out[o + 3], r.max, 1e-6);
o += 4;
}
for (u64 i = last * span; i < e; ++i, o += 2) { // the partial trailing bucket
PLOT_CHECK_NEAR(out[o], f32(f64(i)), 1e-6);
PLOT_CHECK(f64(out[o + 1]) >= exact.min - 1e-6 && f64(out[o + 1]) <= exact.max + 1e-6);
}
PLOT_CHECK(o == out.size());
}
PLOT_TEST(stream_gather_envelope_stays_inside_an_unaligned_range) {
// Same invariant on a sliding window whose base is off the bucket grid. The
// tame zone covers every sample the queried range reaches -- window [3808,
// 12000) queried at window indices [300, n-77), absolute [4108, 11923) -- and
// the extremes sit strictly before it, from 3808 to 4000.
const auto value = [](u64 i) {
return i < 4000 ? (i % 2 == 0 ? 500.0f : -500.0f)
: 0.125f * f32(i % 5) - 0.25f;
};
constexpr u64 kPushes = 12000;
plot::data::StreamSeries s(1u << 13, 0.0, 1.0); // window of 8192, base 3816
for (u64 i = 0; i < kPushes; ++i) s.push(value(i));
s.acquire();
const u64 n = s.size();
const u64 base = kPushes - n;
PLOT_CHECK((base % kBucket) != 0); // window starts mid-bucket
const u64 b = 300, e = n - 77; // unaligned inside the window
const plot::Range exact = s.reduce(b, e);
PLOT_CHECK(exact.max < 1.0 && exact.min > -1.0);
std::vector<f32> out;
s.gather(1, b, e, s.x0(), out);
PLOT_CHECK(out.size() >= 4u);
for (plot::usize i = 0; i + 1 < out.size(); i += 2) {
PLOT_CHECK(f64(out[i]) >= f64(b)); // relative x inside [begin, end)
PLOT_CHECK(f64(out[i]) < f64(e));
}
for (plot::usize i = 1; i < out.size(); i += 2) {
PLOT_CHECK(f64(out[i]) >= exact.min - 1e-6);
PLOT_CHECK(f64(out[i]) <= exact.max + 1e-6);
}
}
// --- B7: non-finite samples are excluded from every aggregate ----------------
PLOT_TEST(nan_samples_are_excluded_from_bounds_and_sums) {
// NaN at the first, middle and last positions: bounds and reduce stay finite,
// sums skip the NaN samples entirely.
std::vector<f32> y{std::nanf(""), 1.0f, 2.0f, std::nanf(""), 3.0f, 4.0f,
std::nanf("")};
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
PLOT_CHECK_NEAR(s.y_bounds().min, 1.0, 1e-9);
PLOT_CHECK_NEAR(s.y_bounds().max, 4.0, 1e-9);
const plot::Range r = s.reduce(0, 7);
PLOT_CHECK_NEAR(r.min, 1.0, 1e-9);
PLOT_CHECK_NEAR(r.max, 4.0, 1e-9);
PLOT_CHECK_NEAR(s.sum(0, 7), 10.0, 1e-9);
PLOT_CHECK_NEAR(s.sumsq(0, 7), 30.0, 1e-9);
// A range that is *all* NaN: zero-span, zero sums.
PLOT_CHECK(s.reduce(0, 1).span() == 0.0);
PLOT_CHECK(s.sum(0, 1) == 0.0);
PLOT_CHECK(s.sumsq(0, 1) == 0.0);
// And a NaN-only window reports a zero-span Range, not NaN.
PLOT_CHECK(s.reduce(5, 7).span() == 0.0);
}
PLOT_TEST(stream_series_bounds_skip_nan_samples) {
// acquire()'s bounds go through the same exact folds, so a stream with NaN
// gaps publishes finite bounds -- and NaN at the edges of the window too.
plot::data::StreamSeries s(256, 0.0, 1.0);
s.push(std::nanf("")); // oldest retained sample
for (int i = 0; i < 254; ++i) s.push(i % 2 == 0 ? -3.0f : 7.0f);
s.push(std::nanf("")); // newest sample
s.acquire();
PLOT_CHECK(s.size() == 256);
PLOT_CHECK_NEAR(s.y_bounds().min, -3.0, 1e-6);
PLOT_CHECK_NEAR(s.y_bounds().max, 7.0, 1e-6);
PLOT_CHECK_NEAR(s.reduce(0, s.size()).max, 7.0, 1e-6);
PLOT_CHECK_NEAR(s.sum(0, s.size()), 127.0 * 4.0, 1e-3);
}
PLOT_TEST(window_without_finite_samples_aggregates_to_zero) {
plot::data::ArraySeries s(std::vector<f32>(8, std::nanf("")), 0.0, 1.0);
PLOT_CHECK(s.y_bounds().span() == 0.0);
PLOT_CHECK(s.reduce(0, 8).span() == 0.0);
PLOT_CHECK(s.sum(0, 8) == 0.0);
PLOT_CHECK(s.sumsq(0, 8) == 0.0);
// x bounds stay the grid's: the window exists even though nothing is finite.
PLOT_CHECK_NEAR(s.x_bounds().min, 0.0, 1e-9);
PLOT_CHECK_NEAR(s.x_bounds().max, 7.0, 1e-9);
}
// --- R3: any u32 gather level is safe ----------------------------------------
PLOT_TEST(gather_level_beyond_the_pyramid_clamps) {
// R3: `kBucket << level` used to shift by >= 64 for a public level past 57
// (undefined). Now any u32 level is accepted and clamped to the deepest one.
std::vector<f32> y(8192);
for (u64 i = 0; i < y.size(); ++i) y[i] = f32(i % 13) - 6.0f;
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
std::vector<f32> deepest;
s.gather(plot::data::kMaxPyramidLevel, 0, s.size(), s.x0(), deepest);
PLOT_CHECK(!deepest.empty());
for (const u32 level : {u32{58}, u32{200}, u32{1000}, ~u32{0}}) {
std::vector<f32> out;
s.gather(level, 0, s.size(), s.x0(), out);
PLOT_CHECK(out == deepest); // clamped to the same deepest geometry
}
// The cap is where the span shift still fits: level 57 spans exactly 2^63.
PLOT_CHECK(plot::data::kMaxPyramidLevel == 57);
PLOT_CHECK((u64{kBucket} << plot::data::kMaxPyramidLevel) == (u64{1} << 63));
}
// --- R4: the ring's capacity is the rounded one -------------------------------
PLOT_TEST(stream_series_capacity_is_rounded_up_and_pre_sized) {
// StreamSeries(100) retains 128 samples: the ring rounds up, size() saturates
// at the rounded capacity, and the pyramid was reserved for it (a saturated
// stream must not grow the level table mid-acquire).
plot::data::StreamSeries s(100, 0.0, 1.0);
for (int i = 0; i < 1000; ++i) s.push(f32(i));
s.acquire();
PLOT_CHECK(s.size() == 128);
PLOT_CHECK_NEAR(s.y_bounds().max, 999.0, 1e-4);
PLOT_CHECK_NEAR(s.x_bounds().max, s.x0() + 127.0, 1e-9);
const plot::Range r = s.reduce(0, s.size());
PLOT_CHECK_NEAR(r.min, 872.0, 1e-4); // the oldest retained sample
}
// --- GatherMode: Envelope / Nearest / Mean ------------------------------------
PLOT_TEST(gather_modes_envelope_nearest_mean) {
// 4096 samples of a sawtooth, level 2 (256-sample buckets, 16 of them).
constexpr u64 kN = 4096;
const auto value = [](u64 i) { return f32(i % 7) - 3.0f; };
std::vector<f32> y(kN);
for (u64 i = 0; i < kN; ++i) y[i] = value(i);
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
const u32 level = 2;
const u64 span = u64{kBucket} << level;
const u64 buckets = kN / span;
PLOT_CHECK(buckets == 16);
std::vector<f32> env, near, mean;
s.gather(level, 0, kN, s.x0(), env, plot::data::GatherMode::Envelope);
s.gather(level, 0, kN, s.x0(), near, plot::data::GatherMode::Nearest);
s.gather(level, 0, kN, s.x0(), mean, plot::data::GatherMode::Mean);
// Envelope: two points per bucket, min at the bucket's first sample, max at
// its last (this sawtooth's per-bucket min is its first sample's value and
// its max its last sample's value).
PLOT_CHECK(env.size() == 4 * buckets);
// Nearest and Mean: one point per bucket, x at the bucket's centre sample.
PLOT_CHECK(near.size() == 2 * buckets);
PLOT_CHECK(mean.size() == 2 * buckets);
for (u64 j = 0; j < buckets; ++j) {
const u64 lo = j * span;
const u64 centre = lo + span / 2;
const f64 centre_rel = f64(centre); // dx == 1, relative to x0() == 0
// x positions are relative and land where the mode says.
PLOT_CHECK_NEAR(env[4 * j], f64(lo), 1e-6);
PLOT_CHECK_NEAR(env[4 * j + 2], f64(lo + span - 1), 1e-6);
PLOT_CHECK_NEAR(near[2 * j], centre_rel, 1e-6);
PLOT_CHECK_NEAR(mean[2 * j], centre_rel, 1e-6);
// Envelope y is the exact per-bucket min/max.
const plot::Range r = s.reduce(lo, lo + span);
PLOT_CHECK_NEAR(env[4 * j + 1], r.min, 1e-6);
PLOT_CHECK_NEAR(env[4 * j + 3], r.max, 1e-6);
// Nearest y is the raw centre sample.
PLOT_CHECK_NEAR(near[2 * j + 1], value(centre), 1e-6);
// Mean y is the bucket's mean.
const f64 want_mean = s.sum(lo, lo + span) / f64(span);
PLOT_CHECK_NEAR(mean[2 * j + 1], want_mean, 1e-6);
}
// Level 0 and sub-bucket ranges are the raw samples for every mode.
std::vector<f32> raw;
s.gather(0, 5, 300, s.x0(), raw, plot::data::GatherMode::Mean);
PLOT_CHECK(raw.size() == 2 * (300 - 5));
PLOT_CHECK_NEAR(raw[0], 5.0, 1e-9);
}
PLOT_TEST(gather_skips_all_nan_buckets) {
// Level-1 bucket 1 (samples [128, 256)) is entirely NaN: the exact queries
// treat it as absent, the gathered geometry emits nothing for it in Envelope
// and Mean, and Nearest draws its centre sample raw -- NaN, as level 0 would.
std::vector<f32> y(384, 1.0f);
for (u64 i = 128; i < 256; ++i) y[i] = std::nanf("");
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
PLOT_CHECK(s.reduce(128, 256).span() == 0.0);
PLOT_CHECK(s.sum(128, 256) == 0.0);
const plot::Range r = s.reduce(0, 384);
PLOT_CHECK_NEAR(r.min, 1.0, 1e-9);
PLOT_CHECK_NEAR(r.max, 1.0, 1e-9);
// first = 0, last = 256/128 = 2: buckets 0 and 1, no lead/trail raw spans.
std::vector<f32> env, mean, near;
s.gather(1, 0, 256, s.x0(), env, plot::data::GatherMode::Envelope);
PLOT_CHECK(env.size() == 4); // bucket 0's two pairs only
PLOT_CHECK_NEAR(env[0], 0.0, 1e-9);
PLOT_CHECK_NEAR(env[1], 1.0, 1e-9);
PLOT_CHECK_NEAR(env[2], 127.0, 1e-9);
PLOT_CHECK_NEAR(env[3], 1.0, 1e-9);
s.gather(1, 0, 256, s.x0(), mean, plot::data::GatherMode::Mean);
PLOT_CHECK(mean.size() == 2); // bucket 1 has no finite mean: skipped
PLOT_CHECK_NEAR(mean[0], 64.0, 1e-9);
PLOT_CHECK_NEAR(mean[1], 1.0, 1e-9);
s.gather(1, 0, 256, s.x0(), near, plot::data::GatherMode::Nearest);
PLOT_CHECK(near.size() == 4); // every bucket still has a centre sample
PLOT_CHECK_NEAR(near[0], 64.0, 1e-9);
PLOT_CHECK_NEAR(near[1], 1.0, 1e-9);
PLOT_CHECK_NEAR(near[2], 192.0, 1e-9); // bucket 1's centre, drawn raw
PLOT_CHECK(std::isnan(near[3]));
}
// --- Snapshot: StreamSeries::freeze() -----------------------------------------
PLOT_TEST(stream_series_freeze_copies_the_last_acquire_window) {
plot::data::StreamSeries s(1024, 5.0, 0.5);
for (u64 i = 0; i < 400; ++i) s.push(f32(i % 31));
s.acquire();
const u64 n = s.size();
const plot::Range bounds = s.y_bounds();
std::vector<f32> gathered;
s.gather(1, 0, n, s.x0(), gathered);
const plot::Range reduced = s.reduce(0, n);
// Freeze now, while the ring still holds exactly the published window.
const plot::data::ArraySeries frozen = s.freeze();
PLOT_CHECK(frozen.size() == n);
PLOT_CHECK_NEAR(frozen.x0(), 5.0, 1e-12);
PLOT_CHECK_NEAR(frozen.dx(), 0.5, 1e-12);
PLOT_CHECK_NEAR(frozen.y_bounds().min, bounds.min, 1e-6);
PLOT_CHECK_NEAR(frozen.y_bounds().max, bounds.max, 1e-6);
const plot::Range fr = frozen.reduce(0, n);
PLOT_CHECK_NEAR(fr.min, reduced.min, 1e-6);
PLOT_CHECK_NEAR(fr.max, reduced.max, 1e-6);
PLOT_CHECK_NEAR(frozen.sum(0, n), s.sum(0, n), 1e-3);
std::vector<f32> frozen_gathered;
frozen.gather(1, 0, n, frozen.x0(), frozen_gathered);
PLOT_CHECK(frozen_gathered == gathered); // same geometry, byte for byte
// The producer now laps the ring (700 more pushes overwrite the oldest
// slots) and a later acquire() grows the published window -- the frozen copy
// answers from its own samples and never moves.
for (u64 i = 0; i < 700; ++i) s.push(-1000.0f);
s.acquire();
PLOT_CHECK(s.size() == 1024);
PLOT_CHECK(frozen.size() == n);
PLOT_CHECK_NEAR(frozen.y_bounds().max, bounds.max, 1e-6);
PLOT_CHECK_NEAR(frozen.y_bounds().min, bounds.min, 1e-6);
PLOT_CHECK(frozen.reduce(0, n).max == reduced.max);
}
// --- The gather origin: f32 precision against the view, not the window -------
PLOT_TEST(a_gather_origin_at_the_view_keeps_a_deep_zoom_on_the_sample_it_quotes) {
// 20 s of retained data at 1 kS/s examined through a 10 us window -- the shape of
// a scope zoomed in on a long ring. gather() stages x as f32, so what the origin
// decides is the span that f32 has to resolve: the whole retained window (which
// is what `x0()`, the old implicit origin, names) or the part of it on screen. A
// readout quotes `x_at()` in exact f64, so the difference between the two is the
// distance between where a sample is *drawn* and the x reported for it.
constexpr u64 kN = 20001;
std::vector<f32> y(static_cast<plot::usize>(kN));
for (u64 i = 0; i < kN; ++i) y[static_cast<plot::usize>(i)] = f32(i % 71);
const plot::data::ArraySeries s(std::move(y), /*x0=*/0.0, /*dx=*/1e-3);
PLOT_CHECK_NEAR(s.x_bounds().max, 20.0, 1e-9);
const f64 centre = s.x_at(kN - 2u); // 19.999 s, near the far end
const plot::Range view{centre - 5e-6, centre + 5e-6}; // 10 us across
const f64 px_per_x = 700.0 / (view.max - view.min); // a typical axis width
const auto worst_error_px = [&](f64 origin) {
std::vector<f32> out;
s.gather(0, kN - 3u, kN, origin, out);
PLOT_CHECK(out.size() == 6u);
f64 worst = 0.0;
for (u64 k = 0; k < 3u; ++k) {
// Exactly the renderer's reconstruction: the origin added back in f64.
const f64 drawn = origin + f64(out[static_cast<plot::usize>(2u * k)]);
worst = std::max(worst, std::abs(drawn - s.x_at(kN - 3u + k)) * px_per_x);
}
return worst;
};
// The visible range's minimum: the f32 resolves 2^-24 of what is on screen, so
// the error is a fixed fraction of a pixel at every zoom.
PLOT_CHECK(worst_error_px(view.min) < 0.05);
// The start of the retained window -- what every caller used to get -- resolves
// 2^-24 of 20 s instead, which at this zoom is most of the axis.
PLOT_CHECK(worst_error_px(s.x0()) > 10.0);
}
// --- Incremental append: ArraySeries::push() ----------------------------------
PLOT_TEST(array_series_push_matches_commit) {
// The incremental path must land exactly where the wholesale path does:
// bounds, x bounds, exact queries and the pyramid's buckets themselves.
std::vector<f32> y;
y.reserve(1000);
for (u64 i = 0; i < 1000; ++i) {
y.push_back(i % 97 == 0 ? std::nanf("") : f32((i * 37) % 251) - 125.0f);
}
plot::data::ArraySeries pushed({}, 10.0, 0.25);
for (const f32 v : y) pushed.push(v);
const plot::data::ArraySeries committed(y, 10.0, 0.25);
PLOT_CHECK(pushed.size() == committed.size());
PLOT_CHECK_NEAR(pushed.x_bounds().min, committed.x_bounds().min, 1e-12);
PLOT_CHECK_NEAR(pushed.x_bounds().max, committed.x_bounds().max, 1e-12);
PLOT_CHECK_NEAR(pushed.y_bounds().min, committed.y_bounds().min, 1e-6);
PLOT_CHECK_NEAR(pushed.y_bounds().max, committed.y_bounds().max, 1e-6);
for (u64 a = 0; a < 1000; a += 173) {
for (u64 b = a + 1; b <= 1000; b += 331) {
const plot::Range ra = pushed.reduce(a, b);
const plot::Range rb = committed.reduce(a, b);
PLOT_CHECK_NEAR(ra.min, rb.min, 1e-9);
PLOT_CHECK_NEAR(ra.max, rb.max, 1e-9);
PLOT_CHECK_NEAR(pushed.sum(a, b), committed.sum(a, b), 1e-9);
PLOT_CHECK_NEAR(pushed.sumsq(a, b), committed.sumsq(a, b), 1e-9);
}
}
for (u32 l = 0; l < 3; ++l) {
PLOT_CHECK(pushed.pyramid().level_base(l) == committed.pyramid().level_base(l));
PLOT_CHECK(pushed.pyramid().level_size(l) == committed.pyramid().level_size(l));
for (u64 b = pushed.pyramid().level_base(l);
b < pushed.pyramid().level_base(l) + pushed.pyramid().level_size(l); ++b) {
const plot::data::MinMax ma = pushed.pyramid().bucket(l, b);
const plot::data::MinMax mb = committed.pyramid().bucket(l, b);
PLOT_CHECK_NEAR(ma.min, mb.min, 1e-9);
PLOT_CHECK_NEAR(ma.max, mb.max, 1e-9);
PLOT_CHECK_NEAR(pushed.pyramid().bucket_sum(l, b), committed.pyramid().bucket_sum(l, b),
1e-9);
PLOT_CHECK(pushed.pyramid().bucket_count(l, b) == committed.pyramid().bucket_count(l, b));
}
}
}
+198 -3
View File
@@ -2,6 +2,7 @@
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "harness.hpp"
#include <plot/plot.hpp>
#include <plot/plot/ticks.hpp>
#include <cmath>
#include <cstdlib>
@@ -97,10 +98,111 @@ PLOT_TEST(minor_ticks_subdivide_and_the_total_is_capped) {
PLOT_CHECK(std::fabs(tick.value * scale - std::round(tick.value * scale)) < 1e-9);
PLOT_CHECK(tick.major ? !tick.label.empty() : tick.label.empty());
}
// A range dense enough to want millions of minors stops at the hard cap.
}
/// R9: the cap counts **minors only**. A range dense enough to want more minors
/// than fit is truncated in its subdivisions and never in its labels: every major
/// of the span is present, labelled, and the far end of the axis keeps its number.
PLOT_TEST(minor_cap_spares_the_majors) {
std::vector<plot::Tick> capped;
plot::generate_ticks({0.0, 1e9}, 1e6f, 1.0f, capped, /*include_minors=*/true);
PLOT_CHECK(capped.size() == plot::kMaxTicks);
// Step 1000 over [0, 65000] (65 major intervals): 260 minors want in, more than
// the 256 the cap holds, and every one of the 66 majors still counts.
plot::generate_ticks({0.0, 65000.0}, 65000.0f, 1000.0f, capped, /*include_minors=*/true);
plot::usize majors = 0;
plot::usize minors = 0;
for (const plot::Tick& tick : capped) {
if (tick.major) {
++majors;
} else {
++minors;
}
}
PLOT_CHECK(minors == plot::kMaxTicks); // minors capped exactly as before
PLOT_CHECK(majors == 66u); // every major of the span kept
for (plot::usize i = 1; i < capped.size(); ++i) PLOT_CHECK(capped[i].value > capped[i - 1].value);
// The far end of the axis still carries its label.
PLOT_CHECK(capped.back().major);
PLOT_CHECK(!capped.back().label.empty());
PLOT_CHECK_NEAR(capped.back().value, 65000.0, 1e-9);
}
/// B2: the major/minor split is an integer-index test, so an epoch origin with a
/// small step no longer fights a ULP-sized residue: majors are classified exactly,
/// whatever the origin's magnitude. The +/-300 s window is the reported defect's
/// shape; the sub-second window is where the old float-tolerance test actually
/// misclassified (step 0.01 at 1.75e9 carries an error far above step*1e-9).
PLOT_TEST(epoch_window_yields_labelled_majors) {
std::vector<plot::Tick> t;
plot::generate_ticks({1.75e9 - 300.0, 1.75e9 + 300.0}, 800.0f, 60.0f, t,
/*include_minors=*/true);
plot::usize majors = 0;
for (const plot::Tick& tick : t) {
if (!tick.major) continue;
++majors;
PLOT_CHECK(!tick.label.empty());
PLOT_CHECK(tick.value >= 1.75e9 - 300.0);
PLOT_CHECK(tick.value <= 1.75e9 + 300.0);
}
// Step 50 s over a 600 s window: the labelled ticks span it.
PLOT_CHECK(majors >= 11);
std::vector<plot::Tick> deep;
plot::generate_ticks({1.75e9 - 0.05, 1.75e9 + 0.05}, 800.0f, 60.0f, deep,
/*include_minors=*/true);
plot::usize deep_majors = 0;
for (const plot::Tick& tick : deep) {
if (!tick.major) continue;
++deep_majors;
PLOT_CHECK(!tick.label.empty());
}
// Step 0.01 s over a 0.1 s window: again a full set of labelled majors.
PLOT_CHECK(deep_majors >= 9);
}
/// The tick that *means* zero reads `0`. The walk starts at `ceil(lo/stride)*stride`,
/// inexact for every stride that is not a power of two, so a range straddling the
/// origin used to reach a few e-16 there and label it from the far end of the SI
/// table: `-1 -500m -139a 500m 1`. The tolerance is relative to the stride, which is
/// also the smallest magnitude a non-zero tick can have, so a range whose ticks are
/// *legitimately* that small keeps them.
PLOT_TEST(the_tick_at_the_origin_reads_zero) {
std::vector<plot::Tick> t;
const float widths[] = {80.0f, 40.0f, 10.0f};
for (const float min_px : widths) {
for (const bool minors : {false, true}) {
plot::generate_ticks({-1.0, 1.0}, 400.0f, min_px, t, minors);
plot::usize zeros = 0;
for (const plot::Tick& tick : t) {
if (!tick.major) continue;
PLOT_CHECK(!tick.label.empty());
// Nothing between -500m and 500m but the origin itself, so no major may
// carry a prefix from below `m`.
PLOT_CHECK(tick.label.find('a') == std::string::npos);
PLOT_CHECK(tick.label.find('f') == std::string::npos);
if (tick.label == "0") {
++zeros;
PLOT_CHECK(tick.value == 0.0); // exact, so a Time axis relabels it as 0 too
}
}
PLOT_CHECK(zeros == 1u);
}
}
// A range whose ticks really are atto-scale is untouched: only the origin folds.
plot::generate_ticks({-2e-16, 2e-16}, 400.0f, 80.0f, t, /*include_minors=*/true);
plot::usize tiny = 0;
plot::usize tiny_zeros = 0;
for (const plot::Tick& tick : t) {
if (!tick.major) continue;
if (tick.value == 0.0) {
++tiny_zeros;
} else {
++tiny;
PLOT_CHECK(tick.label.find('a') != std::string::npos);
}
}
PLOT_CHECK(tiny_zeros == 1u);
PLOT_CHECK(tiny >= 2u);
}
PLOT_TEST(si_labels_are_utf8_and_never_overrun) {
@@ -134,6 +236,11 @@ PLOT_TEST(si_labels_are_utf8_and_never_overrun) {
PLOT_TEST(format_time_shapes) {
char b[32];
plot::format_time(0.5, b, sizeof b); PLOT_CHECK(std::string(b) == "500ms");
plot::format_time(0.005, b, sizeof b); PLOT_CHECK(std::string(b) == "5ms");
plot::format_time(0.0005, b, sizeof b); PLOT_CHECK(std::string(b) == "500\u00b5s");
plot::format_time(5.0e-6, b, sizeof b); PLOT_CHECK(std::string(b) == "5\u00b5s");
plot::format_time(5.0e-9, b, sizeof b); PLOT_CHECK(std::string(b) == "5ns");
plot::format_time(5.0e-12, b, sizeof b); PLOT_CHECK(std::string(b) == "5ps");
plot::format_time(1.5, b, sizeof b); PLOT_CHECK(std::string(b) == "1.5s");
plot::format_time(90.0, b, sizeof b); PLOT_CHECK(std::string(b) == "1:30");
plot::format_time(3661.0, b, sizeof b); PLOT_CHECK(std::string(b) == "1:01:01");
@@ -156,6 +263,18 @@ PLOT_TEST(format_time_never_lets_a_field_reach_its_radix) {
plot::format_time(86399.5, b, sizeof b); PLOT_CHECK(std::string(b) == "1d");
plot::format_time(86400.0, b, sizeof b); PLOT_CHECK(std::string(b) == "1d");
// A mantissa may not reach its own radix either: three significant figures of a value a
// hair under a rung's top round up to 1000, and `%.3g` writes that `1e+03`. A one-second
// tick computed as 0.9999999 s read `1e+03ms` on a live axis, which is where this was seen.
plot::format_time(0.9999999, b, sizeof b); PLOT_CHECK(std::string(b) == "1s");
plot::format_time(0.9995, b, sizeof b); PLOT_CHECK(std::string(b) == "1s");
plot::format_time(0.9994, b, sizeof b); PLOT_CHECK(std::string(b) == "999ms");
plot::format_time(0.9999999e-3, b, sizeof b); PLOT_CHECK(std::string(b) == "1ms");
plot::format_time(0.9994e-3, b, sizeof b); PLOT_CHECK(std::string(b) == "999\u00b5s");
plot::format_time(0.9999999e-6, b, sizeof b); PLOT_CHECK(std::string(b) == "1\u00b5s");
plot::format_time(0.9999999e-9, b, sizeof b); PLOT_CHECK(std::string(b) == "1ns");
plot::format_time(-0.9999999, b, sizeof b); PLOT_CHECK(std::string(b) == "-1s");
// Sweep the boundary neighbourhoods and check every emitted field.
const double values[] = {0.4, 0.9995, 1.0, 59.4, 59.4999, 59.5, 59.9, 60.0, 90.0, 119.5, 3599.4,
3599.5, 3599.9, 86399.4, 86399.5, 86399.9, 86400.0, 172800.0, -119.5,
@@ -167,3 +286,79 @@ PLOT_TEST(format_time_never_lets_a_field_reach_its_radix) {
check_no_field_reaches_its_radix(label);
}
}
// An axis origin shifts what the labels *say* and which grid the ticks sit on: a window
// zoomed into 12.345 s must read `0, 0.5ms, 1ms` against its own left edge, because three
// significant digits cannot tell 12.345671 s from 12.345672 s. A host prints the origin once
// beside the axis and the user adds.
PLOT_TEST(ticks_are_written_relative_to_an_axis_origin) {
// The same window twice: absolute, and relative to its own start.
const plot::Range window{12.345, 12.346};
std::vector<plot::Tick> absolute;
plot::generate_ticks(window, 400.0f, 80.0f, absolute, /*include_minors=*/false, 0.0);
PLOT_CHECK(!absolute.empty());
PLOT_CHECK(absolute.front().value >= window.min);
std::vector<plot::Tick> relative;
plot::generate_ticks(window, 400.0f, 80.0f, relative, /*include_minors=*/false, window.min);
PLOT_CHECK(!relative.empty());
// The walk starts on the origin's grid: the first major is the origin itself, and every
// major is an exact multiple of the step away from it.
PLOT_CHECK_NEAR(relative.front().value, window.min, 1e-12);
PLOT_CHECK_NEAR(relative[1].value - relative[0].value,
(absolute[1].value - absolute[0].value), 1e-12);
for (const plot::Tick& t : relative) {
PLOT_CHECK(t.value >= window.min - 1e-12);
PLOT_CHECK(t.value <= window.max + 1e-12);
}
// And through an axis: the *labels* are the relative values, the positions the absolute
// ones. That is the whole contract -- `format_value()` is where the origin is applied.
plot::Plot p;
p.set_viewport({0, 0, 800, 400});
const u32 ax = p.add_x_axis({"time", window, false, plot::AxisPlace::Bottom, false,
plot::TickFormat::Time});
p.x_axis(ax);
PLOT_CHECK_NEAR(p.x_axis_origin(ax), 0.0, 1e-12);
p.set_x_axis_origin(ax, window.min);
PLOT_CHECK_NEAR(p.x_axis_origin(ax), window.min, 1e-12);
char buf[64];
p.x_axis(ax).format_value(window.min, buf, sizeof buf);
PLOT_CHECK(std::string(buf) == "0"); // the window's start reads zero
p.x_axis(ax).format_value(window.min + 0.0005, buf, sizeof buf);
PLOT_CHECK(std::string(buf) == "500\u00b5s"); // half a millisecond reads in microseconds
// An id that names no axis is ignored; the axis that exists is untouched by it.
p.set_x_axis_origin(7u, 1234.0);
PLOT_CHECK_NEAR(p.x_axis_origin(ax), window.min, 1e-12);
}
// The moving-window case, which is why the mode exists: the origin is re-taken from the range
// at refresh, so the tick on the left edge reads exactly zero however far the window has
// scrolled since the host last looked. A host cannot predict the edge (the follow places it
// from the newest sample), and a value it wrote a frame earlier is a frame of scroll stale --
// at 1 MS/s that is more than a millisecond-scale window shows.
PLOT_TEST(a_range_origin_puts_the_zero_tick_on_the_left_edge) {
plot::Plot p;
p.set_viewport({0, 0, 800, 400});
const plot::Range window{12.345, 12.347};
const u32 ax = p.add_x_axis({"time", window, false, plot::AxisPlace::Bottom, false,
plot::TickFormat::Time});
p.set_x_axis_origin_from_range(ax, true);
// `layout()` is the library's own path to the ticks (it ends in `refresh_ticks()`), so this
// is the order a frame really runs in.
p.layout();
PLOT_CHECK(!p.x_axis(ax).ticks.empty());
const plot::Tick& first = p.x_axis(ax).ticks.front();
PLOT_CHECK(first.major);
PLOT_CHECK_NEAR(f64(first.value), window.min, 1e-12); // exactly the left edge ...
PLOT_CHECK(first.label == "0"); // ... and it reads zero
// The window slides: the origin follows it, so the zero tick is on the *new* edge.
const plot::Range moved{12.6, 12.602};
p.set_x_range(ax, moved);
p.layout();
PLOT_CHECK_NEAR(f64(p.x_axis(ax).ticks.front().value), moved.min, 1e-12);
PLOT_CHECK(p.x_axis(ax).ticks.front().label == "0");
PLOT_CHECK_NEAR(p.x_axis_origin(ax), moved.min, 1e-12); // and the host reads it back
}
+274
View File
@@ -0,0 +1,274 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "harness.hpp"
#include <plot/data/array_series.hpp>
#include <plot/data/xy_series.hpp>
#include <algorithm>
#include <cmath>
#include <random>
#include <vector>
using plot::data::kBucket;
namespace {
/// Monotone but decidedly non-uniform x: early samples a millisecond apart,
/// later ones seconds apart. Exact in f64, no fixed dx anywhere.
std::vector<f64> spread_x(u64 n) {
std::vector<f64> x;
x.reserve(static_cast<plot::usize>(n));
f64 v = 0.0;
for (u64 i = 0; i < n; ++i) {
x.push_back(v);
v += 0.001 + f64(i) * 0.01;
}
return x;
}
std::vector<f32> saw_y(u64 n) {
std::vector<f32> y;
y.reserve(static_cast<plot::usize>(n));
for (u64 i = 0; i < n; ++i) y.push_back(f32((i * 13) % 97) - 48.0f);
return y;
}
} // namespace
PLOT_TEST(xy_series_reports_its_own_grid) {
auto x = spread_x(500);
const std::vector<f64> xs = x; // keep an un-moved copy for the checks
plot::data::XYSeries s(std::move(x), saw_y(500));
PLOT_CHECK(s.size() == 500);
PLOT_CHECK_NEAR(s.x0(), xs.front(), 1e-12);
PLOT_CHECK(s.dx() == 0.0); // meaningless by contract; nothing reads it
for (const u64 i : {u64{0}, u64{1}, u64{250}, u64{499}}) {
PLOT_CHECK_NEAR(s.x_at(i), xs[static_cast<plot::usize>(i)], 1e-12);
}
// x_bounds come from the array's ends, wherever they sit on the number line.
PLOT_CHECK_NEAR(s.x_bounds().min, xs.front(), 1e-12);
PLOT_CHECK_NEAR(s.x_bounds().max, xs.back(), 1e-12);
PLOT_CHECK_NEAR(s.y_bounds().min, -48.0, 1e-6);
PLOT_CHECK_NEAR(s.y_bounds().max, 48.0, 1e-6);
// Epoch-style offset: the same shape shifted by 1.7e9 keeps exact bounds, and
// gather() emits x relative to the origin the caller names -- x0() here, so
// small numbers, not quantised epochs.
std::vector<f64> epoch = spread_x(500);
const std::vector<f64> es = epoch; // un-moved copy for the expectations
for (auto& v : epoch) v += 1.7e9;
plot::data::XYSeries e(std::move(epoch), saw_y(500));
PLOT_CHECK_NEAR(e.x_bounds().min, 1.7e9, 1e-3);
PLOT_CHECK_NEAR(e.x_bounds().max, 1.7e9 + (xs.back() - xs.front()), 1e-3);
std::vector<f32> out;
e.gather(0, 0, 500, e.x0(), out);
PLOT_CHECK(out.size() == 1000);
// x is relative to x0(): x_at(i) - x0(), the array's own offsets -- small
// values even though the absolute epoch is 1.7e9. The expectations come from
// the epoch array itself, since that is the subtraction the contract does.
PLOT_CHECK_NEAR(out[0], 0.0, 1e-9); // x_at(0) - x0()
PLOT_CHECK_NEAR(out[2], f32(es[1] - es[0]), 1e-6);
PLOT_CHECK_NEAR(out[998], f32(es[499] - es[0]), 1e-3); // span, not 1.7e9
PLOT_CHECK(out[998] < 2000.0f);
}
PLOT_TEST(xy_series_neighbour_lookups_crosshair_style) {
// The crosshair asks neighbours() at an arbitrary x and reports whichever of
// the two samples it gets back is nearer. On a non-uniform grid that needs
// real searches: a uniform-grid formula would land megabytes off here.
auto x = spread_x(1000);
const std::vector<f64> xs = x;
plot::data::XYSeries s(std::move(x), saw_y(1000));
const auto at = [&](u64 i) { return xs[static_cast<plot::usize>(i)]; };
u64 i0 = 7, i1 = 7;
// Midway between samples 100 and 101: brackets those two.
const f64 mid = (at(100) + at(101)) / 2.0;
PLOT_CHECK(s.neighbours(mid, i0, i1));
PLOT_CHECK(i0 == 100);
PLOT_CHECK(i1 == 101);
// Exactly on a sample: both indices are that sample.
PLOT_CHECK(s.neighbours(at(250), i0, i1));
PLOT_CHECK(i0 == 250 && i1 == 250);
// Just left of a sample: still bracketed from below.
PLOT_CHECK(s.neighbours(std::nextafter(at(250), 0.0), i0, i1));
PLOT_CHECK(i0 == 249 && i1 == 250);
// Edges are inclusive.
PLOT_CHECK(s.neighbours(at(0), i0, i1));
PLOT_CHECK(i0 == 0 && i1 == 0);
PLOT_CHECK(s.neighbours(at(999), i0, i1));
PLOT_CHECK(i0 == 999 && i1 == 999); // exact hit on the last sample
// Outside the bounds (and NaN): false, indices untouched.
const u64 keep = 42;
i0 = i1 = keep;
PLOT_CHECK(!s.neighbours(at(0) - 1.0, i0, i1));
PLOT_CHECK(!s.neighbours(at(999) + 1.0, i0, i1));
PLOT_CHECK(!s.neighbours(std::nan(""), i0, i1));
PLOT_CHECK(i0 == keep && i1 == keep);
// Clustered duplicates: the contract is "last index <= x" and "first index
// >= x", so a run of equal x brackets the run's ends.
std::vector<f64> dup{1.0, 2.0, 2.0, 2.0, 3.0};
plot::data::XYSeries d(dup, {1.0f, 2.0f, 3.0f, 4.0f, 5.0f});
PLOT_CHECK(d.neighbours(2.0, i0, i1));
PLOT_CHECK(i0 == 3 && i1 == 1); // last <= 2 is index 3, first >= 2 is index 1
}
PLOT_TEST(xy_series_snap_index_finds_the_nearest_sample) {
auto x = spread_x(1000);
const std::vector<f64> xs = x;
plot::data::XYSeries s(std::move(x), saw_y(1000));
const auto at = [&](u64 i) { return xs[static_cast<plot::usize>(i)]; };
PLOT_CHECK(s.snap_index(at(400)) == 400); // exact hit
PLOT_CHECK(s.snap_index((at(400) + at(401)) / 2.0 + 1e-9) == 401);
PLOT_CHECK(s.snap_index((at(400) + at(401)) / 2.0 - 1e-9) == 400);
PLOT_CHECK(s.snap_index(at(0) - 100.0) == 0); // clamped
PLOT_CHECK(s.snap_index(at(999) + 100.0) == 999); // clamped
PLOT_CHECK(s.snap_index(std::nan("")) == 0); // NaN lands on 0
// A tie goes to the lower index (midpoints are impossible on this x, so make
// one explicitly).
std::vector<f64> tie{0.0, 10.0};
plot::data::XYSeries t(tie, {1.0f, 2.0f});
PLOT_CHECK(t.snap_index(5.0) == 0);
PLOT_CHECK(t.snap_index(5.0 + 1e-9) == 1);
}
PLOT_TEST(xy_series_pyramid_matches_array_series_over_the_same_y) {
// The pyramid buckets by sample index, exactly as for a uniform series, so an
// XYSeries over x = i*dx must answer every exact query identically to the
// ArraySeries over the same y -- and the bucket aggregates must agree too.
constexpr u64 kN = 3000;
std::mt19937 rng(11);
std::uniform_real_distribution<float> d(-50.0f, 50.0f);
std::vector<f32> y(kN);
for (auto& v : y) v = d(rng);
std::vector<f64> x(kN);
for (u64 i = 0; i < kN; ++i) x[i] = f64(i) * 0.5;
const plot::data::XYSeries xy(x, y);
const plot::data::ArraySeries uni(y, 0.0, 0.5);
PLOT_CHECK_NEAR(xy.x0(), uni.x0(), 1e-12);
for (u64 a = 0; a < kN; a += 211) {
for (u64 b = a + 1; b <= kN; b += 499) {
const plot::Range rx = xy.reduce(a, b);
const plot::Range ru = uni.reduce(a, b);
PLOT_CHECK_NEAR(rx.min, ru.min, 1e-6);
PLOT_CHECK_NEAR(rx.max, ru.max, 1e-6);
PLOT_CHECK_NEAR(xy.sum(a, b), uni.sum(a, b), 1e-6);
PLOT_CHECK_NEAR(xy.sumsq(a, b), uni.sumsq(a, b), 1e-6);
}
}
for (u32 l = 0; l < 3; ++l) {
PLOT_CHECK(xy.pyramid().level_base(l) == uni.pyramid().level_base(l));
PLOT_CHECK(xy.pyramid().level_size(l) == uni.pyramid().level_size(l));
for (u64 b = xy.pyramid().level_base(l);
b < xy.pyramid().level_base(l) + xy.pyramid().level_size(l); ++b) {
PLOT_CHECK_NEAR(xy.pyramid().bucket(l, b).min, uni.pyramid().bucket(l, b).min, 1e-6);
PLOT_CHECK_NEAR(xy.pyramid().bucket(l, b).max, uni.pyramid().bucket(l, b).max, 1e-6);
}
}
}
PLOT_TEST(xy_series_gather_envelope_matches_exact_reduces) {
// gather() buckets by index with the same B3 ceil/floor selection, but the x
// of every emitted point is the array's own coordinate relative to x0() --
// and each envelope pair equals the exact reduce over the samples its two x
// positions bracket.
constexpr u64 kN = 4096;
auto x = spread_x(kN);
const std::vector<f64> xs = x;
const std::vector<f32> ys = saw_y(kN);
plot::data::XYSeries s(std::move(x), ys);
const auto at = [&](u64 i) { return xs[static_cast<plot::usize>(i)]; };
const auto val = [&](u64 i) { return ys[static_cast<plot::usize>(i)]; };
const f64 x0 = at(0);
const u32 level = 2;
const u64 span = u64{kBucket} << level;
const u64 b = 500 + 33, e = 3500 - 57; // unaligned on the bucket grid
std::vector<f32> out;
s.gather(level, b, e, s.x0(), out, plot::data::GatherMode::Envelope);
PLOT_CHECK(out.size() >= 8u);
// Emitted x stays inside [x_at(begin), x_at(end)) and the geometry reaches
// both the range's extremes (the lead/trail raw spans carry them).
const plot::Range exact = s.reduce(b, e);
f64 emitted_min = 1e30, emitted_max = -1e30;
for (plot::usize i = 0; i + 1 < out.size(); i += 2) {
const f64 rel = f64(out[i]);
// The emitted x is f32(x_at(i) - x0()), so allow the f32 cast's slack
// (relative positions here reach ~1.4e4, where f32 ULP is ~1e-3).
PLOT_CHECK(rel >= at(b) - x0 - 1e-3);
PLOT_CHECK(rel <= at(e - 1) - x0 + 1e-3);
emitted_min = std::min(emitted_min, f64(out[i + 1]));
emitted_max = std::max(emitted_max, f64(out[i + 1]));
}
PLOT_CHECK_NEAR(emitted_min, exact.min, 1e-6);
PLOT_CHECK_NEAR(emitted_max, exact.max, 1e-6);
// Envelope pairs == exact reduce over their own spans. Walk the layout the
// gather contract fixes -- raw lead pairs, then two pairs per complete
// bucket, then raw trail pairs -- and read each pair's span off its x
// positions (all saw_y values are finite, so no bucket is skipped).
const u64 lend = s.pyramid().level_size(level);
const u64 first = std::min(b / span + (b % span == 0 ? u64{0} : u64{1}), lend);
const u64 last = std::min(e / span, lend);
const u64 lead = first * span;
const u64 buckets = last - first;
PLOT_CHECK(out.size() ==
2 * (lead - b + 2 * buckets + (e - last * span)));
plot::usize o = 0;
for (u64 i = b; i < lead; ++i, o += 2) { // the partial leading bucket, raw
PLOT_CHECK_NEAR(out[o], f32(at(i) - x0), 1e-3);
PLOT_CHECK_NEAR(out[o + 1], val(i), 1e-6);
}
for (u64 j = 0; j < buckets; ++j) {
const u64 bi = first + j;
const u64 lo = bi * span;
const u64 hi = std::min((bi + 1) * span, e) - 1;
const plot::Range r = s.reduce(lo, hi + 1);
PLOT_CHECK_NEAR(out[o], f32(at(lo) - x0), 1e-3);
PLOT_CHECK_NEAR(out[o + 1], r.min, 1e-6);
PLOT_CHECK_NEAR(out[o + 2], f32(at(hi) - x0), 1e-3);
PLOT_CHECK_NEAR(out[o + 3], r.max, 1e-6);
o += 4;
}
for (u64 i = last * span; i < e; ++i, o += 2) { // the partial trailing bucket
PLOT_CHECK_NEAR(out[o], f32(at(i) - x0), 1e-3);
PLOT_CHECK_NEAR(out[o + 1], val(i), 1e-6);
}
PLOT_CHECK(o == out.size());
}
PLOT_TEST(xy_series_nan_policy_matches_the_other_series) {
// Non-finite y: excluded from bounds and sums; an all-NaN window aggregates
// to zero-span / zero.
std::vector<f64> x{0.0, 1.0, 2.0, 3.0, 4.0, 5.0};
plot::data::XYSeries s(x, {std::nanf(""), 1.0f, 2.0f, std::nanf(""), 3.0f, std::nanf("")});
PLOT_CHECK_NEAR(s.y_bounds().min, 1.0, 1e-9);
PLOT_CHECK_NEAR(s.y_bounds().max, 3.0, 1e-9);
const plot::Range r = s.reduce(0, 6);
PLOT_CHECK_NEAR(r.min, 1.0, 1e-9);
PLOT_CHECK_NEAR(r.max, 3.0, 1e-9);
PLOT_CHECK_NEAR(s.sum(0, 6), 6.0, 1e-9);
PLOT_CHECK(s.reduce(4, 5).span() == 0.0);
plot::data::XYSeries all(x, std::vector<f32>(6, std::nanf("")));
PLOT_CHECK(all.y_bounds().span() == 0.0);
PLOT_CHECK(all.sum(0, 6) == 0.0);
PLOT_CHECK_NEAR(all.x_bounds().min, 0.0, 1e-12); // x bounds are the array's
PLOT_CHECK_NEAR(all.x_bounds().max, 5.0, 1e-12);
}
PLOT_TEST(xy_series_commit_rebuilds_and_reset_replaces) {
auto x = spread_x(256);
plot::data::XYSeries s(std::move(x), saw_y(256));
const plot::Range before = s.y_bounds();
std::vector<f64> x2{0.0, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0};
s.reset(std::move(x2), {5.0f, 3.0f, 1.0f, 4.0f, 4.0f, 4.0f, 4.0f, 4.0f});
PLOT_CHECK(s.size() == 8);
PLOT_CHECK_NEAR(s.y_bounds().min, 1.0, 1e-9);
PLOT_CHECK_NEAR(s.y_bounds().max, 5.0, 1e-9);
PLOT_CHECK_NEAR(s.x_bounds().max, 32.0, 1e-12);
PLOT_CHECK(s.y_bounds().max != before.max);
// reset() re-asserts the precondition too: the arrays above are sorted, so
// this passes; unsorted x is a debug-abort by contract (see the header) and
// is deliberately not exercised here.
}