Compare commits
24
Commits
98cfc5805b
...
0.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc908efa36 | ||
|
|
e81e84645f | ||
|
|
bee318870f | ||
|
|
58473db384 | ||
|
|
c2c8c596f3 | ||
|
|
59c2e45af7 | ||
|
|
03c41adbec | ||
|
|
c960decaea | ||
|
|
c028f48fa0 | ||
|
|
26a7c82a80 | ||
|
|
0ee6e10c81 | ||
|
|
faef19ba60 | ||
|
|
7dbe4e7183 | ||
|
|
fba8d985cf | ||
|
|
c4c0a49466 | ||
|
|
01e410bd74 | ||
|
|
f3fb500068 | ||
|
|
2dcd1f1864 | ||
|
|
211c5b97bc | ||
|
|
125cc1f64a | ||
|
|
523446d08f | ||
|
|
133cd680ce | ||
|
|
e337c6a60c | ||
|
|
525759f9a9 |
No files matched your search
@@ -14,6 +14,11 @@ built on top of it.
|
|||||||
| Qt6 | `plot::qt6` | `adapters/qt6/` |
|
| Qt6 | `plot::qt6` | `adapters/qt6/` |
|
||||||
| GTK4 | `plot::gtk4` | `adapters/gtk4/` |
|
| GTK4 | `plot::gtk4` | `adapters/gtk4/` |
|
||||||
|
|
||||||
|
The visible x range is mapped onto sample indices through the series' own x
|
||||||
|
(`Series::neighbours()`, a binary search over the real stamps), never through the
|
||||||
|
`(x - x0()) / dx()` grid: `dx()` is a *nominal* spacing, and a series whose x is a streamed
|
||||||
|
timestamp would otherwise have its draw window land inside the visible range and lose the two
|
||||||
|
segments that enter and leave the frame — the empty left and right margins of a zoomed-in view.
|
||||||
Per-frame rendering cost is O(visible pixels + samples produced since the last frame),
|
Per-frame rendering cost is O(visible pixels + samples produced since the last frame),
|
||||||
not O(samples): a trace denser than one sample per pixel is drawn from a min/max LOD
|
not O(samples): a trace denser than one sample per pixel is drawn from a min/max LOD
|
||||||
pyramid rather than from raw samples. The `plot_multiplot_demo` run stages **20,901 GPU
|
pyramid rather than from raw samples. The `plot_multiplot_demo` run stages **20,901 GPU
|
||||||
@@ -24,13 +29,39 @@ run touched a GPU, so read it as an upper bound and a record of the software pat
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
- a trace whose series answers a *clamped* stamp for its oldest slots -- a ring whose published
|
||||||
|
window is pinned while it slides -- is drawn from its longest increasing *suffix*, so an unreadable
|
||||||
|
front cannot draw a segment from the newest sample back to the oldest (a line across the whole
|
||||||
|
panel);
|
||||||
|
- a following group's window stays on its members' data: while the members retain at least a
|
||||||
|
window's worth, the scroll offset is bounded by `retained - span`, so a pan or a zoom-in near an
|
||||||
|
edge cannot leave the window's own span over data the scope never had (a window *wider* than the
|
||||||
|
data keeps the older placement -- right edge on the newest sample);
|
||||||
|
- a trace whose gathered window is denser than the axis is reduced **onto the pixel grid** --
|
||||||
|
one snapped vertical segment per integer column, lit from that column's own extremes --
|
||||||
|
because an LOD envelope staged as a polyline puts three or four near-vertical hairlines per
|
||||||
|
column at as many sub-pixel phases, and the beat between the bucket pitch and the pixel pitch
|
||||||
|
is a crawling moiré; one segment per column leaves one phase, so there is nothing to beat
|
||||||
|
(`FrameStats::column_traces`, and `docs/data-and-performance.md` for the four rules it keeps);
|
||||||
|
- and such a column can be drawn **graded by dwell** instead of as one flat band
|
||||||
|
(`Style::intensity_grading`, off by default, `FrameStats::graded_traces`): bright where the
|
||||||
|
signal sits, faint where it only passes through, the way a digital-phosphor scope reads. It is
|
||||||
|
what puts back the information the reduction throws away — a column's min and max are the same
|
||||||
|
whether its samples fill the span or pile at its two ends — so the gather is asked at a level
|
||||||
|
finer than the pixel grid and the distribution read from those sub-buckets;
|
||||||
|
- sample **markers** when a window is sparse enough that its samples are the shape
|
||||||
|
(`Style::marker_below_samples`, 50 by default, `marker_px` across, in the trace's own colour);
|
||||||
- grid with independent major/minor line classes (`Style::grid_major_visible`,
|
- grid with independent major/minor line classes (`Style::grid_major_visible`,
|
||||||
`grid_minor_visible`);
|
`grid_minor_visible`), each with its own colour, width and (for the minors) dash — the
|
||||||
|
subdivisions default to thinner, dashed and alpha-blended, so they read as texture under the
|
||||||
|
labelled grid rather than as a second set of ticks;
|
||||||
- x and y axes, with a vertical (bottom-to-top) y-axis title;
|
- x and y axes, with a vertical (bottom-to-top) y-axis title;
|
||||||
- cursors in both orientations — vertical markers and horizontal rulers;
|
- cursors in both orientations — vertical markers and horizontal rulers;
|
||||||
- draggable regions and measurement bands with exact interval statistics;
|
- draggable regions and measurement bands with exact interval statistics;
|
||||||
- a signal legend anchored to an axes corner;
|
- a signal legend anchored to an axes corner;
|
||||||
- a hover crosshair with a per-signal, interpolated readout;
|
- a hover crosshair with a per-signal readout, quoting the nearest sample and marking it
|
||||||
|
(`CrosshairValue::NearestSample`, the default) or interpolating between two
|
||||||
|
(`CrosshairValue::Interpolated`);
|
||||||
- a multi-plot `Figure` of stacked rows sharing one synchronised x axis;
|
- a multi-plot `Figure` of stacked rows sharing one synchronised x axis;
|
||||||
- live follow (`XFollow::Live`) with relative (multiplicative) zoom and mouse panning;
|
- live follow (`XFollow::Live`) with relative (multiplicative) zoom and mouse panning;
|
||||||
- XY series for irregularly sampled data (`plot/data/xy_series.hpp`): sorted-ascending x
|
- XY series for irregularly sampled data (`plot/data/xy_series.hpp`): sorted-ascending x
|
||||||
|
|||||||
+130
-12
@@ -166,10 +166,120 @@ series therefore stages O(visible pixels) vertices, not O(samples), and `FrameSt
|
|||||||
which path each trace took. Below the threshold the raw samples are drawn instead; both
|
which path each trace took. Below the threshold the raw samples are drawn instead; both
|
||||||
sides go through the same instanced anti-aliased quad pipeline.
|
sides go through the same instanced anti-aliased quad pipeline.
|
||||||
|
|
||||||
## Exact versus interpolated
|
## Dense traces are reduced onto the pixel grid
|
||||||
|
|
||||||
The library deliberately holds two conventions next to each other, and they do **not**
|
A gathered window denser than the axis is **not** staged as a polyline. For each integer
|
||||||
agree:
|
column of `layout.axes`, `Renderer::draw_traces` emits one exactly vertical segment from the
|
||||||
|
lowest to the highest screen y any gathered point put in that column, flushed in framebuffer
|
||||||
|
pixels with `u_snap` on. `FrameStats::column_traces` counts the traces that took this path.
|
||||||
|
|
||||||
|
It exists because a polyline over such a window **moirés**, and three things compound to make
|
||||||
|
it. `MinMaxPyramid::level_for()` picks the level whose buckets hold about
|
||||||
|
`samples_per_pixel` samples, so a bucket spans **one to two pixel columns**;
|
||||||
|
`GatherMode::Envelope` emits two points per bucket, so each bucket contributes a near-vertical
|
||||||
|
min→max hairline *and* a near-vertical connector to the next — about 3.6 near-vertical
|
||||||
|
hairlines per column; and each of those lands at a **fractional** x, so with ordinary
|
||||||
|
src-alpha compositing a column's brightness is a non-linear function of the sub-pixel phases
|
||||||
|
overlapping it. The phase pattern repeats at the beat between the bucket pitch and the pixel
|
||||||
|
pitch, which is a visible banding, and panning sweeps the beat so it crawls. Reducing to one
|
||||||
|
segment per column leaves the trace **one** sub-pixel phase, so there is nothing left to beat,
|
||||||
|
and the snap then puts that one phase on the grid.
|
||||||
|
|
||||||
|
Four properties of the reduction are load-bearing:
|
||||||
|
|
||||||
|
- **It is gated on the gathered *density*, not on the level.** It runs when the window is
|
||||||
|
`GatherMode::Envelope` and carries at least two gathered points per column of the axes —
|
||||||
|
the raw path at 1.5–64 samples per pixel has the same beat and the same cure. The density
|
||||||
|
term is also what keeps it away from a *sparse* window, whose trace is drawn by the two
|
||||||
|
segments that enter and leave the frame (`a_sparse_window_between_samples_still_inks_both_margins`);
|
||||||
|
`Nearest` and `Mean` are excluded because they are one-point-per-bucket modes a band defeats.
|
||||||
|
- **A hole stays a hole.** A column no gathered point landed in emits nothing. Bridging it
|
||||||
|
would draw a vertical line over x the series says nothing about.
|
||||||
|
- **Disjoint columns are joined.** Two neighbouring columns' extremes need not overlap — a
|
||||||
|
fast edge rises through a whole column — and two disjoint vertical segments read as a broken
|
||||||
|
trace, which is what the polyline's connector prevented. The later column is extended to
|
||||||
|
meet the earlier one's nearest bound, so the join is made inside the column that moved.
|
||||||
|
- **A span shorter than the stroke is drawn *as* the stroke.** The line fragment shader ramps
|
||||||
|
on the distance to the segment's axis and does not cap its ends, so a vertical segment
|
||||||
|
shorter than its own width rasterises a sliver of itself, and a flat trace's column, where
|
||||||
|
the two bounds are equal, nothing at all.
|
||||||
|
|
||||||
|
This is the **one carve-out** in `u_snap`'s rule that chrome may be snapped and data may not
|
||||||
|
(see `LineProgram::set_snap()`): a column *is* a pixel column and its x is already a screen
|
||||||
|
coordinate, so there is no data position to move it off, and the y bounds the segment reports
|
||||||
|
are untouched by the snap.
|
||||||
|
|
||||||
|
`a_dense_trace_is_drawn_as_whole_pixel_columns` is the guard, and what it measures is the
|
||||||
|
**number of distinct coverage levels** in the ink: 4 with the reduction against 126 with the
|
||||||
|
polyline, over one sine period across 60 000 samples. A count of *partially covered pixels* is
|
||||||
|
**not** the reading and was tried first — a 1.5 px stroke with a 1 px anti-aliasing fringe
|
||||||
|
always spills into its neighbour column, so the two renderings measure 577 and 652, which
|
||||||
|
separates nothing. The frame's whole geometry collapses with it: 627 segments against 60 054.
|
||||||
|
|
||||||
|
## A reduced column can be graded by dwell
|
||||||
|
|
||||||
|
`Style::intensity_grading` draws each of those columns **graded** rather than as one flat band:
|
||||||
|
bright where the signal sits, faint where it only passes through, the way a digital-phosphor
|
||||||
|
scope reads. It is off by default — a change of look, not a correction of one — and
|
||||||
|
`FrameStats::graded_traces` counts the traces that took it, a subset of `column_traces`, since
|
||||||
|
grading is a way of drawing the reduction and not a third path.
|
||||||
|
|
||||||
|
What it puts back is the information the reduction throws away: a column's min and max are the
|
||||||
|
**same** whether its samples fill the span evenly or pile at its two ends, so a ramp and a pulse
|
||||||
|
are one band. The distribution cannot be recovered from the band, which is why the gather is
|
||||||
|
asked at a level **finer than the pixel grid** (`intensity_sub_columns` sub-buckets per column)
|
||||||
|
and the dwell read from those sub-buckets.
|
||||||
|
|
||||||
|
Five things carry it, and each was arrived at by being wrong first:
|
||||||
|
|
||||||
|
- **The measure is dwell per row, each segment depositing one unit spread over the rows it
|
||||||
|
spans** (`1 / height`). Counting +1 per row instead grades a sine's fast middle as brightly as
|
||||||
|
its rails, which is the pattern inverted — the middle is where the signal spends the *least*
|
||||||
|
time.
|
||||||
|
- **The accumulator is one column wide.** Gathered x is monotonic, so points arrive column by
|
||||||
|
column: accumulate, read, clear. A full `axes.w × axes.h` density image is 33 MB at 4K; one
|
||||||
|
column of `axes.h` floats is L1-resident. A span is written as a `+1/height` delta at its first
|
||||||
|
row and the negation one past its last, so it costs two writes whatever its height, and the
|
||||||
|
running sum over the column is the per-row dwell.
|
||||||
|
- **Normalisation is per column, against the column's own peak.** A frame-wide scale needs the
|
||||||
|
whole image to exist before anything can be drawn, *and* reads worse: a column the signal flew
|
||||||
|
through uniformly comes out faint at every row, so the trace fades out exactly where it moves
|
||||||
|
fastest. The cost of the local scale is one extra non-clearing walk of the accumulator to find
|
||||||
|
the peak.
|
||||||
|
- **Alpha is quantised into `intensity_steps` tiers, one `LineBatch` and one draw call each.**
|
||||||
|
The line pipeline's colour is a **uniform**, not per-instance (`LineInstance` is four floats of
|
||||||
|
geometry; only `TextInstance` carries `a_colour`), so a per-segment alpha is inexpressible and
|
||||||
|
the tiers are what make the gradient drawable at all. `intensity_floor` keeps the faintest tier
|
||||||
|
visible — a fast riser is one sub-bucket deep everywhere and must not be dimmed out of
|
||||||
|
existence — and `intensity_gamma` lifts the faint end. A connector between two disjoint columns
|
||||||
|
is emitted at the **faintest** tier: it is inferred geometry, not a place the signal was
|
||||||
|
measured to dwell.
|
||||||
|
- **Grading may not undo the reduction.** `level_for()` answers 0 — raw samples — as soon as the
|
||||||
|
asked-for fraction of a column falls under one bucket (`kBucket` is 64), so the finer ask is
|
||||||
|
**floored at level 1** whenever the flat path would have used the pyramid. Without the floor
|
||||||
|
every moderately dense frame became an O(visible) gather: measured in udpscope at 1.3 MS/s over
|
||||||
|
a 2 s window, 172 samples a pixel, the flat path sat on the pyramid and grading dropped the
|
||||||
|
whole figure to raw (`raw=1154 lod=0` against `raw=710 lod=453`) and cost 2.3 → 6.4 ms of draw
|
||||||
|
mean. The floor degrades the *look* instead — fewer sub-columns than asked for, down to what the
|
||||||
|
coarsest buckets give, which at 64 samples a bucket is still two or three per column. That is
|
||||||
|
also why raising `intensity_sub_columns` past about 8 buys nothing: the bucket size is the
|
||||||
|
limit, not the number.
|
||||||
|
|
||||||
|
`a_dense_trace_can_be_graded_by_dwell` is the guard, and it draws the same frame both ways. It
|
||||||
|
profiles the mean peak coverage over the band's two outer fifths against its middle fifth: the
|
||||||
|
flat band is flat (**191 and 191**, one ink) and the graded one is **191 against 131**, a middle
|
||||||
|
at 0.69 of the edge. That ratio is a *tier* ratio and not the dwell ratio — the default gamma
|
||||||
|
0.45 and floor 0.3 compress it on purpose. The geometry stays bounded by the axis and not by its
|
||||||
|
area: **8303 segments over a 572 px axis**, 14.5 runs a column, against the flat path's 627 at
|
||||||
|
one end and `572 × 156` rows at the other. A fourth frame over a window wide enough that the
|
||||||
|
flat path is on the pyramid is what exercises the floor (`raw=0 lod=1 … seg=634
|
||||||
|
visible=200001`); the test's first window sits at 105 samples a pixel, where both paths are raw
|
||||||
|
and the floor is never reached.
|
||||||
|
|
||||||
|
## One sample or a statistic of many
|
||||||
|
|
||||||
|
Every number the library reports is one the samples actually hold. What differs between
|
||||||
|
these two is *how many* samples answer the question:
|
||||||
|
|
||||||
- **`Series::reduce` / `sum` / `sumsq` are exact over a sample range.** They answer "what
|
- **`Series::reduce` / `sum` / `sumsq` are exact over a sample range.** They answer "what
|
||||||
are the samples' extremes / sum / sum of squares over `[begin, end)`". Each is
|
are the samples' extremes / sum / sum of squares over `[begin, end)`". Each is
|
||||||
@@ -179,15 +289,23 @@ agree:
|
|||||||
and no query allocates. `Measure::evaluate` and `Plot::region_stats` are built on these,
|
and no query allocates. `Measure::evaluate` and `Plot::region_stats` are built on these,
|
||||||
never on the `*_buckets` forms, which would silently report wrong extremes for an
|
never on the `*_buckets` forms, which would silently report wrong extremes for an
|
||||||
unaligned interval.
|
unaligned interval.
|
||||||
- **The crosshair readout is interpolated, and says so.** `crosshair_span(series, x)`
|
- **The crosshair readout is a single sample — the nearest one — by default.**
|
||||||
returns the two samples `x` lies between (`begin`, `end`, `t`), and
|
`crosshair_span(series, x)` returns the two samples `x` lies between (`begin`, `end`, `t`),
|
||||||
`crosshair_interpolate(y_begin, y_end, t)` does a linear interpolation: it answers "what
|
and `CrosshairValue::NearestSample` takes whichever of the two is nearer: `t <= 0.5` the
|
||||||
is the signal *at* this instant", which is generally between two samples — a different
|
earlier, otherwise the later. It answers "which datum is under the pointer", not "what is
|
||||||
question from an interval statistic. The lookup is O(1) (one division and a floor) and
|
the signal *at* this instant" — a linearly interpolated y is a number the series does not
|
||||||
allocation-free; `Plot::crosshair_value(trace, x, out_y)` exposes the same number to a
|
contain, and on a source whose samples are already averaged or decimated at ingest it is a
|
||||||
host. A row whose trace has no sample at the crosshair's x shows `kCrosshairNoValue`
|
claim about a signal nobody measured. That is why it is the default; a host whose x is a
|
||||||
(an em dash, `"—"`), and an x outside the series window yields no value rather than the
|
quantity it is willing to interpolate states `CrosshairValue::Interpolated` and gets
|
||||||
nearest sample's.
|
`crosshair_interpolate(y_begin, y_end, t)` instead, at the crosshair's own x. The lookup is
|
||||||
|
O(1) and allocation-free either way, and it reads level 0, so the value is a stored sample
|
||||||
|
(or a blend of two) and never an LOD bucket's aggregate. `Plot::crosshair_point(trace, x,
|
||||||
|
out_x, out_y)` exposes **both** coordinates to a host and `crosshair_value()` the y alone;
|
||||||
|
`CrosshairRow::x`/`y` and its `marker` are placed from that same pair, so in
|
||||||
|
`NearestSample` the mark snaps onto the quoted sample and the dot and the number cannot
|
||||||
|
disagree. A row whose trace has no sample at the crosshair's x shows `kCrosshairNoValue`
|
||||||
|
(an em dash, `"—"`), and an x outside the series window yields no value rather than
|
||||||
|
clamping to the first or last sample.
|
||||||
|
|
||||||
`Measurement` (from `Plot::measurement()`) documents `integral` as a **left-Riemann sum**,
|
`Measurement` (from `Plot::measurement()`) documents `integral` as a **left-Riemann sum**,
|
||||||
`sum(begin, end) * dx` — the samples taken as the value at the left edge of each step. That
|
`sum(begin, end) * dx` — the samples taken as the value at the left edge of each step. That
|
||||||
|
|||||||
+34
-3
@@ -106,6 +106,7 @@ one per plot.
|
|||||||
- `set_crosshair_readout(bool)` switches the panel off — or back on — without moving the
|
- `set_crosshair_readout(bool)` switches the panel off — or back on — without moving the
|
||||||
crosshair. `set_crosshair_style(const CrosshairStyle&)` overrides this plot's stroke and
|
crosshair. `set_crosshair_style(const CrosshairStyle&)` overrides this plot's stroke and
|
||||||
dash.
|
dash.
|
||||||
|
- `set_crosshair_value_mode(CrosshairValue)` chooses what a readout *is* — see below.
|
||||||
|
|
||||||
In a `Figure`, the crosshair is applied to **every row** (one shared `x`): each row's readout
|
In a `Figure`, the crosshair is applied to **every row** (one shared `x`): each row's readout
|
||||||
lists its own traces at that instant. `Figure::set_crosshair(x, active)` is vertical-only
|
lists its own traces at that instant. `Figure::set_crosshair(x, active)` is vertical-only
|
||||||
@@ -113,9 +114,39 @@ across the figure, and `Figure::set_crosshair(x, hovered_row, y_in_row, active)`
|
|||||||
horizontal line **only in the row the pointer is in** — a figure's rows share x and not y, so
|
horizontal line **only in the row the pointer is in** — a figure's rows share x and not y, so
|
||||||
a figure-wide horizontal line would be a value that means something different in each row.
|
a figure-wide horizontal line would be a value that means something different in each row.
|
||||||
|
|
||||||
Readout values are **interpolated**, not interval statistics (see
|
### What a readout is: the two value modes
|
||||||
[data-and-performance.md](data-and-performance.md)); a row whose trace has no sample at the
|
|
||||||
crosshair's x is listed with the em dash `"—"` and no marker, rather than a fabricated value.
|
`Crosshair::value_mode` (`set_crosshair_value_mode()`) answers two different questions, and
|
||||||
|
which one a caller is asking decides the mode:
|
||||||
|
|
||||||
|
| mode | the value | the marker | answers |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `CrosshairValue::NearestSample` (default) | the nearer bracketing sample's y (a tie reads the earlier) | **snapped to that sample's own x** | *which datum is under the pointer?* |
|
||||||
|
| `CrosshairValue::Interpolated` | a linear blend of the two bracketing samples | at the crosshair's own x | *what is the signal at this instant?* |
|
||||||
|
|
||||||
|
The default is the sample, because a readout that quotes a number the data does not contain is
|
||||||
|
a second estimate laid over the first — and once the view is zoomed past the sample spacing the
|
||||||
|
polyline between two samples is a line the *renderer* drew, so reading a value off it reports
|
||||||
|
the drawing rather than the signal. A host whose x is a continuous quantity it is willing to
|
||||||
|
interpolate (a smooth analytic curve, a resampled series) states `Interpolated` and gets the
|
||||||
|
blend, exactly at the pointer. Either way the value is a **stored** sample or a blend of two of
|
||||||
|
them, never an LOD bucket's aggregate (see
|
||||||
|
[data-and-performance.md](data-and-performance.md)).
|
||||||
|
|
||||||
|
`CrosshairRow` carries both halves: `x` and `y` are the data coordinate the value belongs to —
|
||||||
|
the quoted sample's own in `NearestSample`, the crosshair's x in `Interpolated` — and `marker`
|
||||||
|
is that point in screen space. `Plot::crosshair_point(trace, x, out_x, out_y)` is the same
|
||||||
|
lookup for a host that draws instruments of its own; `Plot::crosshair_value()` is it with the x
|
||||||
|
discarded.
|
||||||
|
|
||||||
|
The mark is drawn `Style::crosshair_marker_px` across (9 logical px) at
|
||||||
|
`crosshair_marker_width`, both deliberately **larger than a trace's own sample dots**
|
||||||
|
(`marker_px`, 3): in `NearestSample` the mark lands *among* those dots, and at the same size it
|
||||||
|
would read as one more sample instead of as the one the panel is quoting. `0` across draws no
|
||||||
|
mark.
|
||||||
|
|
||||||
|
A row whose trace has no sample at the crosshair's x is listed with the em dash `"—"` and no
|
||||||
|
marker, rather than a fabricated value.
|
||||||
|
|
||||||
## The gesture table (exactly as implemented)
|
## The gesture table (exactly as implemented)
|
||||||
|
|
||||||
|
|||||||
+30
-3
@@ -36,8 +36,8 @@ Colours (neither logical nor device — nothing to scale):
|
|||||||
| field | default | meaning |
|
| field | default | meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `background` | `0x101418` | frame clear colour and legend/readout panel base |
|
| `background` | `0x101418` | frame clear colour and legend/readout panel base |
|
||||||
| `grid_major` | `0x2A3038` | major (labelled-tick) grid lines |
|
| `grid_major` | `0x39414C` | major (labelled-tick) grid lines |
|
||||||
| `grid_minor` | `0x1C2126` | minor (subdivision) grid lines |
|
| `grid_minor` | `0x1C2126` at 50% alpha | minor (subdivision) grid lines |
|
||||||
| `axis_line` | `0x8A94A6` | axis lines |
|
| `axis_line` | `0x8A94A6` | axis lines |
|
||||||
| `axis_text` | `0xC8D0DC` | tick labels and axis titles |
|
| `axis_text` | `0xC8D0DC` | tick labels and axis titles |
|
||||||
| `cursor` | `0xFFB020` | cursor markers; also the hovered/grab highlight |
|
| `cursor` | `0xFFB020` | cursor markers; also the hovered/grab highlight |
|
||||||
@@ -56,8 +56,12 @@ Logical metrics (scaled by `device()`):
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `axis_font_px` | `12.0` | logical em the axis text is drawn at |
|
| `axis_font_px` | `12.0` | logical em the axis text is drawn at |
|
||||||
| `grid_label_pad` | `4.0` | gap between a tick label and the axes, and the inset inside a title strip |
|
| `grid_label_pad` | `4.0` | gap between a tick label and the axes, and the inset inside a title strip |
|
||||||
|
| `marker_below_samples` | `50` | a dot per raw sample when the window holds at most this many (`0` = never) |
|
||||||
|
| `marker_px` | `3.0` | logical size of that dot |
|
||||||
| `grid_major_width` | `1.0` | width of a major grid line |
|
| `grid_major_width` | `1.0` | width of a major grid line |
|
||||||
| `grid_minor_width` | `1.0` | width of a minor grid line |
|
| `grid_minor_width` | `0.75` | width of a minor grid line (sub-pixel on purpose) |
|
||||||
|
| `grid_minor_dash_on` | `3.0` | minor grid dash: ink length in logical pixels |
|
||||||
|
| `grid_minor_dash_off` | `3.0` | minor grid dash: gap; `0` draws the minor grid solid |
|
||||||
| `grid_major_visible` | `true` | stage the major grid at all |
|
| `grid_major_visible` | `true` | stage the major grid at all |
|
||||||
| `grid_minor_visible` | `true` | stage the minor grid at all |
|
| `grid_minor_visible` | `true` | stage the minor grid at all |
|
||||||
| `line_width_px` | `1.5` | width of the strokes framing the data (axis lines, cursor markers) |
|
| `line_width_px` | `1.5` | width of the strokes framing the data (axis lines, cursor markers) |
|
||||||
@@ -91,6 +95,29 @@ transparent. `Grid` therefore carries both classes' geometry whatever the style
|
|||||||
caller that draws the grid itself applies the same two switches. `Grid::vertical` and
|
caller that draws the grid itself applies the same two switches. `Grid::vertical` and
|
||||||
`Grid::horizontal` (both `true`) control which directions emit lines at all.
|
`Grid::horizontal` (both `true`) control which directions emit lines at all.
|
||||||
|
|
||||||
|
The labelled class is the **structure** and the subdivisions are **texture**, and the minor
|
||||||
|
grid's three defaults are what makes that read: it is thinner than the major hairline
|
||||||
|
(`grid_minor_width`, a sub-pixel width that at 1x is expressed as coverage and on a HiDPI
|
||||||
|
display as a narrower line), its colour carries an **alpha** so it blends into the
|
||||||
|
background instead of sitting on it, and it is **dashed** (`grid_minor_dash_on`/`_off`) so an
|
||||||
|
unlabelled line at an unlabelled position is never mistaken for a labelled one. Together
|
||||||
|
those put a minor line at an order of magnitude less ink than a major one — at full opacity and the
|
||||||
|
same width a dense minor grid reads as a second set of ticks. The dash is a fragment-shader
|
||||||
|
discard, not extra geometry: the vertices are the line either way. Set
|
||||||
|
`grid_minor_dash_off = 0` for the old solid subdivision.
|
||||||
|
|
||||||
|
## A cursor's stroke
|
||||||
|
|
||||||
|
`set_cursor_color()` gives a cursor its own colour and `set_cursor_stroke()` its own width and
|
||||||
|
dash (`Cursor::width`, `Cursor::dash`). Per cursor rather than per plot, because a host uses
|
||||||
|
cursors for two different jobs: the instruments that frame a reading — a marker at an edge, a pull
|
||||||
|
handle — and the *readout marks* a computation places on the data (a measured level, a peak, a
|
||||||
|
pulse's edges). Those want different weights. `width <= 0` means the style's default for that
|
||||||
|
cursor (`handle_width_px` while it is hovered or grabbed, `line_width_px` otherwise), and a hover
|
||||||
|
can only make the stroke *thicker*: the cue that the pointer has hold of something must not be the
|
||||||
|
thing that hides it. `dash.off <= 0` (the default) is solid — the same convention as a trace's,
|
||||||
|
the crosshair's and the grid's.
|
||||||
|
|
||||||
## The crosshair's dash
|
## The crosshair's dash
|
||||||
|
|
||||||
`crosshair_dash_on`/`crosshair_dash_off` are the fragment shader's discard pattern, so no
|
`crosshair_dash_on`/`crosshair_dash_off` are the fragment shader's discard pattern, so no
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class ArraySeries final : public Series {
|
|||||||
Range x_bounds() const override;
|
Range x_bounds() const override;
|
||||||
Range y_bounds() const override;
|
Range y_bounds() const override;
|
||||||
const MinMaxPyramid& pyramid() const override;
|
const MinMaxPyramid& pyramid() const override;
|
||||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||||
GatherMode mode = GatherMode::Envelope) const override;
|
GatherMode mode = GatherMode::Envelope) const override;
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
|
|||||||
@@ -59,12 +59,20 @@ class Series {
|
|||||||
|
|
||||||
/// Appends interleaved (x, y) pairs for level `level`.
|
/// Appends interleaved (x, y) pairs for level `level`.
|
||||||
///
|
///
|
||||||
/// **x is relative to `x0()`**: the emitted value is `x_at(i) - x0()` as
|
/// **x is relative to `x_origin`**: the emitted value is `x_at(i) - x_origin` as f32, never
|
||||||
/// f32, never the absolute coordinate. A consumer reconstructs the absolute
|
/// the absolute coordinate. A consumer reconstructs the absolute x in f64
|
||||||
/// x in f64 (`x0() + emitted`) before mapping it to the screen; staging an
|
/// (`x_origin + emitted`) before mapping it to the screen; staging an absolute epoch-time x
|
||||||
/// absolute epoch-time x as f32 would quantise it to a grid coarser than
|
/// as f32 would quantise it to a grid coarser than the pixel (f32 carries 24 bits -- 1 s at
|
||||||
/// the pixel (f32 carries 24 bits -- 1 s at 1.7e9), while the relative
|
/// 1.7e9). y is unchanged.
|
||||||
/// value `i*dx` is small and exact. y is unchanged.
|
///
|
||||||
|
/// **The caller names the origin because only the caller knows the span the f32 has to be
|
||||||
|
/// exact against.** The origin was `x0()` -- the start of the series' *retained window* --
|
||||||
|
/// and a relative value's ULP is then a fraction of the whole window however little of it is
|
||||||
|
/// on screen: 1.9 us over 20 s of retained data, which at a view 10 us wide is more than a
|
||||||
|
/// hundred pixels between where a sample is *drawn* and the x a readout quotes for the same
|
||||||
|
/// sample (`x_at()`, exact f64). Passing the visible range's minimum instead makes the error
|
||||||
|
/// 2^-24 of the *visible span* -- a fixed 4e-5 of a pixel over a 700 px axis -- at every
|
||||||
|
/// zoom. A caller with no view in mind may pass `x0()` and get the old behaviour.
|
||||||
///
|
///
|
||||||
/// Level 0 emits the raw samples of [begin, end). Level >= 1 emits one
|
/// Level 0 emits the raw samples of [begin, end). Level >= 1 emits one
|
||||||
/// geometry element per *complete* level bucket wholly inside the retained
|
/// geometry element per *complete* level bucket wholly inside the retained
|
||||||
@@ -85,7 +93,7 @@ class Series {
|
|||||||
/// level_for() picks), and never reaches outside the retained window -- nor
|
/// level_for() picks), and never reaches outside the retained window -- nor
|
||||||
/// outside [begin, end). Pairs are appended to out_xy, which is never
|
/// outside [begin, end). Pairs are appended to out_xy, which is never
|
||||||
/// cleared.
|
/// cleared.
|
||||||
virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
virtual void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||||
GatherMode mode = GatherMode::Envelope) const = 0;
|
GatherMode mode = GatherMode::Envelope) const = 0;
|
||||||
|
|
||||||
/// Absolute x of sample `i` (`x0() + i*dx()` on a uniform-grid series).
|
/// Absolute x of sample `i` (`x0() + i*dx()` on a uniform-grid series).
|
||||||
@@ -97,7 +105,7 @@ class Series {
|
|||||||
/// lands exactly on a sample). False when x lies outside the series' x
|
/// lands exactly on a sample). False when x lies outside the series' x
|
||||||
/// bounds; `i0`/`i1` are then untouched. The crosshair and measurement
|
/// bounds; `i0`/`i1` are then untouched. The crosshair and measurement
|
||||||
/// paths read coordinates only through this and `x_at()`, so a series with
|
/// paths read coordinates only through this and `x_at()`, so a series with
|
||||||
/// its own x array interpolates exactly.
|
/// its own x array brackets exactly.
|
||||||
virtual bool neighbours(f64 x, u64& i0, u64& i1) const;
|
virtual bool neighbours(f64 x, u64& i0, u64& i1) const;
|
||||||
|
|
||||||
/// Index of the sample nearest data-space `x`, clamped to `[0, size()-1]`;
|
/// Index of the sample nearest data-space `x`, clamped to `[0, size()-1]`;
|
||||||
@@ -121,7 +129,7 @@ class Series {
|
|||||||
|
|
||||||
/// The shared body of the concrete gather() overrides: raw samples at level 0,
|
/// The shared body of the concrete gather() overrides: raw samples at level 0,
|
||||||
/// bucket geometry above it (per `mode`).
|
/// bucket geometry above it (per `mode`).
|
||||||
void gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
void gather_impl(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||||
GatherMode mode) const;
|
GatherMode mode) const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
@@ -143,7 +151,7 @@ class Series {
|
|||||||
/// 0 is entirely this, and a level >= 1 gather uses it for the two partial edge
|
/// 0 is entirely this, and a level >= 1 gather uses it for the two partial edge
|
||||||
/// buckets a pyramid of complete buckets cannot answer for. Precondition:
|
/// buckets a pyramid of complete buckets cannot answer for. Precondition:
|
||||||
/// begin < end, end <= size().
|
/// begin < end, end <= size().
|
||||||
void gather_raw(u64 begin, u64 end, std::vector<f32>& out_xy) const;
|
void gather_raw(u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy) const;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace plot::data
|
} // namespace plot::data
|
||||||
@@ -92,7 +92,7 @@ class StreamSeries final : public Series {
|
|||||||
Range x_bounds() const override;
|
Range x_bounds() const override;
|
||||||
Range y_bounds() const override;
|
Range y_bounds() const override;
|
||||||
const MinMaxPyramid& pyramid() const override;
|
const MinMaxPyramid& pyramid() const override;
|
||||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||||
GatherMode mode = GatherMode::Envelope) const override;
|
GatherMode mode = GatherMode::Envelope) const override;
|
||||||
/// Always true: this series' `x_bounds()` is the retained ring window, which
|
/// Always true: this series' `x_bounds()` is the retained ring window, which
|
||||||
/// slides with every `acquire()` -- the property `XFollow::Live` follows.
|
/// slides with every `acquire()` -- the property `XFollow::Live` follows.
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ class XYSeries final : public Series {
|
|||||||
Range x_bounds() const override;
|
Range x_bounds() const override;
|
||||||
Range y_bounds() const override;
|
Range y_bounds() const override;
|
||||||
const MinMaxPyramid& pyramid() const override;
|
const MinMaxPyramid& pyramid() const override;
|
||||||
/// Same contract as `Series::gather`, with x emitted relative to `x0()`.
|
/// Same contract as `Series::gather`, with x emitted relative to `x_origin`.
|
||||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||||
GatherMode mode = GatherMode::Envelope) const override;
|
GatherMode mode = GatherMode::Envelope) const override;
|
||||||
|
|
||||||
/// The sample's own x.
|
/// The sample's own x.
|
||||||
|
|||||||
@@ -52,6 +52,23 @@ struct Axis {
|
|||||||
AxisPlace place = AxisPlace::Bottom;
|
AxisPlace place = AxisPlace::Bottom;
|
||||||
bool log = false;
|
bool log = false;
|
||||||
TickFormat format = TickFormat::Si;
|
TickFormat format = TickFormat::Si;
|
||||||
|
/// The value the labels are written *relative to*: every value on this axis is formatted as
|
||||||
|
/// `v - origin`, and the ticks are generated on that shifted grid. A host uses it to read a
|
||||||
|
/// zoomed-in window against its own left edge -- the labels then say `0, 0.5ms, 1ms` with the
|
||||||
|
/// window's start printed once beside the axis, instead of `12.345671s, 12.345672s` (which
|
||||||
|
/// three significant digits cannot even distinguish). 0 by default, which is the plain
|
||||||
|
/// absolute axis; the *positions* of the ticks are unchanged by it in the sense that only
|
||||||
|
/// their printed value shifts.
|
||||||
|
f64 origin = 0.0;
|
||||||
|
|
||||||
|
/// When set, `origin` is re-taken from the axis' own range on every `refresh()` -- the labels
|
||||||
|
/// then count from whatever the left edge currently is, and the tick on that edge reads zero
|
||||||
|
/// exactly, without a host having to predict where the follow will place the window (which it
|
||||||
|
/// cannot: the window moves with the samples, and a value written a frame earlier is a frame
|
||||||
|
/// of scroll stale). The window-start label a host draws beside the axis is then
|
||||||
|
/// `origin` *after* the frame, which is the value these labels were built with.
|
||||||
|
bool origin_follows_range = false;
|
||||||
|
|
||||||
/// Whether this axis follows the newest sample. Only meaningful on an x axis --
|
/// Whether this axis follows the newest sample. Only meaningful on an x axis --
|
||||||
/// a y axis has no live edge -- and `Off` on both, so nothing changes for a
|
/// a y axis has no live edge -- and `Off` on both, so nothing changes for a
|
||||||
/// caller who never asks for it. See XFollow.
|
/// caller who never asks for it. See XFollow.
|
||||||
|
|||||||
@@ -30,6 +30,35 @@ struct CrosshairStyle {
|
|||||||
///< (once resolved) means solid.
|
///< (once resolved) means solid.
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// What a crosshair readout's number *is*: one of the series' own samples, or a value
|
||||||
|
/// computed between two of them.
|
||||||
|
///
|
||||||
|
/// The two answer different questions and the right one is a property of the data, which the
|
||||||
|
/// plot cannot know — so it is the caller's to state.
|
||||||
|
///
|
||||||
|
/// * `NearestSample` answers **"which datum is under the pointer"**. The row reports the y of
|
||||||
|
/// whichever bracketing sample is nearer the crosshair's x (a tie reading the earlier), and
|
||||||
|
/// the marker is placed at **that sample's own x**, so the mark says *which* sample the
|
||||||
|
/// number came from. This is the default, and it is the honest mode for a source whose
|
||||||
|
/// samples are already averaged or decimated before they arrive: a number between two of
|
||||||
|
/// those is a second estimate of a signal that nothing measured, and at a zoom past the
|
||||||
|
/// sample spacing the polyline between two samples is a line the renderer drew, so a
|
||||||
|
/// reading off it reports the drawing.
|
||||||
|
/// * `Interpolated` answers **"what is the signal at this instant"**, by linear interpolation
|
||||||
|
/// between the two bracketing samples (`crosshair_interpolate()`). The marker stays on the
|
||||||
|
/// crosshair's own x, on the polyline, because that is where the number is. Right for a
|
||||||
|
/// continuous quantity sampled finely enough that the line between two samples is a model
|
||||||
|
/// the caller believes — a smooth analytical curve, an interpolating instrument — and
|
||||||
|
/// wrong wherever that line is an artefact of plotting.
|
||||||
|
///
|
||||||
|
/// Set per plot through `Plot::set_crosshair_value_mode()`. It reaches the readout's numbers,
|
||||||
|
/// the markers' positions and `Plot::crosshair_value()` alike, so a host and the panel beside
|
||||||
|
/// the pointer can never disagree about what the value means.
|
||||||
|
enum class CrosshairValue : u8 {
|
||||||
|
NearestSample, ///< The nearer bracketing sample's y, marked at that sample's x. Default.
|
||||||
|
Interpolated, ///< A linear blend of the two, marked at the crosshair's own x.
|
||||||
|
};
|
||||||
|
|
||||||
/// The crosshair's state: where it is, which of its two lines it draws, and whether the
|
/// The crosshair's state: where it is, which of its two lines it draws, and whether the
|
||||||
/// per-signal readout is wanted.
|
/// per-signal readout is wanted.
|
||||||
///
|
///
|
||||||
@@ -44,10 +73,10 @@ struct CrosshairStyle {
|
|||||||
/// drawn -- the single-coordinate `Plot::set_crosshair()` leaves the vertical one only,
|
/// drawn -- the single-coordinate `Plot::set_crosshair()` leaves the vertical one only,
|
||||||
/// and a `Figure` is always vertical-only because its rows share x and not y.
|
/// and a `Figure` is always vertical-only because its rows share x and not y.
|
||||||
///
|
///
|
||||||
/// **The readout's values are interpolated, and deliberately so.** The y a row reports for
|
/// **The readout's values are samples by default** -- the y a row reports is the nearer of
|
||||||
/// a trace is *linear interpolation between the two samples adjacent to `x`*, not a sample
|
/// the two samples adjacent to `x`, and its marker sits on that sample. `value_mode` is what
|
||||||
/// and not an interval statistic -- see `crosshair_span()` for the rule stated in full, and
|
/// chooses otherwise; see `CrosshairValue` for the two questions it picks between, and
|
||||||
/// for why it is the opposite convention from `Series::reduce()`/`sum()`/`sumsq()`.
|
/// `crosshair_span()` for how both differ from `Series::reduce()`/`sum()`/`sumsq()`.
|
||||||
struct Crosshair {
|
struct Crosshair {
|
||||||
/// Whether the crosshair is drawn at all. Set by `Plot::set_crosshair()`; cleared by
|
/// Whether the crosshair is drawn at all. Set by `Plot::set_crosshair()`; cleared by
|
||||||
/// `Plot::clear_crosshair()`, which `input::Controller` calls when the pointer leaves the
|
/// `Plot::clear_crosshair()`, which `input::Controller` calls when the pointer leaves the
|
||||||
@@ -58,7 +87,7 @@ struct Crosshair {
|
|||||||
/// Draw the horizontal line (the row of `y`). On for the two-coordinate overload only:
|
/// Draw the horizontal line (the row of `y`). On for the two-coordinate overload only:
|
||||||
/// a crosshair placed at one coordinate has no second value to mark a row with.
|
/// a crosshair placed at one coordinate has no second value to mark a row with.
|
||||||
bool horizontal = true;
|
bool horizontal = true;
|
||||||
/// List the visible traces and their interpolated values in a panel beside the pointer.
|
/// List the visible traces and their values in a panel beside the pointer.
|
||||||
/// On by default, and free to leave on: a plot with no visible trace has no rows to
|
/// On by default, and free to leave on: a plot with no visible trace has no rows to
|
||||||
/// measure and the panel is then not placed at all.
|
/// measure and the panel is then not placed at all.
|
||||||
bool show_readout = true;
|
bool show_readout = true;
|
||||||
@@ -67,6 +96,10 @@ struct Crosshair {
|
|||||||
/// The data y of the horizontal line, on y axis 0. Meaningless for a vertical-only
|
/// The data y of the horizontal line, on y axis 0. Meaningless for a vertical-only
|
||||||
/// crosshair, which draws no row and anchors its readout at the top of the axes.
|
/// crosshair, which draws no row and anchors its readout at the top of the axes.
|
||||||
f64 y = 0.0;
|
f64 y = 0.0;
|
||||||
|
/// Whether a row's value is one of the series' samples or a blend of two -- see
|
||||||
|
/// `CrosshairValue`, which also says where each mode puts the marker. Set through
|
||||||
|
/// `Plot::set_crosshair_value_mode()`.
|
||||||
|
CrosshairValue value_mode = CrosshairValue::NearestSample;
|
||||||
/// This plot's own stroke and dash, when it wants to differ from the global style; a
|
/// This plot's own stroke and dash, when it wants to differ from the global style; a
|
||||||
/// default-constructed `CrosshairStyle` defers every field to `render::Style` (see
|
/// default-constructed `CrosshairStyle` defers every field to `render::Style` (see
|
||||||
/// `CrosshairStyle`). Set through `Plot::set_crosshair_style()`.
|
/// `CrosshairStyle`). Set through `Plot::set_crosshair_style()`.
|
||||||
@@ -84,14 +117,17 @@ struct Crosshair {
|
|||||||
/// arithmetic, which is O(1) in the series' length. The crosshair runs on every pointer
|
/// arithmetic, which is O(1) in the series' length. The crosshair runs on every pointer
|
||||||
/// move, so that is a requirement rather than a nicety.
|
/// move, so that is a requirement rather than a nicety.
|
||||||
///
|
///
|
||||||
/// **Interpolated, not exact.** The value a caller builds from this span by
|
/// **`t` is a position, not yet an answer.** It says where the query sits between the two
|
||||||
/// `crosshair_interpolate()` between the two samples is the signal *at* `x`, which is
|
/// samples and nothing more; what a caller makes of it is `CrosshairValue`'s to decide. In
|
||||||
/// generally between two samples -- the opposite convention from `Series::reduce()`,
|
/// `NearestSample` -- the default -- it *selects*, `t <= 0.5` taking `begin`, so the readout
|
||||||
/// `sum()` and `sumsq()`, which are **exact over a sample range**. The two conventions sit
|
/// quotes a sample the series actually holds. In `Interpolated` it *blends*, through
|
||||||
/// next to each other in this library on purpose and they do not agree: `reduce()` answers
|
/// `crosshair_interpolate()`. A plot cannot tell which is right, because the question is
|
||||||
/// "what are the samples' extremes over this interval", this answers "what is the signal at
|
/// about the data and not about the drawing; `CrosshairValue` is where that is argued.
|
||||||
/// this instant". A readout that pretended to be exact would be quoting an extreme or a
|
///
|
||||||
/// mean of a window as if it were the value under the pointer.
|
/// Either way this is a different convention from `Series::reduce()`, `sum()` and `sumsq()`,
|
||||||
|
/// which are **exact over a sample range**: those answer "what are the samples'
|
||||||
|
/// extremes/total over this interval", a crosshair answers "what is the signal at this one
|
||||||
|
/// x". What differs is a statistic of many samples against a reading at one place.
|
||||||
///
|
///
|
||||||
/// `valid` is false -- and every field left at its default -- for an **empty series**, a
|
/// `valid` is false -- and every field left at its default -- for an **empty series**, a
|
||||||
/// non-positive `dx()`, a non-finite `x`, and an `x` strictly outside the series' window
|
/// non-positive `dx()`, a non-finite `x`, and an `x` strictly outside the series' window
|
||||||
@@ -104,13 +140,14 @@ struct CrosshairSpan {
|
|||||||
/// Index of the first sample whose x is at or after the query: `begin` when the query
|
/// Index of the first sample whose x is at or after the query: `begin` when the query
|
||||||
/// lands on a sample, `begin + 1` between two of them.
|
/// lands on a sample, `begin + 1` between two of them.
|
||||||
u64 end = 0;
|
u64 end = 0;
|
||||||
/// The query's position between the two samples, in `[0, 1]`.
|
/// The query's position between the two samples, in `[0, 1]`. Whether it selects one of
|
||||||
|
/// them or blends both is `CrosshairValue`'s -- see the struct comment.
|
||||||
f64 t = 0.0;
|
f64 t = 0.0;
|
||||||
bool valid = false;
|
bool valid = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The sample span `x` falls in, on `series`; see `CrosshairSpan` for the rule, for the
|
/// The sample span `x` falls in, on `series`; see `CrosshairSpan` for what `t` means, for the
|
||||||
/// interpolation convention it is half of, and for the cases that yield `valid == false`.
|
/// convention it is half of, and for the cases that yield `valid == false`.
|
||||||
///
|
///
|
||||||
/// O(1) and allocation-free: a subtraction, a division and a truncation. The series' own sample
|
/// O(1) and allocation-free: a subtraction, a division and a truncation. The series' own sample
|
||||||
/// fetch is the caller's -- `Plot::crosshair_value()` reads the span through
|
/// fetch is the caller's -- `Plot::crosshair_value()` reads the span through
|
||||||
@@ -118,12 +155,14 @@ struct CrosshairSpan {
|
|||||||
/// allocates.
|
/// allocates.
|
||||||
CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept;
|
CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept;
|
||||||
|
|
||||||
/// The interpolated y at `t` between two samples: `y_begin` at `t == 0`, `y_end` at
|
/// The linear blend of a span's two end samples at `t`: `y_begin + t * (y_end - y_begin)`,
|
||||||
/// `t == 1`. The one place the interpolation's arithmetic lives, so the lookup's rule and
|
/// which is what `CrosshairValue::Interpolated` reports.
|
||||||
/// its documentation cannot drift apart.
|
///
|
||||||
inline f64 crosshair_interpolate(f64 y_begin, f64 y_end, f64 t) noexcept {
|
/// Written as that form rather than `(1 - t) * a + t * b` so `t == 0` returns `y_begin`
|
||||||
return y_begin + (y_end - y_begin) * t;
|
/// exactly and `t == 1` returns `y_end` exactly, with no rounding of its own at the ends: a
|
||||||
}
|
/// query that landed on a sample must read that sample's value back unchanged, whichever mode
|
||||||
|
/// is in force. `NearestSample` does not call it at all.
|
||||||
|
f64 crosshair_interpolate(f64 y_begin, f64 y_end, f64 t) noexcept;
|
||||||
|
|
||||||
/// One trace's line of the readout: which trace it is about, the value under the pointer
|
/// One trace's line of the readout: which trace it is about, the value under the pointer
|
||||||
/// and the boxes the renderer stages.
|
/// and the boxes the renderer stages.
|
||||||
@@ -150,8 +189,14 @@ struct CrosshairRow {
|
|||||||
/// entries -- so a row that is not listed is still a listed *trace*, with a marker, just
|
/// entries -- so a row that is not listed is still a listed *trace*, with a marker, just
|
||||||
/// not a line in a panel that has no room for it.
|
/// not a line in a panel that has no room for it.
|
||||||
bool listed = false;
|
bool listed = false;
|
||||||
/// The trace's interpolated y at the crosshair's x, on the trace's own y axis. Only
|
/// The data x the row's value belongs to, on the trace's own x axis: in
|
||||||
/// meaningful when `valid`.
|
/// `CrosshairValue::NearestSample` the **quoted sample's own x**, which is generally not the
|
||||||
|
/// crosshair's; in `Interpolated` the crosshair's x itself, since that is where an
|
||||||
|
/// interpolated number is. `marker` is placed from this and from `y`. Only meaningful when
|
||||||
|
/// `valid`.
|
||||||
|
f64 x = 0.0;
|
||||||
|
/// The row's value, on the trace's own y axis: the nearer sample's y, or the blend of the
|
||||||
|
/// two -- see `CrosshairValue`. Only meaningful when `valid`.
|
||||||
f64 y = 0.0;
|
f64 y = 0.0;
|
||||||
/// The trace's name, or the positional placeholder of an unnamed one.
|
/// The trace's name, or the positional placeholder of an unnamed one.
|
||||||
std::string label;
|
std::string label;
|
||||||
@@ -161,9 +206,24 @@ struct CrosshairRow {
|
|||||||
/// `kCrosshairNoValue`, the em dash. The absence is therefore *legible* -- a blank cell reads as
|
/// `kCrosshairNoValue`, the em dash. The absence is therefore *legible* -- a blank cell reads as
|
||||||
/// "still loading" rather than as "nothing here" -- and nothing numeric is fabricated for it.
|
/// "still loading" rather than as "nothing here" -- and nothing numeric is fabricated for it.
|
||||||
std::string value;
|
std::string value;
|
||||||
/// The trace's intersection with the crosshair, in framebuffer pixels: the pen the
|
/// Where the renderer draws the marker, in framebuffer pixels: the pen for `(x, y)`, placed
|
||||||
/// renderer draws the marker at. Placed through the *trace's own* axis pair, so the
|
/// through the *trace's own* axis pair, so a trace on a secondary y axis is marked on the
|
||||||
/// marker is where that trace is actually drawn. Meaningless when `!valid`.
|
/// scale it is drawn against.
|
||||||
|
///
|
||||||
|
/// It marks **the point the row reports**, which is the whole job: in
|
||||||
|
/// `CrosshairValue::NearestSample` it is *snapped to the quoted sample*, sitting on the datum
|
||||||
|
/// rather than beside it on the crosshair's column, so the mark says which of the samples
|
||||||
|
/// under the pointer the number came from. (Placing it at the pointer's x with the sample's y
|
||||||
|
/// put it off the data in both coordinates at a deep zoom -- a mark on nothing.) In
|
||||||
|
/// `Interpolated` it stays on the crosshair's column, on the polyline, which is equally where
|
||||||
|
/// *that* mode's number is. One `(x, y)` feeding both the mark and the text is what keeps them
|
||||||
|
/// from ever disagreeing.
|
||||||
|
///
|
||||||
|
/// It is drawn `Style::crosshair_marker_px` across -- **bigger than a trace's own sample
|
||||||
|
/// markers** on purpose, since a snapped mark lands among them and has to read as "this one"
|
||||||
|
/// rather than as one more sample. A mark for a sample outside the visible range is staged
|
||||||
|
/// where that sample is and left to the axes clip, rather than being dropped by a range test
|
||||||
|
/// of the drawing layer's own. Meaningless when `!valid`.
|
||||||
Vec2 marker{};
|
Vec2 marker{};
|
||||||
/// The swatch's box, `Legend::swatch_w` x `Legend::swatch_h` centred in the row. Empty
|
/// The swatch's box, `Legend::swatch_w` x `Legend::swatch_h` centred in the row. Empty
|
||||||
/// when `!listed`.
|
/// when `!listed`.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
#include <plot/core/color.hpp>
|
#include <plot/core/color.hpp>
|
||||||
#include <plot/core/types.hpp>
|
#include <plot/core/types.hpp>
|
||||||
|
#include <plot/math/vec2.hpp>
|
||||||
|
|
||||||
namespace plot {
|
namespace plot {
|
||||||
|
|
||||||
@@ -86,6 +87,25 @@ struct Cursor {
|
|||||||
u32 axis_x = 0; ///< the x axis `x` is in. Dead for `Horizontal`.
|
u32 axis_x = 0; ///< the x axis `x` is in. Dead for `Horizontal`.
|
||||||
u32 axis_y = 0; ///< the y axis `y` is in. Dead for `Vertical`.
|
u32 axis_y = 0; ///< the y axis `y` is in. Dead for `Vertical`.
|
||||||
bool visible = true;
|
bool visible = true;
|
||||||
|
/// Whether the pointer may hit this cursor at all: `false` for a *readout* -- a marker a
|
||||||
|
/// host keeps to show something it computed, which the user must not be able to drag. The
|
||||||
|
/// controller's `cursor_at()` skips it, so it is neither hovered nor grabbed, and the
|
||||||
|
/// hit radius it would have taken is left to whatever is behind it. Drawn exactly as a
|
||||||
|
/// visible one is: this is about the gesture, not the ink.
|
||||||
|
bool selectable = true;
|
||||||
|
/// The stroke's logical width; `<= 0` means the style's default for this cursor
|
||||||
|
/// (`Style::handle_width_px` while it is hovered or grabbed, `Style::line_width_px`
|
||||||
|
/// otherwise). Per cursor, like the colour, because a host uses cursors for two different
|
||||||
|
/// jobs: the instruments that frame a reading (a marker at an edge, a pull handle) and the
|
||||||
|
/// *readout marks* a computation places on the data (a measured level, a peak). Those want
|
||||||
|
/// different weights, and a hover can only make the stroke thicker, never thinner -- the cue
|
||||||
|
/// that the pointer has hold of something must not be the thing that hides it.
|
||||||
|
f32 width = 0.0f;
|
||||||
|
/// The dash, logical pixels: ink length and gap. `off <= 0` (the default) is a solid stroke --
|
||||||
|
/// the same convention as a trace's, the crosshair's and the grid's. A dashed readout mark is
|
||||||
|
/// distinguishable by *form* from the solid chrome around it, which is what an instrument that
|
||||||
|
/// reports a number should be.
|
||||||
|
Vec2 dash{};
|
||||||
|
|
||||||
/// True when this is a horizontal ruler (spans the axes' width, reads its `y`). The one
|
/// True when this is a horizontal ruler (spans the axes' width, reads its `y`). The one
|
||||||
/// place the orientation becomes a branch, so every consumer takes the same decision and
|
/// place the orientation becomes a branch, so every consumer takes the same decision and
|
||||||
|
|||||||
@@ -138,6 +138,16 @@ class Figure {
|
|||||||
}
|
}
|
||||||
f32 min_y_gutter() const noexcept { return min_y_gutter_; }
|
f32 min_y_gutter() const noexcept { return min_y_gutter_; }
|
||||||
|
|
||||||
|
/// The empty band every row keeps to the right of its `axes`, in device pixels (0.0
|
||||||
|
/// by default, so `axes` reaches the viewport's right edge). It is set here rather
|
||||||
|
/// than row by row for the left gutter's reason: the rows' axes have to line up, and
|
||||||
|
/// they end on a common column only if they are told one width. See
|
||||||
|
/// `Plot::set_right_gutter()` for what it is for. Negative values are clamped to 0.
|
||||||
|
void set_right_gutter(f32 device_px) noexcept {
|
||||||
|
right_gutter_ = device_px > 0.0f ? device_px : 0.0f;
|
||||||
|
}
|
||||||
|
f32 right_gutter() const noexcept { return right_gutter_; }
|
||||||
|
|
||||||
/// Whether the shared x axis is the figure's: every row reserves no x label strip
|
/// Whether the shared x axis is the figure's: every row reserves no x label strip
|
||||||
/// and one shared axis is drawn under the last row (the default), or every row
|
/// and one shared axis is drawn under the last row (the default), or every row
|
||||||
/// draws its own labels and the figure draws nothing extra.
|
/// draws its own labels and the figure draws nothing extra.
|
||||||
@@ -326,6 +336,7 @@ class Figure {
|
|||||||
FigureLayout layout_{};
|
FigureLayout layout_{};
|
||||||
f32 row_gap_ = 0.0f;
|
f32 row_gap_ = 0.0f;
|
||||||
f32 min_y_gutter_ = 0.0f;
|
f32 min_y_gutter_ = 0.0f;
|
||||||
|
f32 right_gutter_ = 0.0f;
|
||||||
bool labels_bottom_only_ = true;
|
bool labels_bottom_only_ = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+102
-9
@@ -88,6 +88,11 @@ struct PlotLayout {
|
|||||||
/// no text backend there is no title to lay out at all and both stay 0.0f.
|
/// no text backend there is no title to lay out at all and both stay 0.0f.
|
||||||
f32 y_title_width = 0.0f;
|
f32 y_title_width = 0.0f;
|
||||||
f32 x_title_height = 0.0f;
|
f32 x_title_height = 0.0f;
|
||||||
|
/// The empty band kept between `axes`' right edge and the viewport's, 0.0f unless a
|
||||||
|
/// caller asked for one (see `Plot::set_right_gutter()`). Reported because it is
|
||||||
|
/// clamped by the same cap as the strips above, so what was asked for and what was
|
||||||
|
/// reserved are not always the same number.
|
||||||
|
f32 right_gutter = 0.0f;
|
||||||
/// The measured legend: inside `axes`, never reserving any part of it, and empty
|
/// The measured legend: inside `axes`, never reserving any part of it, and empty
|
||||||
/// when there is nothing to draw. It is measured with the same text backend the
|
/// when there is nothing to draw. It is measured with the same text backend the
|
||||||
/// strips above are, and against the same `axes` rect the caller reads back, so
|
/// strips above are, and against the same `axes` rect the caller reads back, so
|
||||||
@@ -101,8 +106,8 @@ struct PlotLayout {
|
|||||||
/// where a pointer can grab it cannot drift apart.
|
/// where a pointer can grab it cannot drift apart.
|
||||||
std::vector<RegionLayout> regions;
|
std::vector<RegionLayout> regions;
|
||||||
/// The crosshair's geometry, when one is active: the two lines' stroke boxes, the
|
/// The crosshair's geometry, when one is active: the two lines' stroke boxes, the
|
||||||
/// readout panel and one row per visible trace with the interpolated value the pointer
|
/// readout panel and one row per visible trace with the value of the sample nearest the
|
||||||
/// is over. Placed here rather than derived by the renderer, exactly as the legend and
|
/// pointer. Placed here rather than derived by the renderer, exactly as the legend and
|
||||||
/// the regions are -- and, like them, entirely inside `axes`: the panel is flipped and
|
/// the regions are -- and, like them, entirely inside `axes`: the panel is flipped and
|
||||||
/// clamped against the rect it was measured for, so nothing of the readout can be drawn
|
/// clamped against the rect it was measured for, so nothing of the readout can be drawn
|
||||||
/// over a label strip or into a figure's neighbouring row.
|
/// over a label strip or into a figure's neighbouring row.
|
||||||
@@ -229,6 +234,13 @@ class Plot {
|
|||||||
/// Measures the interval between two cursors over the series of `trace`.
|
/// Measures the interval between two cursors over the series of `trace`.
|
||||||
u32 add_measure(u32 cursor_a, u32 cursor_b, u32 trace = 0);
|
u32 add_measure(u32 cursor_a, u32 cursor_b, u32 trace = 0);
|
||||||
u32 add_annotation(const Annotation& annotation);
|
u32 add_annotation(const Annotation& annotation);
|
||||||
|
/// Moves an annotation that is already in the plot, keeping its id -- what a host whose
|
||||||
|
/// annotation is a *readout* needs (the point where a cursor crosses a trace moves with
|
||||||
|
/// the cursor). `Plot` has no `remove_annotation`, so the same parking discipline as the
|
||||||
|
/// cursors applies: set `visible = false` rather than dropping it, and re-place where it
|
||||||
|
/// belongs. An id that names no annotation is ignored, not an error, exactly as every
|
||||||
|
/// other setter here treats a stale id.
|
||||||
|
void set_annotation(u32 id, const Annotation& annotation);
|
||||||
/// Adds a region. Its bounds are ordered and, when the region asks to snap, pulled
|
/// Adds a region. Its bounds are ordered and, when the region asks to snap, pulled
|
||||||
/// onto the sample grid as it is added -- exactly as `add_cursor()` places a
|
/// onto the sample grid as it is added -- exactly as `add_cursor()` places a
|
||||||
/// snapping cursor -- so a region handed to a plot that already holds samples is on
|
/// snapping cursor -- so a region handed to a plot that already holds samples is on
|
||||||
@@ -472,6 +484,46 @@ class Plot {
|
|||||||
///
|
///
|
||||||
/// Ignored for a vertical cursor, whose snap is `SnapMode` -- see `set_cursor_snap()`.
|
/// Ignored for a vertical cursor, whose snap is `SnapMode` -- see `set_cursor_snap()`.
|
||||||
void set_cursor_h_snap(u32 id, HRulerSnap mode);
|
void set_cursor_h_snap(u32 id, HRulerSnap mode);
|
||||||
|
/// Recolours a cursor that already exists. A *readout* cursor -- one a host keeps as a
|
||||||
|
/// marker for something it computed, rather than an instrument the user placed -- has to
|
||||||
|
/// be told apart from its neighbours, and its colour is how it says which readout it
|
||||||
|
/// belongs to. An id that names no cursor is ignored, like every other setter here.
|
||||||
|
void set_cursor_color(u32 id, Color color);
|
||||||
|
|
||||||
|
/// A cursor's stroke: its logical width (`<= 0` for the style's default) and its dash
|
||||||
|
/// (`dash.off <= 0` for solid). The instrument's own, like its colour -- see `Cursor::width`.
|
||||||
|
void set_cursor_stroke(u32 id, f32 width, Vec2 dash);
|
||||||
|
/// Makes a cursor unselectable (see `Cursor::selectable`): the pointer cannot hover or
|
||||||
|
/// grab it, which is what a host wants for a marker it draws as a *readout*. An id that
|
||||||
|
/// names no cursor is ignored, like every other setter here.
|
||||||
|
void set_cursor_selectable(u32 id, bool selectable);
|
||||||
|
/// Sets the value an axis' labels are written relative to (see `Axis::origin`). The ticks
|
||||||
|
/// are generated on that shifted grid too, so a host that zooms into a window can label it
|
||||||
|
/// from its own left edge. An id that names no axis of that orientation is ignored, like the
|
||||||
|
/// other setters here -- x and y ids are separate sequences, so the orientation is part of
|
||||||
|
/// the call.
|
||||||
|
void set_x_axis_origin(u32 axis, f64 origin);
|
||||||
|
void set_y_axis_origin(u32 axis, f64 origin);
|
||||||
|
/// The origin the matching setter stored (0 for an axis that never had one).
|
||||||
|
f64 x_axis_origin(u32 axis) const;
|
||||||
|
f64 y_axis_origin(u32 axis) const;
|
||||||
|
/// Makes an axis' label origin follow its own range (see `Axis::origin_follows_range`): the
|
||||||
|
/// labels then count from the left edge the frame actually has, which is the only way a
|
||||||
|
/// host can label a *moving* window from its start without being a frame behind.
|
||||||
|
void set_x_axis_origin_from_range(u32 axis, bool on);
|
||||||
|
void set_y_axis_origin_from_range(u32 axis, bool on);
|
||||||
|
|
||||||
|
/// A one-line caption for the x axis, drawn as **chrome** in the bottom-left corner of the
|
||||||
|
/// axes with the legend's own backplate behind it. It exists for the thing a shifted origin
|
||||||
|
/// leaves unsaid: labels counted from a window's left edge are only readable beside the value
|
||||||
|
/// that edge has, and the reader adds the two. An `Annotation` cannot serve -- its anchor is a
|
||||||
|
/// data coordinate, so the caption crawls across the plot under a pan and drifts under a zoom,
|
||||||
|
/// which is the one thing a legend-like panel must not do. The text is the host's, because only
|
||||||
|
/// the host knows how much precision the sum needs: the axis' own label form carries three
|
||||||
|
/// significant figures, which is exactly what made the shifted origin necessary.
|
||||||
|
/// Empty (the default) draws nothing.
|
||||||
|
void set_x_note(std::string text);
|
||||||
|
const std::string& x_note() const noexcept { return x_note_; }
|
||||||
|
|
||||||
/// The measurement for `id`. Re-evaluated only when a cursor moved, when the
|
/// The measurement for `id`. Re-evaluated only when a cursor moved, when the
|
||||||
/// measure was added, or when the series underneath it changed: a different
|
/// measure was added, or when the series underneath it changed: a different
|
||||||
@@ -593,6 +645,20 @@ class Plot {
|
|||||||
}
|
}
|
||||||
f32 min_y_gutter() const noexcept { return min_y_gutter_; }
|
f32 min_y_gutter() const noexcept { return min_y_gutter_; }
|
||||||
|
|
||||||
|
/// An empty band reserved between `axes`' right edge and the viewport's, in device
|
||||||
|
/// pixels. 0.0 -- the default -- is what the layout has always done: `axes` reaches
|
||||||
|
/// the viewport's right edge, which leaves the rightmost x tick label, *centred* on
|
||||||
|
/// that edge, half of it to be drawn in and nothing to draw it in -- and puts the
|
||||||
|
/// newest sample's ink on the frame. It is the mirror of the left gutter in purpose
|
||||||
|
/// and not in kind: nothing is drawn here, so there is nothing to measure and the
|
||||||
|
/// value is the caller's to choose. Reserved inside the same cap as the label and
|
||||||
|
/// title strips, so it can no more squeeze `axes` below the half-viewport floor than
|
||||||
|
/// they can. Negative values are clamped to 0.
|
||||||
|
void set_right_gutter(f32 device_px) noexcept {
|
||||||
|
right_gutter_ = device_px > 0.0f ? device_px : 0.0f;
|
||||||
|
}
|
||||||
|
f32 right_gutter() const noexcept { return right_gutter_; }
|
||||||
|
|
||||||
/// Grid segments for the primary axis pair (x axis 0, y axis 0), in data space.
|
/// Grid segments for the primary axis pair (x axis 0, y axis 0), in data space.
|
||||||
const Grid& grid() const noexcept { return grid_; }
|
const Grid& grid() const noexcept { return grid_; }
|
||||||
|
|
||||||
@@ -653,20 +719,31 @@ class Plot {
|
|||||||
/// dash, which live on `CrosshairLayout` because they depend on `ui_scale`.
|
/// dash, which live on `CrosshairLayout` because they depend on `ui_scale`.
|
||||||
const CrosshairStyle& crosshair_style() const noexcept { return crosshair_.style; }
|
const CrosshairStyle& crosshair_style() const noexcept { return crosshair_.style; }
|
||||||
|
|
||||||
|
/// Chooses what a readout's numbers *are*: one of the series' own samples, or a value
|
||||||
|
/// computed between two of them. `CrosshairValue` argues the two cases and says where each
|
||||||
|
/// puts the marker; the default is `NearestSample`.
|
||||||
|
///
|
||||||
|
/// Deliberately **not** part of `set_crosshair()`, like the readout switch and the style
|
||||||
|
/// override: it is a statement about the data, so a pointer move must not be able to change
|
||||||
|
/// it. It takes effect on the next `layout()` and reaches `crosshair_value()` immediately.
|
||||||
|
void set_crosshair_value_mode(CrosshairValue mode) noexcept { crosshair_.value_mode = mode; }
|
||||||
|
/// The readout's value mode, as set -- see `set_crosshair_value_mode()`.
|
||||||
|
CrosshairValue crosshair_value_mode() const noexcept { return crosshair_.value_mode; }
|
||||||
|
|
||||||
/// The crosshair's state: where it is, which lines it draws and whether the readout is
|
/// The crosshair's state: where it is, which lines it draws and whether the readout is
|
||||||
/// on. Read-only -- `set_crosshair()` and `clear_crosshair()` are how it moves -- so the
|
/// on. Read-only -- `set_crosshair()` and `clear_crosshair()` are how it moves -- so the
|
||||||
/// coordinates it holds and the geometry `layout()` places from them cannot disagree.
|
/// coordinates it holds and the geometry `layout()` places from them cannot disagree.
|
||||||
const Crosshair& crosshair() const noexcept { return crosshair_; }
|
const Crosshair& crosshair() const noexcept { return crosshair_; }
|
||||||
|
|
||||||
/// The **interpolated** value of `trace`'s series at a data `x`: `crosshair_span()`
|
/// The value this plot's readout would report for `trace` at a data `x`: the nearer
|
||||||
/// between the two adjacent samples, then `crosshair_interpolate()` -- the same lookup
|
/// bracketing sample's y, or the blend of the two, according to
|
||||||
/// the readout's rows are built from, exposed for a host that wants the number for
|
/// `crosshair_value_mode()` -- the same lookup the readout's rows are built from, exposed
|
||||||
/// itself (a status line, a tooltip of its own).
|
/// for a host that wants the number for itself (a status line, a tooltip of its own).
|
||||||
///
|
///
|
||||||
/// False, leaving `out_y` untouched, for an id that names no trace, a trace with no
|
/// False, leaving `out_y` untouched, for an id that names no trace, a trace with no
|
||||||
/// series, and an `x` the series has no sample at -- the cases `CrosshairSpan::valid`
|
/// series, and an `x` the series has no sample at -- the cases `CrosshairSpan::valid`
|
||||||
/// documents, plus the trace itself. Never a clamped or nearest-sample answer: see
|
/// documents, plus the trace itself. Never a clamped answer: see `crosshair_span()` for why
|
||||||
/// `crosshair_span()` for why an out-of-window x is not a value.
|
/// an out-of-window x is not a value.
|
||||||
///
|
///
|
||||||
/// O(1) and allocation-free once the plot has looked a value up before: the span is
|
/// O(1) and allocation-free once the plot has looked a value up before: the span is
|
||||||
/// index arithmetic and the samples are read through one `Series::gather()` of at most
|
/// index arithmetic and the samples are read through one `Series::gather()` of at most
|
||||||
@@ -674,6 +751,17 @@ class Plot {
|
|||||||
/// on every pointer move -- once per visible trace, so nothing here may scan a series.
|
/// on every pointer move -- once per visible trace, so nothing here may scan a series.
|
||||||
bool crosshair_value(u32 trace, f64 x, f64& out_y) const;
|
bool crosshair_value(u32 trace, f64 x, f64& out_y) const;
|
||||||
|
|
||||||
|
/// The whole **point** the readout reports, x as well as y: `crosshair_value()`'s number
|
||||||
|
/// together with the data x it belongs to. In `CrosshairValue::NearestSample` that x is the
|
||||||
|
/// quoted sample's own, which is generally not the `x` asked for; in `Interpolated` it is
|
||||||
|
/// `x` itself.
|
||||||
|
///
|
||||||
|
/// This is what places `CrosshairRow::marker`, and it is public because a host that draws
|
||||||
|
/// instruments of its own -- a cursor pinned to the value it reads, an annotation naming the
|
||||||
|
/// sample -- needs the same snap, and recomputing it would be a second rule that can drift
|
||||||
|
/// from this one. Same failure cases, same cost, same scratch buffer as `crosshair_value()`.
|
||||||
|
bool crosshair_point(u32 trace, f64 x, f64& out_x, f64& out_y) const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
/// One measure plus its cache. The dirty flag is what keeps measurement() from
|
/// One measure plus its cache. The dirty flag is what keeps measurement() from
|
||||||
/// re-evaluating every frame; `last_series`, `last_size` and `last_x0` catch a
|
/// re-evaluating every frame; `last_series`, `last_size` and `last_x0` catch a
|
||||||
@@ -781,7 +869,7 @@ class Plot {
|
|||||||
/// never names a trace.
|
/// never names a trace.
|
||||||
void build_legend();
|
void build_legend();
|
||||||
/// Measures the crosshair into `layout_.crosshair`: the two lines' stroke boxes, one row
|
/// Measures the crosshair into `layout_.crosshair`: the two lines' stroke boxes, one row
|
||||||
/// per visible trace with its interpolated value and marker, and the readout panel --
|
/// per visible trace with its nearest sample's value and marker, and the readout panel --
|
||||||
/// placed beside the crosshair, flipped where it would cross an edge and clamped, so
|
/// placed beside the crosshair, flipped where it would cross an edge and clamped, so
|
||||||
/// every rect it produces is inside `axes`. A plot with no visible trace still gets its
|
/// every rect it produces is inside `axes`. A plot with no visible trace still gets its
|
||||||
/// lines: the rows are the readout's, not the crosshair's.
|
/// lines: the rows are the readout's, not the crosshair's.
|
||||||
@@ -791,11 +879,16 @@ class Plot {
|
|||||||
render::Style style_{};
|
render::Style style_{};
|
||||||
const render::TextBackend* text_ = nullptr;
|
const render::TextBackend* text_ = nullptr;
|
||||||
Legend legend_{};
|
Legend legend_{};
|
||||||
|
/// The x axis' corner caption; see set_x_note(). Empty draws nothing.
|
||||||
|
std::string x_note_;
|
||||||
/// Which parts of the bottom x chrome are reserved at all; see set_x_strip_mode().
|
/// Which parts of the bottom x chrome are reserved at all; see set_x_strip_mode().
|
||||||
XStrip x_strip_ = XStrip::Both;
|
XStrip x_strip_ = XStrip::Both;
|
||||||
/// The floor a figure imposes on the left gutter; see set_min_y_gutter(). 0.0 means
|
/// The floor a figure imposes on the left gutter; see set_min_y_gutter(). 0.0 means
|
||||||
/// none, which is what a plot standing on its own keeps.
|
/// none, which is what a plot standing on its own keeps.
|
||||||
f32 min_y_gutter_ = 0.0f;
|
f32 min_y_gutter_ = 0.0f;
|
||||||
|
/// The empty band kept to the right of `axes`; see set_right_gutter(). 0.0 means none,
|
||||||
|
/// which is what the layout did before the setter existed.
|
||||||
|
f32 right_gutter_ = 0.0f;
|
||||||
/// Which handles a pointer is on, published by the input layer (see HotHandles).
|
/// Which handles a pointer is on, published by the input layer (see HotHandles).
|
||||||
HotHandles hot_{};
|
HotHandles hot_{};
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,10 @@ void format_si(f64 v, char* buf, usize n, const char* unit = nullptr);
|
|||||||
/// field is derived from that one number, so no field can ever reach its radix:
|
/// field is derived from that one number, so no field can ever reach its radix:
|
||||||
/// 119.5 s is `2:00`, not `1:60`, and 3599.5 s is `1:00:00`.
|
/// 119.5 s is `2:00`, not `1:60`, and 3599.5 s is `1:00:00`.
|
||||||
///
|
///
|
||||||
/// Shapes, by magnitude: below 1 s `500ms`; below 60 s `1.5s`; below 1 h `1:30`;
|
/// Shapes, by magnitude: below 1 s the prefixed unit that keeps the mantissa in
|
||||||
|
/// `[1, 1000)` -- `500ms`, `5ms`, `500us`, `5us`, `5ns`, `5ps` (the micro sign is
|
||||||
|
/// UTF-8 U+00B5, as in `format_si`) -- with an exact zero printed as `0` rather
|
||||||
|
/// than as a zero of some unit; below 60 s `1.5s`; below 1 h `1:30`;
|
||||||
/// at or above 24 h, as a decimal day count `2.5d`. Between 1 h and 24 h the
|
/// at or above 24 h, as a decimal day count `2.5d`. Between 1 h and 24 h the
|
||||||
/// shape is `1:01:01` -- and because a value that rounds up to 24 h takes the day
|
/// shape is `1:01:01` -- and because a value that rounds up to 24 h takes the day
|
||||||
/// shape, the hour field of that shape is always below 24. The 60 s and 1 h
|
/// shape, the hour field of that shape is always below 24. The 60 s and 1 h
|
||||||
@@ -73,11 +76,20 @@ inline constexpr usize kMaxTicks = 256;
|
|||||||
/// its labelled majors -- every major at or below `hi` is emitted, so the far
|
/// its labelled majors -- every major at or below `hi` is emitted, so the far
|
||||||
/// end of the axis keeps its numbers.
|
/// 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`
|
/// Hostile input clears `out` and returns: a non-finite endpoint, `max <= min`
|
||||||
/// (a zero or negative span, which includes the collapsed range of a
|
/// (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
|
/// single-sample series), a non-positive `px` or `min_px`, and a span whose step
|
||||||
/// overflows to infinity.
|
/// overflows to infinity.
|
||||||
void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out,
|
void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out,
|
||||||
bool include_minors = false);
|
bool include_minors = false, f64 origin = 0.0);
|
||||||
|
|
||||||
} // namespace plot
|
} // namespace plot
|
||||||
@@ -40,6 +40,14 @@ class LineProgram {
|
|||||||
void set_aa(f32 px) const noexcept;
|
void set_aa(f32 px) const noexcept;
|
||||||
/// `u_color`: stroke colour, alpha included.
|
/// `u_color`: stroke colour, alpha included.
|
||||||
void set_color(Color color) const noexcept;
|
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:
|
private:
|
||||||
gl::Program program_;
|
gl::Program program_;
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ namespace plot {
|
|||||||
struct FrameStats {
|
struct FrameStats {
|
||||||
u32 raw_traces = 0; ///< traces drawn from raw samples (level 0)
|
u32 raw_traces = 0; ///< traces drawn from raw samples (level 0)
|
||||||
u32 lod_traces = 0; ///< traces drawn from a min/max pyramid level
|
u32 lod_traces = 0; ///< traces drawn from a min/max pyramid level
|
||||||
|
/// Traces whose geometry was reduced onto the pixel grid: one vertical segment
|
||||||
|
/// per column, lit from that column's own extremes. Counts a trace from either
|
||||||
|
/// path above -- the reduction is about the *density* of the gathered points,
|
||||||
|
/// not about which level produced them.
|
||||||
|
u32 column_traces = 0;
|
||||||
|
/// Traces whose columns were graded by dwell rather than drawn as one flat band
|
||||||
|
/// (`Style::intensity_grading`). A subset of `column_traces`: grading is a way of
|
||||||
|
/// drawing the reduction, not a third path.
|
||||||
|
u32 graded_traces = 0;
|
||||||
u64 visible_samples = 0; ///< samples the gathered windows covered, summed
|
u64 visible_samples = 0; ///< samples the gathered windows covered, summed
|
||||||
u32 staged_segments = 0; ///< line segments staged, all colour groups
|
u32 staged_segments = 0; ///< line segments staged, all colour groups
|
||||||
u32 staged_markers = 0; ///< marker shapes staged over the traces
|
u32 staged_markers = 0; ///< marker shapes staged over the traces
|
||||||
@@ -397,7 +406,20 @@ class Renderer {
|
|||||||
/// crosshair passes and a dashed trace -- means **solid**. It is set on every flush,
|
/// 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
|
/// 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.
|
/// likewise set on every flush, from `style().line_aa_px` in device pixels.
|
||||||
void flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash = Vec2{}) noexcept;
|
///
|
||||||
|
/// `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
|
/// 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
|
/// (`a_colour`) -- and empties the batch. Text is texture-bound, so this runs
|
||||||
/// once per text batch, after the line geometry it belongs over.
|
/// once per text batch, after the line geometry it belongs over.
|
||||||
@@ -439,11 +461,12 @@ class Renderer {
|
|||||||
/// `origin_x` back in, in f64, before the single f32 narrowing (B1), so a
|
/// `origin_x` back in, in f64, before the single f32 narrowing (B1), so a
|
||||||
/// large-magnitude x -- an epoch axis -- never loses sub-pixel precision.
|
/// large-magnitude x -- an epoch axis -- never loses sub-pixel precision.
|
||||||
void stage_segment(f64 origin_x, f64 x0, f64 y0, f64 x1, f64 y1, bool log_x, bool log_y);
|
void stage_segment(f64 origin_x, f64 x0, f64 y0, f64 x1, f64 y1, bool log_x, bool log_y);
|
||||||
/// Stages `n` interleaved `x,y` points of a gathered window (see
|
/// Stages `n` interleaved `x,y` points of a gathered window (see `Series::gather`): x
|
||||||
/// `Series::gather`): x **relative to the series' `x0()`**, y absolute. The
|
/// **relative to `origin_x`**, which the caller asked the gather for, y absolute. On linear
|
||||||
/// absolute data x is reconstructed in f64 (`series_x0 + rel`) and staged as
|
/// axes that is already what the batch wants and the buffer is handed over as it is; a log
|
||||||
|
/// axis reconstructs the absolute x in f64 per segment and stages
|
||||||
/// `to_space(x_abs) - to_space(origin_x)` -- O(visible pixels), not O(epoch).
|
/// `to_space(x_abs) - to_space(origin_x)` -- O(visible pixels), not O(epoch).
|
||||||
void stage_xy(const f32* xy, u32 n, f64 series_x0, f64 origin_x, bool log_x, bool log_y);
|
void stage_xy(const f32* xy, u32 n, f64 origin_x, bool log_x, bool log_y);
|
||||||
/// Lays one run of text out at a framebuffer position and stages it into
|
/// Lays one run of text out at a framebuffer position and stages it into
|
||||||
/// `batch`, shifting it into viewport space and tinting it `color` (the
|
/// `batch`, shifting it into viewport space and tinting it `color` (the
|
||||||
/// per-instance `a_colour`). Returns the pen past the run.
|
/// per-instance `a_colour`). Returns the pen past the run.
|
||||||
@@ -493,6 +516,29 @@ class Renderer {
|
|||||||
/// The data helpers take it too; they read the axes rect and the transforms it
|
/// The data helpers take it too; they read the axes rect and the transforms it
|
||||||
/// pins.
|
/// pins.
|
||||||
void draw_traces(const Plot& plot, const PlotLayout& layout);
|
void draw_traces(const Plot& plot, const PlotLayout& layout);
|
||||||
|
/// One gathered window reduced onto the pixel grid and staged in framebuffer
|
||||||
|
/// pixels: for each integer column of `layout.axes`, one exactly vertical
|
||||||
|
/// segment from the lowest to the highest screen y any gathered point put in
|
||||||
|
/// it. Returns how many columns were staged (0 stages nothing); the caller
|
||||||
|
/// flushes with `pixel_mapping()` and `snap = true`.
|
||||||
|
///
|
||||||
|
/// `gather_` is the input, read with the same origin `draw_traces` asked the
|
||||||
|
/// series for. See `draw_traces` for when this path is taken and why.
|
||||||
|
u32 stage_trace_columns(const Plot& plot, const PlotLayout& layout, const Trace& trace,
|
||||||
|
f64 x_origin, u32 points);
|
||||||
|
/// The same reduction, graded by **dwell**: a column is drawn as a stack of runs,
|
||||||
|
/// each at the alpha step its own share of the column's samples earns, so a flat
|
||||||
|
/// top is bright and the edge leading to it is faint (`Style::intensity_grading`).
|
||||||
|
///
|
||||||
|
/// Stages into `steps_`, one `LineBatch` per alpha step, which the caller flushes
|
||||||
|
/// in turn -- the line pipeline's colour is a uniform, so a step is a draw call.
|
||||||
|
/// Returns the segments staged across all steps (0 stages nothing).
|
||||||
|
///
|
||||||
|
/// `gather_` is the input and must have been asked for at the *finer* level
|
||||||
|
/// `Style::intensity_sub_columns` implies; a column's dwell is not recoverable
|
||||||
|
/// from its own min and max (see `Style::intensity_grading`).
|
||||||
|
u32 stage_trace_density(const Plot& plot, const PlotLayout& layout, const Trace& trace,
|
||||||
|
f64 x_origin, u32 points);
|
||||||
/// The cursors, both orientations: one stroke per cursor, placed at the coordinate its own
|
/// 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
|
/// 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
|
/// range, in its own colour -- or in the accent at handle width while it is hovered or
|
||||||
@@ -528,6 +574,12 @@ class Renderer {
|
|||||||
/// them again, so the panel a test asserts and the panel this draws are the same
|
/// them again, so the panel a test asserts and the panel this draws are the same
|
||||||
/// numbers.
|
/// numbers.
|
||||||
void draw_legend(const Plot& plot, const PlotLayout& layout);
|
void draw_legend(const Plot& plot, const PlotLayout& layout);
|
||||||
|
/// `Plot::x_note()`, in the bottom-left corner of the axes on the legend's backplate. It is
|
||||||
|
/// measured here rather than in `Plot::layout()` on purpose: unlike a legend it reserves no
|
||||||
|
/// space and nothing can be picked on it, so there is no measurement a host or a test needs to
|
||||||
|
/// read back, and a caption that explains the x labels must not be able to move the layout the
|
||||||
|
/// labels were measured into.
|
||||||
|
void draw_x_note(const Plot& plot, const PlotLayout& layout);
|
||||||
/// The backplate the legend and the crosshair's readout share: the frame's background at
|
/// The backplate the legend and the crosshair's readout share: the frame's background at
|
||||||
/// `kLegendPanelAlpha` over `panel`, with a one-device-pixel border in `border` around the
|
/// `kLegendPanelAlpha` over `panel`, with a one-device-pixel border in `border` around the
|
||||||
/// outermost pixels the fill covers (the crisp-hairline rule -- a border drawn on the measured
|
/// outermost pixels the fill covers (the crisp-hairline rule -- a border drawn on the measured
|
||||||
@@ -598,7 +650,25 @@ class Renderer {
|
|||||||
render::TextBatch glyphs_under_;
|
render::TextBatch glyphs_under_;
|
||||||
render::TextBatch glyphs_over_;
|
render::TextBatch glyphs_over_;
|
||||||
std::vector<f32> gather_; ///< scratch for one gathered window, re-used
|
std::vector<f32> gather_; ///< scratch for one gathered window, re-used
|
||||||
std::vector<f32> staged_; ///< scratch for one staged 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
|
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
|
/// The host's `GL_COLOR_CLEAR_VALUE`, queried on the first `begin_frame()` (GL
|
||||||
|
|||||||
@@ -44,16 +44,27 @@ extern const char* const kLineFS;
|
|||||||
/// the cell's own +v direction -- so `a_axis` of `(0, -1)` draws a run that
|
/// 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
|
/// 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
|
/// 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
|
/// turned. It also passes the cell's origin through `flat`, which is the bound
|
||||||
/// single-channel `GL_R8` atlas at the nearest texel centre, so `GL_NEAREST`
|
/// the fragment shader filters against.
|
||||||
/// never lands on a cell boundary. The instance's own `a_colour` tints the
|
///
|
||||||
|
/// 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.
|
/// 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
|
/// 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
|
/// 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
|
/// 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
|
/// glyph of a frame comes from one atlas; read in *both* stages, the fragment
|
||||||
/// texels), `u_atlas` (sampler2D, unit 0).
|
/// 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 kTextVS;
|
||||||
extern const char* const kTextFS;
|
extern const char* const kTextFS;
|
||||||
|
|
||||||
|
|||||||
@@ -38,8 +38,19 @@ struct Style {
|
|||||||
// --- colours: neither logical nor device, so nothing to scale ------------
|
// --- colours: neither logical nor device, so nothing to scale ------------
|
||||||
|
|
||||||
Color background = Color::hex(0x101418);
|
Color background = Color::hex(0x101418);
|
||||||
Color grid_major = Color::hex(0x2A3038);
|
/// The labelled grid. Brighter than the subdivisions and still well under `axis_line`
|
||||||
Color grid_minor = Color::hex(0x1C2126);
|
/// (0x8A94A6): the grid is structure the eye can read, the axis is the frame, and the
|
||||||
|
/// hierarchy between the three is what keeps a dense plot legible. It is already fully
|
||||||
|
/// opaque, so "more prominent" means a brighter colour and nothing else.
|
||||||
|
Color grid_major = Color::hex(0x39414C);
|
||||||
|
/// The minor grid's colour, **with an alpha** so it blends into the background rather than
|
||||||
|
/// sitting on it: a subdivision is a reading aid, and at full opacity a dense minor grid
|
||||||
|
/// reads as a second set of ticks competing with the labelled one. The line shader applies
|
||||||
|
/// `u_color.a` (see `src/render/shaders.cpp`), so this one blends and the major -- opaque at
|
||||||
|
/// 0x39414C -- stays the structure the eye picks out. Together with `grid_minor_width` and
|
||||||
|
/// the dash below, the defaults put a minor line at an order of magnitude less ink than a
|
||||||
|
/// major one.
|
||||||
|
Color grid_minor = Color::hex(0x1C2126, 0.5f);
|
||||||
Color axis_line = Color::hex(0x8A94A6);
|
Color axis_line = Color::hex(0x8A94A6);
|
||||||
Color axis_text = Color::hex(0xC8D0DC);
|
Color axis_text = Color::hex(0xC8D0DC);
|
||||||
Color cursor = Color::hex(0xFFB020);
|
Color cursor = Color::hex(0xFFB020);
|
||||||
@@ -75,6 +86,21 @@ struct Style {
|
|||||||
f32 crosshair_width = 0.75f;
|
f32 crosshair_width = 0.75f;
|
||||||
f32 crosshair_dash_on = 4.0f;
|
f32 crosshair_dash_on = 4.0f;
|
||||||
f32 crosshair_dash_off = 4.0f;
|
f32 crosshair_dash_off = 4.0f;
|
||||||
|
/// The full extent, in logical pixels, of the little cross drawn at each trace's readout
|
||||||
|
/// point (`CrosshairRow::marker`), in that trace's own colour.
|
||||||
|
///
|
||||||
|
/// **Deliberately larger than `marker_px`**, which is what a trace's own samples are dotted
|
||||||
|
/// with at a deep zoom. In the default `CrosshairValue::NearestSample` the readout's mark is
|
||||||
|
/// *snapped to a sample*, so it lands among those dots -- at the same size it would read as
|
||||||
|
/// one more sample instead of as "this is the one the panel is quoting". The lines are chrome
|
||||||
|
/// and recede; this mark is the answer to the question the hover asks, and it is the one
|
||||||
|
/// crosshair element that should not.
|
||||||
|
///
|
||||||
|
/// Its stroke is `crosshair_marker_width`, thicker than the lines' for the same reason. `0`
|
||||||
|
/// across draws no mark at all, which is how a caller that wants the panel without the marks
|
||||||
|
/// says so.
|
||||||
|
f32 crosshair_marker_px = 9.0f;
|
||||||
|
f32 crosshair_marker_width = 1.5f;
|
||||||
|
|
||||||
// --- the scale ----------------------------------------------------------
|
// --- the scale ----------------------------------------------------------
|
||||||
|
|
||||||
@@ -96,10 +122,31 @@ struct Style {
|
|||||||
/// skips it before the first segment is staged, so a dense plot's minor grid
|
/// skips it before the first segment is staged, so a dense plot's minor grid
|
||||||
/// costs nothing rather than being drawn transparent (spec §11). The colours
|
/// costs nothing rather than being drawn transparent (spec §11). The colours
|
||||||
/// above are already per class.
|
/// above are already per class.
|
||||||
|
/// When a trace's window holds at most this many raw samples, each one is also drawn as a dot
|
||||||
|
/// `marker_px` logical pixels across, in the trace's own colour. `0` draws none. Zoomed in far
|
||||||
|
/// enough to see individual samples -- a handful of them across a whole panel -- the polyline
|
||||||
|
/// *between* them stops being the shape, and the samples themselves are what a reader is looking
|
||||||
|
/// at; a marker makes each one a place rather than a bend in a line.
|
||||||
|
f32 marker_below_samples = 50.0f;
|
||||||
|
f32 marker_px = 3.0f;
|
||||||
f32 grid_major_width = 1.0f; ///< logical width of a major (labelled-tick) line.
|
f32 grid_major_width = 1.0f; ///< logical width of a major (labelled-tick) line.
|
||||||
f32 grid_minor_width = 1.0f; ///< logical width of a minor (subdivision) line.
|
/// Logical width of a minor (subdivision) line: **thinner than the major hairline**, and
|
||||||
|
/// sub-pixel on purpose. At 1x that is expressed as *coverage* -- the anti-aliased ramp
|
||||||
|
/// (`line_aa_px`) lights one column at roughly three quarters of the ink -- and on a HiDPI
|
||||||
|
/// display as a line that is literally narrower. The point is hierarchy: the labelled grid is
|
||||||
|
/// structure, the subdivisions are texture.
|
||||||
|
f32 grid_minor_width = 0.75f;
|
||||||
bool grid_major_visible = true; ///< stage the major grid at all.
|
bool grid_major_visible = true; ///< stage the major grid at all.
|
||||||
bool grid_minor_visible = true; ///< stage the minor grid at all.
|
bool grid_minor_visible = true; ///< stage the minor grid at all.
|
||||||
|
/// The minor grid's dash, in logical pixels: ink length and gap. **`off == 0` draws it
|
||||||
|
/// solid** -- the same convention as the crosshair's and a trace's dash -- which is what a
|
||||||
|
/// caller that wants the old solid subdivision states. Dashed is the default because an
|
||||||
|
/// unlabelled line at an unlabelled position should never be mistaken for a labelled one, and
|
||||||
|
/// because a broken line carries less weight over data than a continuous one. The dash is a
|
||||||
|
/// fragment discard, never extra geometry, so a dense minor grid costs no more vertices for
|
||||||
|
/// being dashed.
|
||||||
|
f32 grid_minor_dash_on = 3.0f;
|
||||||
|
f32 grid_minor_dash_off = 3.0f;
|
||||||
f32 line_width_px = 1.5f; ///< logical width of the strokes framing the data
|
f32 line_width_px = 1.5f; ///< logical width of the strokes framing the data
|
||||||
///< (axis lines, cursor markers).
|
///< (axis lines, cursor markers).
|
||||||
/// Logical half-width of the anti-aliased fringe the line shader blends at
|
/// Logical half-width of the anti-aliased fringe the line shader blends at
|
||||||
@@ -130,6 +177,82 @@ struct Style {
|
|||||||
///< sides draw through the one instanced
|
///< sides draw through the one instanced
|
||||||
///< anti-aliased quad path.
|
///< anti-aliased quad path.
|
||||||
|
|
||||||
|
// --- intensity grading: counts and ratios, never sizes -------------------
|
||||||
|
|
||||||
|
/// Draw a dense trace's pixel columns **graded by how long the signal dwells at
|
||||||
|
/// each height**, the way a digital-phosphor scope does, instead of as one flat
|
||||||
|
/// band per column: bright where samples pile up (a flat top, a peak), faint
|
||||||
|
/// where they only pass through (an edge, a noise excursion).
|
||||||
|
///
|
||||||
|
/// Off by default, because it is a change of *look* and not a correction: the
|
||||||
|
/// flat band is the honest answer to "what did this column reach", and a host
|
||||||
|
/// that wants that -- or that draws few enough samples never to take the column
|
||||||
|
/// path at all -- should not have its traces restyled by an upgrade. A scope-like
|
||||||
|
/// host turns it on.
|
||||||
|
///
|
||||||
|
/// It is not derivable from the flat band. A column's min and max are the same
|
||||||
|
/// whether its samples fill the span or sit piled at its two ends, which is
|
||||||
|
/// exactly the difference between a ramp and a pulse -- so grading needs the
|
||||||
|
/// gather asked at a *finer* level than the pixel grid (`intensity_sub_columns`)
|
||||||
|
/// and reads the distribution from those sub-buckets.
|
||||||
|
///
|
||||||
|
/// The measure is *dwell per row*, and the weighting is the whole of it: each
|
||||||
|
/// sub-column segment deposits **one unit spread over the rows it spans**, so a
|
||||||
|
/// tall crossing leaves a thin smear and a short one at a turning point leaves a
|
||||||
|
/// bright mark. Counting a row at +1 per segment that touches it instead -- the
|
||||||
|
/// obvious thing -- grades a sine's fast middle as brightly as its rails, which
|
||||||
|
/// is the pattern inverted.
|
||||||
|
bool intensity_grading = false;
|
||||||
|
|
||||||
|
/// Sub-buckets per pixel column the gather is asked for when grading: the level
|
||||||
|
/// is chosen for `samples_per_pixel / intensity_sub_columns`, so a column's
|
||||||
|
/// density is read from this many envelopes instead of from one.
|
||||||
|
///
|
||||||
|
/// It buys resolution in *dwell*, linearly, for the same factor in gathered
|
||||||
|
/// points -- and the points stay bounded by the screen, which is the property
|
||||||
|
/// that matters (8 of them over a 1000 px axis is 16 000 points whatever the
|
||||||
|
/// window holds). Below about 4 a pulse's flat levels and its edge are too few
|
||||||
|
/// samples apart to separate.
|
||||||
|
///
|
||||||
|
/// It is a *want*, not a promise: the renderer floors the level it asks for at the
|
||||||
|
/// pyramid's coarsest bucket level, so a window too sparse to hold this many buckets
|
||||||
|
/// a column is graded from however many it does hold. Without that floor `level_for`
|
||||||
|
/// answers 0 -- raw samples -- the moment a sub-column falls under one bucket, which
|
||||||
|
/// turns a frame the flat path draws off the pyramid into an O(visible) gather. So
|
||||||
|
/// raising this past about 8 costs resolution nothing and buys it nothing either: the
|
||||||
|
/// bucket size is the limit, not this number.
|
||||||
|
u32 intensity_sub_columns = 8;
|
||||||
|
|
||||||
|
/// Quantisation of the grading, in alpha steps. Each step is one draw call (the
|
||||||
|
/// line pipeline's colour is a uniform, so a per-segment alpha would mean a
|
||||||
|
/// per-instance one), and the segments are bounded by the columns, so the cost
|
||||||
|
/// of a step is a draw call over a few hundred instances.
|
||||||
|
///
|
||||||
|
/// Four is enough to read a waveform's structure and few enough that a step is a
|
||||||
|
/// visible tier rather than a gradient -- the point is to separate "dwelt here"
|
||||||
|
/// from "passed through", not to measure.
|
||||||
|
u32 intensity_steps = 4;
|
||||||
|
|
||||||
|
/// Alpha the faintest graded step is drawn at, as a fraction of the trace's own.
|
||||||
|
///
|
||||||
|
/// Not 0: a column the signal merely passed through is still a column the signal
|
||||||
|
/// *reached*, and a riser dimmed out of existence is a square wave drawn as two
|
||||||
|
/// disconnected lines. This is the floor that keeps the trace continuous.
|
||||||
|
f32 intensity_floor = 0.3f;
|
||||||
|
|
||||||
|
/// Exponent the normalised dwell is raised to before it is quantised. Below 1 it
|
||||||
|
/// lifts the faint end, which is what makes a fast edge visible beside a flat top
|
||||||
|
/// whose dwell is three orders of magnitude greater.
|
||||||
|
///
|
||||||
|
/// The dwell is normalised **against the column's own peak**, not against a scale
|
||||||
|
/// for the whole frame, so the steps spend their range on the structure *inside*
|
||||||
|
/// each column. One scale for the frame needs the whole `w * h` density image
|
||||||
|
/// (33 MB at 4K, which is why the accumulator is one column wide) and reads
|
||||||
|
/// worse: a column the signal flew straight through holds little dwell at any
|
||||||
|
/// height, so it would grade uniformly faint -- a trace that fades out exactly
|
||||||
|
/// where it moves fastest.
|
||||||
|
f32 intensity_gamma = 0.45f;
|
||||||
|
|
||||||
/// `logical` in device pixels: the single conversion point for every logical
|
/// `logical` in device pixels: the single conversion point for every logical
|
||||||
/// metric above, so the arithmetic appears once per consumer instead of a `*
|
/// metric above, so the arithmetic appears once per consumer instead of a `*
|
||||||
/// ui_scale` scattered over the renderer and the layout.
|
/// ui_scale` scattered over the renderer and the layout.
|
||||||
|
|||||||
@@ -75,9 +75,9 @@ Range ArraySeries::y_bounds() const { return y_bounds_; }
|
|||||||
|
|
||||||
const MinMaxPyramid& ArraySeries::pyramid() const { return pyramid_; }
|
const MinMaxPyramid& ArraySeries::pyramid() const { return pyramid_; }
|
||||||
|
|
||||||
void ArraySeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
void ArraySeries::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||||
GatherMode mode) const {
|
GatherMode mode) const {
|
||||||
gather_impl(level, begin, end, out_xy, mode);
|
gather_impl(level, begin, end, x_origin, out_xy, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ArraySeries::copy_raw(u64 begin, u64 end, f32* out) const {
|
void ArraySeries::copy_raw(u64 begin, u64 end, f32* out) const {
|
||||||
|
|||||||
+15
-11
@@ -176,21 +176,24 @@ u64 Series::snap_index(f64 x) const {
|
|||||||
return static_cast<u64>(k);
|
return static_cast<u64>(k);
|
||||||
}
|
}
|
||||||
|
|
||||||
void Series::gather_raw(u64 begin, u64 end, std::vector<f32>& out_xy) const {
|
void Series::gather_raw(u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy) const {
|
||||||
const f64 step = dx();
|
const f64 step = dx();
|
||||||
|
// Sample i sits at `x0() + i*step`, so this is `x_at(i) - x_origin` with the subtraction done
|
||||||
|
// once in f64 rather than per sample: see gather() for why the caller owns the origin.
|
||||||
|
const f64 shift = x0() - x_origin;
|
||||||
f32 raw[kGatherChunk];
|
f32 raw[kGatherChunk];
|
||||||
for (u64 i = begin; i < end;) {
|
for (u64 i = begin; i < end;) {
|
||||||
const u64 take = std::min<u64>(kGatherChunk, end - i);
|
const u64 take = std::min<u64>(kGatherChunk, end - i);
|
||||||
copy_raw(i, i + take, raw);
|
copy_raw(i, i + take, raw);
|
||||||
for (u64 k = 0; k < take; ++k) {
|
for (u64 k = 0; k < take; ++k) {
|
||||||
out_xy.push_back(f32(f64(i + k) * step)); // x relative to x0(): see gather()
|
out_xy.push_back(f32(shift + f64(i + k) * step)); // x relative to x_origin: see gather()
|
||||||
out_xy.push_back(raw[k]);
|
out_xy.push_back(raw[k]);
|
||||||
}
|
}
|
||||||
i += take;
|
i += take;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
void Series::gather_impl(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||||
GatherMode mode) const {
|
GatherMode mode) const {
|
||||||
const u64 n = size();
|
const u64 n = size();
|
||||||
const u64 b = std::min(begin, n);
|
const u64 b = std::min(begin, n);
|
||||||
@@ -198,7 +201,7 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
|
|||||||
if (b >= e) return;
|
if (b >= e) return;
|
||||||
|
|
||||||
if (level == 0) {
|
if (level == 0) {
|
||||||
gather_raw(b, e, out_xy);
|
gather_raw(b, e, x_origin, out_xy);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Public levels clamp to the deepest representable one (R3): past
|
// Public levels clamp to the deepest representable one (R3): past
|
||||||
@@ -226,6 +229,7 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
|
|||||||
// picks, so the envelope path stays O(buckets) and the geometry still spans
|
// picks, so the envelope path stays O(buckets) and the geometry still spans
|
||||||
// the requested range exactly, for every mode.
|
// the requested range exactly, for every mode.
|
||||||
const f64 step = dx();
|
const f64 step = dx();
|
||||||
|
const f64 shift = x0() - x_origin; // as in gather_raw(): x_at(i) - x_origin = shift + i*step
|
||||||
const u64 span = u64{kBucket} << level;
|
const u64 span = u64{kBucket} << level;
|
||||||
const u64 origin = pyramid().window_base();
|
const u64 origin = pyramid().window_base();
|
||||||
const u64 lbase = pyramid().level_base(level);
|
const u64 lbase = pyramid().level_base(level);
|
||||||
@@ -235,11 +239,11 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
|
|||||||
if (first >= last) {
|
if (first >= last) {
|
||||||
// No complete bucket of this level is inside the range -- a window squeezed
|
// No complete bucket of this level is inside the range -- a window squeezed
|
||||||
// into one bucket -- so the raw samples are the only exact geometry there is.
|
// into one bucket -- so the raw samples are the only exact geometry there is.
|
||||||
gather_raw(b, e, out_xy);
|
gather_raw(b, e, x_origin, out_xy);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const u64 lead = first * span - origin; // window index the first emitted bucket starts at
|
const u64 lead = first * span - origin; // window index the first emitted bucket starts at
|
||||||
if (b < lead) gather_raw(b, std::min(lead, e), out_xy);
|
if (b < lead) gather_raw(b, std::min(lead, e), x_origin, out_xy);
|
||||||
|
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case GatherMode::Envelope: {
|
case GatherMode::Envelope: {
|
||||||
@@ -252,9 +256,9 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
|
|||||||
const MinMax m = pyramid().bucket(level, bi);
|
const MinMax m = pyramid().bucket(level, bi);
|
||||||
const u64 first_sample = bi * span - origin; // >= 0: stored buckets start inside the window
|
const u64 first_sample = bi * span - origin; // >= 0: stored buckets start inside the window
|
||||||
const u64 last_sample = std::min((bi + 1) * span, origin + e) - 1 - origin;
|
const u64 last_sample = std::min((bi + 1) * span, origin + e) - 1 - origin;
|
||||||
out_xy.push_back(f32(f64(first_sample) * step)); // x relative to x0()
|
out_xy.push_back(f32(shift + f64(first_sample) * step)); // x relative to x_origin
|
||||||
out_xy.push_back(m.min);
|
out_xy.push_back(m.min);
|
||||||
out_xy.push_back(f32(f64(last_sample) * step));
|
out_xy.push_back(f32(shift + f64(last_sample) * step));
|
||||||
out_xy.push_back(m.max);
|
out_xy.push_back(m.max);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -269,7 +273,7 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
|
|||||||
const u64 centre = bi * span + span / 2 - origin; // inside [b, e): the bucket is wholly inside
|
const u64 centre = bi * span + span / 2 - origin; // inside [b, e): the bucket is wholly inside
|
||||||
f32 v = 0.0f;
|
f32 v = 0.0f;
|
||||||
copy_raw(centre, centre + 1, &v);
|
copy_raw(centre, centre + 1, &v);
|
||||||
out_xy.push_back(f32(f64(centre) * step)); // x relative to x0()
|
out_xy.push_back(f32(shift + f64(centre) * step)); // x relative to x_origin
|
||||||
out_xy.push_back(v);
|
out_xy.push_back(v);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -283,7 +287,7 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
|
|||||||
const u32 count = pyramid().bucket_count(level, bi);
|
const u32 count = pyramid().bucket_count(level, bi);
|
||||||
if (count == 0) continue;
|
if (count == 0) continue;
|
||||||
const u64 centre = bi * span + span / 2 - origin;
|
const u64 centre = bi * span + span / 2 - origin;
|
||||||
out_xy.push_back(f32(f64(centre) * step)); // x relative to x0()
|
out_xy.push_back(f32(shift + f64(centre) * step)); // x relative to x_origin
|
||||||
out_xy.push_back(f32(pyramid().bucket_sum(level, bi) / f64(count)));
|
out_xy.push_back(f32(pyramid().bucket_sum(level, bi) / f64(count)));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -291,7 +295,7 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
|
|||||||
}
|
}
|
||||||
|
|
||||||
const u64 trail = last * span - origin; // one past the last emitted bucket's span
|
const u64 trail = last * span - origin; // one past the last emitted bucket's span
|
||||||
if (trail < e) gather_raw(trail, e, out_xy);
|
if (trail < e) gather_raw(trail, e, x_origin, out_xy);
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace plot::data
|
} // namespace plot::data
|
||||||
@@ -87,9 +87,9 @@ Range StreamSeries::y_bounds() const { return y_bounds_; }
|
|||||||
|
|
||||||
const MinMaxPyramid& StreamSeries::pyramid() const { return pyramid_; }
|
const MinMaxPyramid& StreamSeries::pyramid() const { return pyramid_; }
|
||||||
|
|
||||||
void StreamSeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
void StreamSeries::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||||
GatherMode mode) const {
|
GatherMode mode) const {
|
||||||
gather_impl(level, begin, end, out_xy, mode);
|
gather_impl(level, begin, end, x_origin, out_xy, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
void StreamSeries::copy_raw(u64 begin, u64 end, f32* out) const {
|
void StreamSeries::copy_raw(u64 begin, u64 end, f32* out) const {
|
||||||
|
|||||||
@@ -100,16 +100,16 @@ u64 XYSeries::snap_index(f64 x) const {
|
|||||||
: hi; // ties go to the lower index
|
: hi; // ties go to the lower index
|
||||||
}
|
}
|
||||||
|
|
||||||
void XYSeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
void XYSeries::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||||
GatherMode mode) const {
|
GatherMode mode) const {
|
||||||
const u64 n = size();
|
const u64 n = size();
|
||||||
const u64 b = std::min(begin, n);
|
const u64 b = std::min(begin, n);
|
||||||
const u64 e = std::min(std::max(end, b), n);
|
const u64 e = std::min(std::max(end, b), n);
|
||||||
if (b >= e) return;
|
if (b >= e) return;
|
||||||
|
|
||||||
// x emitted relative to x0(): see Series::gather(). The x array is f64, so
|
// x emitted relative to the caller's origin: see Series::gather(). The x array is f64, so
|
||||||
// the subtraction happens wide and only the small, exact offset narrows.
|
// the subtraction happens wide and only the small, exact offset narrows.
|
||||||
const f64 origin = x0();
|
const f64 origin = x_origin;
|
||||||
|
|
||||||
if (level == 0) {
|
if (level == 0) {
|
||||||
for (u64 i = b; i < e; ++i) {
|
for (u64 i = b; i < e; ++i) {
|
||||||
@@ -127,7 +127,7 @@ void XYSeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
|||||||
// `begin`, `last` one past the last bucket wholly before `end`, and the two
|
// `begin`, `last` one past the last bucket wholly before `end`, and the two
|
||||||
// edge spans around them are read from the raw arrays -- so no emitted
|
// edge spans around them are read from the raw arrays -- so no emitted
|
||||||
// geometry comes from outside [begin, end), and the emitted x values are the
|
// geometry comes from outside [begin, end), and the emitted x values are the
|
||||||
// array's own coordinates relative to x0().
|
// array's own coordinates relative to the caller's origin.
|
||||||
const u64 span = u64{kBucket} << level;
|
const u64 span = u64{kBucket} << level;
|
||||||
const u64 lend = pyramid().level_size(level);
|
const u64 lend = pyramid().level_size(level);
|
||||||
const u64 first = std::min(ceil_div(b, span), lend);
|
const u64 first = std::min(ceil_div(b, span), lend);
|
||||||
|
|||||||
+23
-11
@@ -323,17 +323,27 @@ void Controller::on_key(const KeyEvent& e) {
|
|||||||
if (zooms) {
|
if (zooms) {
|
||||||
const f64 notches = e.key == kKeyZoomIn ? kKeyZoomNotches : -kKeyZoomNotches;
|
const f64 notches = e.key == kKeyZoomIn ? kKeyZoomNotches : -kKeyZoomNotches;
|
||||||
const f64 factor = zoom_factor(notches);
|
const f64 factor = zoom_factor(notches);
|
||||||
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
|
// Ctrl restricts the zoom to x, Alt to y; bare +/- zooms both. (Shift cannot be
|
||||||
const Axis& a = plot_->x_axis(i);
|
// used: '+' is shift+'=' on common layouts, so key_from() folds them together and
|
||||||
const f64 lo = to_space(a.range.min, a.log);
|
// a bare zoom-in already arrives with Mod::Shift set.) Ctrl+Alt zooms neither,
|
||||||
const f64 hi = to_space(a.range.max, a.log);
|
// which is harmless and needs no special case.
|
||||||
zoom_x_axis(i, from_space(0.5 * (lo + hi), a.log), factor);
|
const bool only_x = has_mod(e.mods, Mod::Ctrl);
|
||||||
|
const bool only_y = has_mod(e.mods, Mod::Alt);
|
||||||
|
if (!only_y) {
|
||||||
|
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
|
||||||
|
const Axis& a = plot_->x_axis(i);
|
||||||
|
const f64 lo = to_space(a.range.min, a.log);
|
||||||
|
const f64 hi = to_space(a.range.max, a.log);
|
||||||
|
zoom_x_axis(i, from_space(0.5 * (lo + hi), a.log), factor);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
|
if (!only_x) {
|
||||||
const Axis& a = plot_->y_axis(i);
|
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
|
||||||
const f64 lo = to_space(a.range.min, a.log);
|
const Axis& a = plot_->y_axis(i);
|
||||||
const f64 hi = to_space(a.range.max, a.log);
|
const f64 lo = to_space(a.range.min, a.log);
|
||||||
zoom_y_axis(i, from_space(0.5 * (lo + hi), a.log), factor);
|
const f64 hi = to_space(a.range.max, a.log);
|
||||||
|
zoom_y_axis(i, from_space(0.5 * (lo + hi), a.log), factor);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (fits) {
|
} else if (fits) {
|
||||||
plot_->auto_fit_x();
|
plot_->auto_fit_x();
|
||||||
@@ -925,7 +935,9 @@ u64 Controller::cursor_at(f32 px, f32 py) const {
|
|||||||
f32 best_d = 0.0f;
|
f32 best_d = 0.0f;
|
||||||
for (u32 i = 0; i < plot_->cursor_count(); ++i) {
|
for (u32 i = 0; i < plot_->cursor_count(); ++i) {
|
||||||
const Cursor& cursor = plot_->cursor(i);
|
const Cursor& cursor = plot_->cursor(i);
|
||||||
if (!cursor.visible) continue;
|
// A readout is drawn but not offered to the pointer: skipping it here is what makes it
|
||||||
|
// undraggable, rather than fighting the drag a frame at a time.
|
||||||
|
if (!cursor.visible || !cursor.selectable) continue;
|
||||||
// One hit test for both orientations, because a cursor *is* one line across the axes:
|
// One hit test for both orientations, because a cursor *is* one line across the axes:
|
||||||
// what the pointer has to be near is that line's own coordinate -- the column of a
|
// what the pointer has to be near is that line's own coordinate -- the column of a
|
||||||
// vertical marker, the row of a horizontal ruler -- measured with the same radius
|
// vertical marker, the row of a horizontal ruler -- measured with the same radius
|
||||||
|
|||||||
+17
-3
@@ -3,18 +3,32 @@
|
|||||||
|
|
||||||
#include <plot/plot/axis.hpp>
|
#include <plot/plot/axis.hpp>
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
namespace plot {
|
namespace plot {
|
||||||
|
|
||||||
void Axis::format_value(f64 v, char* buf, usize n) const {
|
void Axis::format_value(f64 v, char* buf, usize n) const {
|
||||||
|
// The one place a value on this axis becomes text, so the origin is applied here and
|
||||||
|
// everything that goes through it -- tick labels, and the crosshair's readout -- reads in
|
||||||
|
// the same frame of reference as the axis itself.
|
||||||
|
// The subtraction leaves a residue when the value and the origin are the same distant number
|
||||||
|
// (a tick exactly on the origin then formatted as `1.78e-12ms` instead of `0ms`). Snapping it
|
||||||
|
// is a *relative* test, so a genuinely small value on a large axis is not swallowed -- what
|
||||||
|
// this removes is arithmetic dust, not data.
|
||||||
|
f64 shown = v - origin;
|
||||||
|
if (std::fabs(shown) < 1.0e-12 * std::max(1.0, std::fabs(v))) shown = 0.0;
|
||||||
if (format == TickFormat::Time) {
|
if (format == TickFormat::Time) {
|
||||||
format_time(v, buf, n);
|
format_time(shown, buf, n);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
format_si(v, buf, n);
|
format_si(shown, buf, n);
|
||||||
}
|
}
|
||||||
|
|
||||||
void Axis::refresh(f32 px, f32 min_px, bool include_minors) {
|
void Axis::refresh(f32 px, f32 min_px, bool include_minors) {
|
||||||
generate_ticks(range, px, min_px, ticks, include_minors);
|
// The range first, so a following origin is this frame's edge and not the last one's: the
|
||||||
|
// zero tick then sits exactly on the window start, which is the whole point of asking.
|
||||||
|
if (origin_follows_range) origin = range.min;
|
||||||
|
generate_ticks(range, px, min_px, ticks, include_minors, origin);
|
||||||
if (format != TickFormat::Time) return;
|
if (format != TickFormat::Time) return;
|
||||||
// generate_ticks labels with format_si; a time axis relabels its majors here,
|
// generate_ticks labels with format_si; a time axis relabels its majors here,
|
||||||
// because the tick generator has no business knowing about a clock -- through the one
|
// because the tick generator has no business knowing about a clock -- through the one
|
||||||
|
|||||||
+18
-3
@@ -4,6 +4,7 @@
|
|||||||
#include <plot/plot/figure.hpp>
|
#include <plot/plot/figure.hpp>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cstdio>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
|
|
||||||
@@ -277,13 +278,23 @@ bool Figure::place_live_window(XLink& group) {
|
|||||||
// The offset can outlive the window it names: a ring that dropped samples moves
|
// The offset can outlive the window it names: a ring that dropped samples moves
|
||||||
// the oldest x forward under it, so it is clamped against what the members hold
|
// the oldest x forward under it, so it is clamped against what the members hold
|
||||||
// *now* -- measured in the same space, or a log group could hold an offset larger
|
// *now* -- measured in the same space, or a log group could hold an offset larger
|
||||||
// than everything it retains. An offset past that would put the window's right
|
// than everything it retains.
|
||||||
// edge behind the oldest sample left, i.e. over data that is gone.
|
//
|
||||||
|
// The bound is about the whole *window*, not just its right edge -- but only when the window
|
||||||
|
// *can* sit on the data. `retained >= span` is that case, and there the offset may not exceed
|
||||||
|
// `retained - span`: with the loose bound a scroll could put the window's right edge exactly on
|
||||||
|
// the oldest sample, which leaves the entire span to its left over data the scope never had --
|
||||||
|
// an empty margin as wide as the view, reached by a pan or by a zoom-in near that edge. When the
|
||||||
|
// window is *wider* than what the members retain (a ring still filling, or one just trimmed)
|
||||||
|
// nothing can avoid a margin, and the honest placement is the old one: the window's right edge
|
||||||
|
// on the newest sample, the shortfall on the left, which is what a scope that just started
|
||||||
|
// should look like.
|
||||||
const bool log = !plots_.empty() && plots_.back().x_axis(0u).log;
|
const bool log = !plots_.empty() && plots_.back().x_axis(0u).log;
|
||||||
const f64 newest_space = to_link_space(live.max, log);
|
const f64 newest_space = to_link_space(live.max, log);
|
||||||
const f64 retained =
|
const f64 retained =
|
||||||
std::max(0.0, newest_space - to_link_space(live.min, log));
|
std::max(0.0, newest_space - to_link_space(live.min, log));
|
||||||
group.offset = std::clamp(group.offset, 0.0, retained);
|
const f64 reach = retained >= group.span ? retained - group.span : retained;
|
||||||
|
group.offset = std::clamp(group.offset, 0.0, std::max(0.0, reach));
|
||||||
group.live_newest = live.max;
|
group.live_newest = live.max;
|
||||||
group.has_live = true;
|
group.has_live = true;
|
||||||
const f64 right = newest_space - group.offset;
|
const f64 right = newest_space - group.offset;
|
||||||
@@ -440,6 +451,10 @@ const FigureLayout& Figure::layout() {
|
|||||||
// not drawn once per row. A figure told to label every row keeps them everywhere,
|
// not drawn once per row. A figure told to label every row keeps them everywhere,
|
||||||
// and then there is no shared axis to speak of: each row has its own.
|
// and then there is no shared axis to speak of: each row has its own.
|
||||||
p.set_x_strip_mode(labels_bottom_only_ && row != bottom_row ? XStrip::None : XStrip::Both);
|
p.set_x_strip_mode(labels_bottom_only_ && row != bottom_row ? XStrip::None : XStrip::Both);
|
||||||
|
// Before either pass below, and the same value for every row: nothing is measured on
|
||||||
|
// the right, so unlike the left gutter this needs no measurement pass -- the rows end
|
||||||
|
// on a common column because they are all told the one width.
|
||||||
|
p.set_right_gutter(right_gutter_);
|
||||||
}
|
}
|
||||||
// After the viewports, so a row's ticks and grid -- built from its axes rect -- and
|
// After the viewports, so a row's ticks and grid -- built from its axes rect -- and
|
||||||
// its transforms describe this frame's rects; and before the rows lay themselves
|
// its transforms describe this frame's rects; and before the rows lay themselves
|
||||||
|
|||||||
+116
-19
@@ -306,6 +306,57 @@ u32 Plot::add_annotation(const Annotation& annotation) {
|
|||||||
return static_cast<u32>(annotations_.size() - 1);
|
return static_cast<u32>(annotations_.size() - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Plot::set_cursor_color(u32 id, Color color) {
|
||||||
|
if (id >= cursors_.size()) return;
|
||||||
|
cursors_[id].color = color;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Plot::set_cursor_stroke(u32 id, f32 width, Vec2 dash) {
|
||||||
|
if (id >= cursors_.size()) return;
|
||||||
|
cursors_[id].width = width;
|
||||||
|
cursors_[id].dash = dash;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Plot::set_cursor_selectable(u32 id, bool selectable) {
|
||||||
|
if (id >= cursors_.size()) return;
|
||||||
|
cursors_[id].selectable = selectable;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Plot::set_x_axis_origin(u32 axis, f64 origin) {
|
||||||
|
if (axis >= x_axes_.size()) return;
|
||||||
|
x_axes_[axis].origin = origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Plot::set_y_axis_origin(u32 axis, f64 origin) {
|
||||||
|
if (axis >= y_axes_.size()) return;
|
||||||
|
y_axes_[axis].origin = origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Plot::set_x_axis_origin_from_range(u32 axis, bool on) {
|
||||||
|
if (axis >= x_axes_.size()) return;
|
||||||
|
x_axes_[axis].origin_follows_range = on;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Plot::set_y_axis_origin_from_range(u32 axis, bool on) {
|
||||||
|
if (axis >= y_axes_.size()) return;
|
||||||
|
y_axes_[axis].origin_follows_range = on;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Plot::set_x_note(std::string text) { x_note_ = std::move(text); }
|
||||||
|
|
||||||
|
f64 Plot::x_axis_origin(u32 axis) const {
|
||||||
|
return axis < x_axes_.size() ? x_axes_[axis].origin : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
f64 Plot::y_axis_origin(u32 axis) const {
|
||||||
|
return axis < y_axes_.size() ? y_axes_[axis].origin : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Plot::set_annotation(u32 id, const Annotation& annotation) {
|
||||||
|
if (id >= annotations_.size()) return;
|
||||||
|
annotations_[id] = annotation;
|
||||||
|
}
|
||||||
|
|
||||||
u32 Plot::add_region(const Region& region) {
|
u32 Plot::add_region(const Region& region) {
|
||||||
// A region whose bounds are not numbers is not an interval this plot can hold, and a
|
// A region whose bounds are not numbers is not an interval this plot can hold, and a
|
||||||
// `Free` one stored that way could never be dragged back (every motion re-derives both
|
// `Free` one stored that way could never be dragged back (every motion re-derives both
|
||||||
@@ -988,12 +1039,21 @@ const PlotLayout& Plot::layout() {
|
|||||||
x_height = std::min(x_height, x_budget);
|
x_height = std::min(x_height, x_budget);
|
||||||
const f32 y_title_width = std::min(y_title_w, y_budget - y_width);
|
const f32 y_title_width = std::min(y_title_w, y_budget - y_width);
|
||||||
const f32 x_title_height = std::min(x_title_h, x_budget - x_height);
|
const f32 x_title_height = std::min(x_title_h, x_budget - x_height);
|
||||||
|
// The right-hand band, out of what is left of the same allowance the two left
|
||||||
|
// strips were served from -- so the three together cannot take more than
|
||||||
|
// `1 - kMinAxesShare` of the width however much each asks for. Nothing is drawn
|
||||||
|
// in it, so unlike the strips above it is a want and never a measurement, and it
|
||||||
|
// cannot feed the fixed point below: the y labels are chosen from the row's range
|
||||||
|
// and height, so a narrower `axes` does not change what they need.
|
||||||
|
const f32 right_gutter =
|
||||||
|
std::min(right_gutter_, y_budget - (y_width + y_title_width));
|
||||||
layout_.y_axis_width = y_width;
|
layout_.y_axis_width = y_width;
|
||||||
layout_.x_axis_height = x_height;
|
layout_.x_axis_height = x_height;
|
||||||
layout_.y_title_width = y_title_width;
|
layout_.y_title_width = y_title_width;
|
||||||
layout_.x_title_height = x_title_height;
|
layout_.x_title_height = x_title_height;
|
||||||
|
layout_.right_gutter = right_gutter;
|
||||||
layout_.axes = Rect{viewport_.x + y_width + y_title_width, viewport_.y,
|
layout_.axes = Rect{viewport_.x + y_width + y_title_width, viewport_.y,
|
||||||
vw - (y_width + y_title_width),
|
vw - (y_width + y_title_width + right_gutter),
|
||||||
vh - (x_height + x_title_height)};
|
vh - (x_height + x_title_height)};
|
||||||
layout_.y_title_area = Rect{viewport_.x, layout_.axes.y, y_title_width, layout_.axes.h};
|
layout_.y_title_area = Rect{viewport_.x, layout_.axes.y, y_title_width, layout_.axes.h};
|
||||||
layout_.y_label_area =
|
layout_.y_label_area =
|
||||||
@@ -1033,7 +1093,7 @@ const PlotLayout& Plot::layout() {
|
|||||||
build_regions();
|
build_regions();
|
||||||
build_legend();
|
build_legend();
|
||||||
// The crosshair last: its panel is fitted into `axes` and its lines are held inside it, and
|
// The crosshair last: its panel is fitted into `axes` and its lines are held inside it, and
|
||||||
// its rows are the interpolated values at a data x -- so it wants the transforms the
|
// its rows are the sample values nearest a data x -- so it wants the transforms the
|
||||||
// settled layout pins, exactly as the legend and the regions do.
|
// settled layout pins, exactly as the legend and the regions do.
|
||||||
build_crosshair();
|
build_crosshair();
|
||||||
return layout_;
|
return layout_;
|
||||||
@@ -1303,13 +1363,13 @@ void Plot::build_regions() {
|
|||||||
CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept {
|
CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept {
|
||||||
CrosshairSpan span;
|
CrosshairSpan span;
|
||||||
const u64 n = series.size();
|
const u64 n = series.size();
|
||||||
// No samples and no finite query: nothing to interpolate between.
|
// No samples and no finite query: nothing to bracket.
|
||||||
if (n == 0u || !std::isfinite(x)) return span;
|
if (n == 0u || !std::isfinite(x)) return span;
|
||||||
|
|
||||||
// The bracketing samples, straight from the series (`neighbours()`/`x_at()`):
|
// The bracketing samples, straight from the series (`neighbours()`/`x_at()`):
|
||||||
// the window is closed at both ends -- the oldest and the newest sample are
|
// the window is closed at both ends -- the oldest and the newest sample are
|
||||||
// samples too -- and a query outside it has no neighbours to interpolate
|
// samples too -- and a query outside it has no neighbours at all. Answering
|
||||||
// between. Answering with the nearest sample's value instead is the clamped lie
|
// with the nearest sample's value instead is the clamped lie
|
||||||
// this refuses: the readout would report a number as if it were under the
|
// this refuses: the readout would report a number as if it were under the
|
||||||
// pointer when the series does not reach that far. The series owns where its
|
// pointer when the series does not reach that far. The series owns where its
|
||||||
// samples are, so an XY series is bracketed by its own x array exactly, where
|
// samples are, so an XY series is bracketed by its own x array exactly, where
|
||||||
@@ -1322,8 +1382,8 @@ CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept {
|
|||||||
// A query that landed **on** a sample is a span of one, whichever side the
|
// A query that landed **on** a sample is a span of one, whichever side the
|
||||||
// series' bracket reported: a sample at the query is the answer, and the two
|
// series' bracket reported: a sample at the query is the answer, and the two
|
||||||
// ends of the window are samples too -- a bracket of (0, 1) at a query sitting
|
// ends of the window are samples too -- a bracket of (0, 1) at a query sitting
|
||||||
// on sample 0 would read as an interpolation with no width. Decided on the
|
// on sample 0 would read as a span with a width it does not have. Decided on
|
||||||
// samples' own coordinates, so it holds for any `neighbours()` implementation.
|
// the samples' own coordinates, so it holds for any `neighbours()` impl.
|
||||||
if (series.x_at(i0) == x) {
|
if (series.x_at(i0) == x) {
|
||||||
span.begin = span.end = i0;
|
span.begin = span.end = i0;
|
||||||
span.t = 0.0;
|
span.t = 0.0;
|
||||||
@@ -1338,16 +1398,16 @@ CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (i0 == i1) {
|
if (i0 == i1) {
|
||||||
// The series bracketed a single sample for a query that is not on it: nothing
|
// The series bracketed a single sample for a query that is not on it: there
|
||||||
// to interpolate with, and the sample is the honest answer.
|
// is no second sample to choose between, and this one is the honest answer.
|
||||||
span.begin = span.end = i0;
|
span.begin = span.end = i0;
|
||||||
span.t = 0.0;
|
span.t = 0.0;
|
||||||
} else {
|
} else {
|
||||||
span.begin = i0;
|
span.begin = i0;
|
||||||
span.end = i1;
|
span.end = i1;
|
||||||
// `t` from the samples' own x coordinates, so the interpolation is exact on
|
// `t` from the samples' own x coordinates, so "which is nearer" is exact on
|
||||||
// any grid, uniform or not. A degenerate pair (two samples sharing an x)
|
// any grid, uniform or not. A degenerate pair (two samples sharing an x)
|
||||||
// interpolates nothing and reads the earlier sample.
|
// reads the earlier sample.
|
||||||
const f64 xa = series.x_at(i0);
|
const f64 xa = series.x_at(i0);
|
||||||
const f64 xb = series.x_at(i1);
|
const f64 xb = series.x_at(i1);
|
||||||
span.t = xb > xa ? (x - xa) / (xb - xa) : 0.0;
|
span.t = xb > xa ? (x - xa) / (xb - xa) : 0.0;
|
||||||
@@ -1361,6 +1421,13 @@ CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept {
|
|||||||
return span;
|
return span;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
f64 crosshair_interpolate(f64 y_begin, f64 y_end, f64 t) noexcept {
|
||||||
|
// This form, not `(1 - t) * a + t * b`: at `t == 0` it is `y_begin` and at `t == 1` it is
|
||||||
|
// `y_end`, both exactly, so a query that landed on a sample reads that sample back whatever
|
||||||
|
// mode is in force. The symmetric form rounds at both ends.
|
||||||
|
return y_begin + t * (y_end - y_begin);
|
||||||
|
}
|
||||||
|
|
||||||
std::string crosshair_unnamed_label(u32 trace) {
|
std::string crosshair_unnamed_label(u32 trace) {
|
||||||
char buf[16];
|
char buf[16];
|
||||||
std::snprintf(buf, sizeof buf, "#%u", static_cast<unsigned>(trace));
|
std::snprintf(buf, sizeof buf, "#%u", static_cast<unsigned>(trace));
|
||||||
@@ -1390,6 +1457,11 @@ void Plot::set_crosshair(f64 x, f64 y, bool active) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool Plot::crosshair_value(u32 trace, f64 x, f64& out_y) const {
|
bool Plot::crosshair_value(u32 trace, f64 x, f64& out_y) const {
|
||||||
|
f64 ignored = 0.0;
|
||||||
|
return crosshair_point(trace, x, ignored, out_y);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Plot::crosshair_point(u32 trace, f64 x, f64& out_x, f64& out_y) const {
|
||||||
if (trace >= traces_.size()) return false;
|
if (trace >= traces_.size()) return false;
|
||||||
const data::Series* series = traces_[trace].series;
|
const data::Series* series = traces_[trace].series;
|
||||||
if (series == nullptr) return false;
|
if (series == nullptr) return false;
|
||||||
@@ -1401,12 +1473,31 @@ bool Plot::crosshair_value(u32 trace, f64 x, f64& out_y) const {
|
|||||||
// when it fell between them. The scratch is cleared and refilled rather than reallocated,
|
// when it fell between them. The scratch is cleared and refilled rather than reallocated,
|
||||||
// and sized for two pairs from construction, so the pointer path allocates nothing.
|
// and sized for two pairs from construction, so the pointer path allocates nothing.
|
||||||
crosshair_scratch_.clear();
|
crosshair_scratch_.clear();
|
||||||
series->gather(0u, span.begin, span.end + 1u, crosshair_scratch_);
|
// The origin is the query itself: only y is read out of the pairs below -- the x comes back
|
||||||
|
// through `x_at()` -- so this names the one origin that cannot lose precision for a caller
|
||||||
|
// that might.
|
||||||
|
series->gather(0u, span.begin, span.end + 1u, x, crosshair_scratch_);
|
||||||
const usize pairs = static_cast<usize>(span.end - span.begin + 1u);
|
const usize pairs = static_cast<usize>(span.end - span.begin + 1u);
|
||||||
if (crosshair_scratch_.size() != 2u * pairs) return false; // a series that produced nothing
|
if (crosshair_scratch_.size() != 2u * pairs) return false; // a series that produced nothing
|
||||||
const f64 y_begin = static_cast<f64>(crosshair_scratch_[1u]);
|
const f64 y_begin = static_cast<f64>(crosshair_scratch_[1u]);
|
||||||
const f64 y_end = static_cast<f64>(crosshair_scratch_[2u * pairs - 1u]);
|
const f64 y_end = static_cast<f64>(crosshair_scratch_[2u * pairs - 1u]);
|
||||||
out_y = crosshair_interpolate(y_begin, y_end, span.t);
|
if (crosshair_.value_mode == CrosshairValue::Interpolated) {
|
||||||
|
// The signal the samples imply *at* this x, so the point is the query's own x: that is
|
||||||
|
// where an interpolated number lives, and the marker on the polyline there is honest
|
||||||
|
// about it.
|
||||||
|
out_x = x;
|
||||||
|
out_y = crosshair_interpolate(y_begin, y_end, span.t);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// `NearestSample`: the nearer of the two, and **its own x with it**. `span.t` is measured on
|
||||||
|
// the samples' own coordinates, so "nearer" is exact on any grid; a tie reads the earlier
|
||||||
|
// sample, which is the bias `crosshair_span()` already takes for a degenerate pair. The x
|
||||||
|
// comes back through `x_at()` rather than from the gathered pairs, which are relative to a
|
||||||
|
// caller-named origin by the `Series::gather()` contract -- adding it back here would be a
|
||||||
|
// second reconstruction of a coordinate the series will hand over directly.
|
||||||
|
const bool take_begin = span.t <= 0.5;
|
||||||
|
out_x = series->x_at(take_begin ? span.begin : span.end);
|
||||||
|
out_y = take_begin ? y_begin : y_end;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1475,17 +1566,23 @@ void Plot::build_crosshair() {
|
|||||||
row.trace = i;
|
row.trace = i;
|
||||||
row.valid = false;
|
row.valid = false;
|
||||||
row.listed = false;
|
row.listed = false;
|
||||||
|
row.x = 0.0;
|
||||||
row.y = 0.0;
|
row.y = 0.0;
|
||||||
row.marker = Vec2{};
|
row.marker = Vec2{};
|
||||||
row.swatch = Rect{};
|
row.swatch = Rect{};
|
||||||
row.label_box = Rect{};
|
row.label_box = Rect{};
|
||||||
row.value_box = Rect{};
|
row.value_box = Rect{};
|
||||||
row.label.assign(trace.name.empty() ? crosshair_unnamed_label(i) : trace.name);
|
row.label.assign(trace.name.empty() ? crosshair_unnamed_label(i) : trace.name);
|
||||||
// The value: the interpolated number where the trace has a sample at this x, and the em dash
|
// The value where the trace has one at this x, and the em dash where it does not. The row is
|
||||||
// where it does not. The row is listed either way, so the absence has to be *said* -- a blank
|
// listed either way, so the absence has to be *said* -- a blank value cell reads as a value
|
||||||
// value cell reads as a value that has not arrived -- and the dash is measured into the panel's
|
// that has not arrived -- and the dash is measured into the panel's width like any other
|
||||||
// width like any other value, so the row's boxes are placed by one rule.
|
// value, so the row's boxes are placed by one rule.
|
||||||
if (crosshair_value(i, crosshair_.x, row.y)) {
|
//
|
||||||
|
// The **point**, not just the number: `crosshair_point()` reports the x the value belongs to
|
||||||
|
// as well, which in the default `NearestSample` mode is the quoted sample's own and not the
|
||||||
|
// pointer's. Both coordinates come from that one call, so the mark and the text cannot
|
||||||
|
// disagree about which datum is being read (see `CrosshairRow::marker`).
|
||||||
|
if (crosshair_point(i, crosshair_.x, row.x, row.y)) {
|
||||||
row.valid = true;
|
row.valid = true;
|
||||||
char value[32];
|
char value[32];
|
||||||
// The trace's *own* y axis formats the number, so a readout reads exactly like the
|
// The trace's *own* y axis formats the number, so a readout reads exactly like the
|
||||||
@@ -1494,7 +1591,7 @@ void Plot::build_crosshair() {
|
|||||||
row.value.assign(value);
|
row.value.assign(value);
|
||||||
// Placed through the trace's own axis pair, so the marker sits where that trace is
|
// Placed through the trace's own axis pair, so the marker sits where that trace is
|
||||||
// actually drawn however its axes are scaled.
|
// actually drawn however its axes are scaled.
|
||||||
row.marker = Vec2{static_cast<f32>(x_transform(trace.axis_x).to_screen_x(crosshair_.x)),
|
row.marker = Vec2{static_cast<f32>(x_transform(trace.axis_x).to_screen_x(row.x)),
|
||||||
static_cast<f32>(y_transform(trace.axis_y).to_screen_y(row.y))};
|
static_cast<f32>(y_transform(trace.axis_y).to_screen_y(row.y))};
|
||||||
} else {
|
} else {
|
||||||
row.value.assign(kCrosshairNoValue);
|
row.value.assign(kCrosshairNoValue);
|
||||||
|
|||||||
+62
-12
@@ -98,15 +98,42 @@ void format_time(f64 seconds, char* buf, usize n) {
|
|||||||
const char* const sign = seconds < 0.0 ? "-" : "";
|
const char* const sign = seconds < 0.0 ? "-" : "";
|
||||||
const f64 t = std::fabs(seconds);
|
const f64 t = std::fabs(seconds);
|
||||||
|
|
||||||
if (t < 1.0) {
|
// `0.9995` and not `1.0`: three significant figures of 0.9999 s are `1e+03ms`, so a value
|
||||||
// Sub-second: milliseconds. The 999.5 cut keeps %.3g out of scientific
|
// that *rounds* to a second belongs to the whole-second branch below, which writes it `1s`.
|
||||||
// notation (it would print "1e+03ms") right below the one-second boundary.
|
// The same spill one rung down is handled inside the ladder.
|
||||||
const f64 ms = t * 1000.0;
|
if (t < 0.9995) {
|
||||||
if (ms < 999.5) {
|
// Sub-second: the prefix that keeps the mantissa in [1, 1000) -- the same rule the SI
|
||||||
std::snprintf(buf, n, "%s%.3gms", sign, ms);
|
// formatter uses on a y axis, and the reading a zoomed-in window needs. A fixed
|
||||||
} else {
|
// millisecond unit printed `0.005ms` where `5us` is what the axis is showing, and
|
||||||
std::snprintf(buf, n, "%s%.3gs", sign, t);
|
// `1e-05ms` where `10ns` is. The micro sign is the UTF-8 sequence the embedded font has a
|
||||||
|
// glyph for (see `format_si`), not a Latin-1 byte.
|
||||||
|
// Exactly zero carries no unit, the same convention `format_si` states: every prefixed
|
||||||
|
// form of it is "0" with a unit the value does not have.
|
||||||
|
if (t == 0.0) {
|
||||||
|
std::snprintf(buf, n, "0");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
static const struct {
|
||||||
|
f64 scale;
|
||||||
|
const char* unit;
|
||||||
|
} kUnits[] = {
|
||||||
|
{1.0e-3, "ms"},
|
||||||
|
{1.0e-6, "\u00b5s"},
|
||||||
|
{1.0e-9, "ns"},
|
||||||
|
{1.0e-12, "ps"},
|
||||||
|
};
|
||||||
|
// The rung is picked by the *rounded* mantissa, which is what `format_si`'s own 999.5
|
||||||
|
// check buys there: 0.9999 ms is `999.9µs` before rounding and `1e+03µs` after, so it is
|
||||||
|
// the millisecond rung's value and reads `1ms`. Testing the raw `t < u.scale` let the
|
||||||
|
// mantissa reach 1000 at every rung boundary.
|
||||||
|
for (const auto& u : kUnits) {
|
||||||
|
if (t < u.scale * 0.9995) continue;
|
||||||
|
std::snprintf(buf, n, "%s%.3g%s", sign, t / u.scale, u.unit);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Below a picosecond nothing on a time axis reads in prefixed units; plain seconds keep
|
||||||
|
// the fallback's own precision rather than inventing one.
|
||||||
|
std::snprintf(buf, n, "%s%.3gs", sign, t);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +158,8 @@ void format_time(f64 seconds, char* buf, usize n) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out, bool include_minors) {
|
void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out, bool include_minors,
|
||||||
|
f64 origin) {
|
||||||
out.clear();
|
out.clear();
|
||||||
|
|
||||||
const f64 lo = range.min;
|
const f64 lo = range.min;
|
||||||
@@ -157,7 +185,10 @@ void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out, boo
|
|||||||
: 10.0);
|
: 10.0);
|
||||||
if (!std::isfinite(step) || !(step > 0.0)) return; // span so large the step overflowed
|
if (!std::isfinite(step) || !(step > 0.0)) return; // span so large the step overflowed
|
||||||
|
|
||||||
const f64 first_major = std::ceil(lo / step) * step;
|
// The major walk starts on the *origin's* grid: with an origin the ticks a host reads are
|
||||||
|
// round numbers relative to it (0, 0.5ms, 1ms of the window it zoomed into), which is the
|
||||||
|
// whole point of having one. `origin = 0` is the plain absolute grid.
|
||||||
|
const f64 first_major = origin + std::ceil((lo - origin) / step) * step;
|
||||||
if (!std::isfinite(first_major)) return;
|
if (!std::isfinite(first_major)) return;
|
||||||
|
|
||||||
// Minor ticks subdivide the major step: by 4 when the step's leading digit is
|
// Minor ticks subdivide the major step: by 4 when the step's leading digit is
|
||||||
@@ -189,6 +220,23 @@ void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out, boo
|
|||||||
const f64 first = include_minors ? std::ceil(lo / minor_step) * minor_step : first_major;
|
const f64 first = include_minors ? std::ceil(lo / minor_step) * minor_step : first_major;
|
||||||
if (!std::isfinite(first)) return;
|
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];
|
char label[kLabelBuf];
|
||||||
usize minors = 0;
|
usize minors = 0;
|
||||||
for (f64 v = first; v <= hi + stride * 1e-9;) {
|
for (f64 v = first; v <= hi + stride * 1e-9;) {
|
||||||
@@ -213,12 +261,14 @@ void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out, boo
|
|||||||
}
|
}
|
||||||
if (!major) ++minors;
|
if (!major) ++minors;
|
||||||
Tick tick;
|
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;
|
tick.major = major;
|
||||||
if (major) {
|
if (major) {
|
||||||
// format_si owns both label guards: it never writes past the buffer, and it
|
// 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.
|
// 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;
|
tick.label = label;
|
||||||
}
|
}
|
||||||
out.push_back(std::move(tick));
|
out.push_back(std::move(tick));
|
||||||
|
|||||||
@@ -9,12 +9,17 @@
|
|||||||
namespace plot::render {
|
namespace plot::render {
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
|
#ifndef NDEBUG
|
||||||
/// Two declared cells agree when neither size differs by more than the atlas'
|
/// Two declared cells agree when neither size differs by more than the atlas'
|
||||||
/// tolerance: the test `glyph_is_cell()` makes, for a cell against a cell.
|
/// tolerance: the test `glyph_is_cell()` makes, for a cell against a cell.
|
||||||
|
///
|
||||||
|
/// Guarded because its one caller is an `assert` (see `TextBatch::set_cell`), so a
|
||||||
|
/// release build has no use for it and would warn that it is unused.
|
||||||
bool same_cell(Vec2 a, Vec2 b) noexcept {
|
bool same_cell(Vec2 a, Vec2 b) noexcept {
|
||||||
const auto near = [](f32 p, f32 q) { return std::fabs(p - q) <= kAtlasCellTolerance; };
|
const auto near = [](f32 p, f32 q) { return std::fabs(p - q) <= kAtlasCellTolerance; };
|
||||||
return near(a.x, b.x) && near(a.y, b.y);
|
return near(a.x, b.x) && near(a.y, b.y);
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
/// `axis` as a unit vector: the cell's local +x, which is what the instance and
|
/// `axis` as a unit vector: the cell's local +x, which is what the instance and
|
||||||
/// the shader both take (see `TextInstance`). A zero, infinite or NaN direction
|
/// the shader both take (see `TextInstance`). A zero, infinite or NaN direction
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ void LineProgram::set_color(Color color) const noexcept { program_.set_uniform("
|
|||||||
|
|
||||||
void LineProgram::set_aa(f32 px) const noexcept { program_.set_uniform("u_aa", px); }
|
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 {
|
bool TextProgram::create(Status* st) noexcept {
|
||||||
program_ = gl::Program::from_source(kTextVS, kTextFS, st);
|
program_ = gl::Program::from_source(kTextVS, kTextFS, st);
|
||||||
if (program_.id() == 0) {
|
if (program_.id() == 0) {
|
||||||
|
|||||||
+569
-71
@@ -9,6 +9,7 @@
|
|||||||
#include <plot/render/detail/text_scale.hpp>
|
#include <plot/render/detail/text_scale.hpp>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cstring>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
@@ -42,15 +43,6 @@ bool fail(Status* st, ErrorCode code, const char* message) noexcept {
|
|||||||
/// clamp, so it lands off-scale rather than as NaN.
|
/// clamp, so it lands off-scale rather than as NaN.
|
||||||
f64 space(f64 v, bool log) noexcept { return log ? Transform::log10_positive(v) : v; }
|
f64 space(f64 v, bool log) noexcept { return log ? Transform::log10_positive(v) : v; }
|
||||||
|
|
||||||
/// Sample index for a (possibly fractional, possibly non-finite) position in a
|
|
||||||
/// series' index space, clamped to [0, n]: NaN and -inf to 0, +inf to n, so a
|
|
||||||
/// range the axis cannot map still yields a valid window.
|
|
||||||
u64 clamp_index(f64 v, u64 n) noexcept {
|
|
||||||
if (!(v > 0.0)) return 0;
|
|
||||||
if (v >= static_cast<f64>(n)) return n;
|
|
||||||
return static_cast<u64>(v);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One ASCII/UTF-8 line of the measurement readout. `format_si` leaves room for
|
/// One ASCII/UTF-8 line of the measurement readout. `format_si` leaves room for
|
||||||
/// three significant digits plus a prefix and sign.
|
/// three significant digits plus a prefix and sign.
|
||||||
constexpr usize kNumberChars = 24;
|
constexpr usize kNumberChars = 24;
|
||||||
@@ -541,6 +533,7 @@ void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) {
|
|||||||
draw_measures(plot, layout);
|
draw_measures(plot, layout);
|
||||||
draw_annotations(plot);
|
draw_annotations(plot);
|
||||||
draw_legend(plot, layout);
|
draw_legend(plot, layout);
|
||||||
|
draw_x_note(plot, layout);
|
||||||
// The under text batch goes out here, so the readout's panel below it can
|
// The under text batch goes out here, so the readout's panel below it can
|
||||||
// occlude what it covers: legend names, cursor labels, measure labels and
|
// occlude what it covers: legend names, cursor labels, measure labels and
|
||||||
// annotation captions are all chrome the panel is allowed to sit on.
|
// annotation captions are all chrome the panel is allowed to sit on.
|
||||||
@@ -621,10 +614,16 @@ void Renderer::upload_text_vertices(std::span<const f32> interleaved) noexcept {
|
|||||||
text_vao_.upload(interleaved.data(), static_cast<u32>(interleaved.size() / 10u));
|
text_vao_.upload(interleaved.data(), static_cast<u32>(interleaved.size() / 10u));
|
||||||
}
|
}
|
||||||
|
|
||||||
void Renderer::flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash) noexcept {
|
void Renderer::flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash,
|
||||||
const u32 segments = lines_.segment_count();
|
bool snap) noexcept {
|
||||||
|
flush_lines(lines_, map, color, width, dash, snap);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Renderer::flush_lines(render::LineBatch& batch, const Mapping& map, Color color, f32 width,
|
||||||
|
Vec2 dash, bool snap) noexcept {
|
||||||
|
const u32 segments = batch.segment_count();
|
||||||
if (segments != 0 && ready_) {
|
if (segments != 0 && ready_) {
|
||||||
upload_vertices(lines_.data());
|
upload_vertices(batch.data());
|
||||||
line_program_.bind();
|
line_program_.bind();
|
||||||
line_program_.set_scale(map.scale);
|
line_program_.set_scale(map.scale);
|
||||||
line_program_.set_offset(map.offset);
|
line_program_.set_offset(map.offset);
|
||||||
@@ -633,6 +632,9 @@ void Renderer::flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash
|
|||||||
// Set on *every* flush, solid included, so the crosshair's dash cannot leak into the
|
// Set on *every* flush, solid included, so the crosshair's dash cannot leak into the
|
||||||
// grid, a trace or a cursor drawn after it (see draw_crosshair_lines()).
|
// grid, a trace or a cursor drawn after it (see draw_crosshair_lines()).
|
||||||
line_program_.set_dash(dash);
|
line_program_.set_dash(dash);
|
||||||
|
// Likewise set on every flush: the grid's snap must not follow the trace drawn
|
||||||
|
// after it onto the pixel grid.
|
||||||
|
line_program_.set_snap(snap);
|
||||||
line_program_.set_color(color);
|
line_program_.set_color(color);
|
||||||
// The AA fringe, too: the style's logical knob in device pixels, clamped so
|
// The AA fringe, too: the style's logical knob in device pixels, clamped so
|
||||||
// a 0 style still keeps a quarter pixel of ramp. 1.0 -- the default -- is
|
// a 0 style still keeps a quarter pixel of ramp. 1.0 -- the default -- is
|
||||||
@@ -646,7 +648,7 @@ void Renderer::flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash
|
|||||||
stats_.staged_segments += segments;
|
stats_.staged_segments += segments;
|
||||||
++stats_.draw_calls;
|
++stats_.draw_calls;
|
||||||
}
|
}
|
||||||
lines_.clear(); // keeps the capacity, so the next group re-uses the store
|
batch.clear(); // keeps the capacity, so the next group re-uses the store
|
||||||
}
|
}
|
||||||
|
|
||||||
void Renderer::flush_text(render::TextBatch& batch) noexcept {
|
void Renderer::flush_text(render::TextBatch& batch) noexcept {
|
||||||
@@ -739,24 +741,16 @@ void Renderer::stage_segment(f64 origin_x, f64 x0, f64 y0, f64 x1, f64 y1, bool
|
|||||||
static_cast<f32>(space(y1, log_y))});
|
static_cast<f32>(space(y1, log_y))});
|
||||||
}
|
}
|
||||||
|
|
||||||
void Renderer::stage_xy(const f32* xy, u32 n, f64 series_x0, f64 origin_x, bool log_x,
|
void Renderer::stage_xy(const f32* xy, u32 n, f64 origin_x, bool log_x, bool log_y) {
|
||||||
bool log_y) {
|
|
||||||
if (!log_x && !log_y) {
|
if (!log_x && !log_y) {
|
||||||
// The common case: reconstruct the absolute data x in f64 (`x0 + rel` --
|
// The common case costs nothing at all: the gather emitted x relative to this very origin
|
||||||
// the gather contract's own reconstruction), stage it relative to the
|
// (`draw_traces` asks it for `x_range.min`, which is the draw's origin too) and a linear y
|
||||||
// draw's origin, and the f32 is O(visible pixels), not O(epoch).
|
// needs no mapping, so the gathered buffer *is* the staged one.
|
||||||
staged_.clear();
|
lines_.add_polyline_xy(xy, n);
|
||||||
staged_.reserve(usize(n) * 2u);
|
|
||||||
const f64 origin_s = space(origin_x, log_x);
|
|
||||||
for (u32 i = 0; i < n; ++i) {
|
|
||||||
staged_.push_back(static_cast<f32>(xy[2u * i] + series_x0 - origin_s));
|
|
||||||
staged_.push_back(xy[2u * i + 1u]);
|
|
||||||
}
|
|
||||||
lines_.add_polyline_xy(staged_.data(), n);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (u32 i = 0; i + 1u < n; ++i) {
|
for (u32 i = 0; i + 1u < n; ++i) {
|
||||||
stage_segment(origin_x, xy[2u * i] + series_x0, xy[2u * i + 1u], xy[2u * i + 2u] + series_x0,
|
stage_segment(origin_x, xy[2u * i] + origin_x, xy[2u * i + 1u], xy[2u * i + 2u] + origin_x,
|
||||||
xy[2u * i + 3u], log_x, log_y);
|
xy[2u * i + 3u], log_x, log_y);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -803,7 +797,7 @@ void Renderer::draw_grid(const Plot& plot) {
|
|||||||
// to draw rather than being drawn transparent. Majors first, minors over them, as
|
// to draw rather than being drawn transparent. Majors first, minors over them, as
|
||||||
// the single group order always did. `LineBatch::clear()` keeps its capacity, so
|
// the single group order always did. `LineBatch::clear()` keeps its capacity, so
|
||||||
// the second pass re-uses the first one's storage.
|
// the second pass re-uses the first one's storage.
|
||||||
const auto draw_class = [&](bool major, bool visible, Color color, f32 width) {
|
const auto draw_class = [&](bool major, bool visible, Color color, f32 width, Vec2 dash) {
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -811,12 +805,23 @@ void Renderer::draw_grid(const Plot& plot) {
|
|||||||
if (line.major != major) continue; // grid.build() already dropped non-finite ones
|
if (line.major != major) continue; // grid.build() already dropped non-finite ones
|
||||||
stage_segment(x_range.min, line.x0, line.y0, line.x1, line.y1, log_x, log_y);
|
stage_segment(x_range.min, line.x0, line.y0, line.x1, line.y1, log_x, log_y);
|
||||||
}
|
}
|
||||||
flush_lines(map, color, width);
|
// Snapped: a grid line is chrome at a round *data* value, and a hairline that
|
||||||
|
// straddles two columns reads as a grey smear and shimmers as the view pans.
|
||||||
|
flush_lines(map, color, width, dash, true);
|
||||||
};
|
};
|
||||||
draw_class(true, style_.grid_major_visible, style_.grid_major,
|
draw_class(true, style_.grid_major_visible, style_.grid_major,
|
||||||
style_.device(style_.grid_major_width));
|
style_.device(style_.grid_major_width), Vec2{});
|
||||||
|
// The minor grid's dash rides the same per-draw uniform the crosshair and a dashed trace use,
|
||||||
|
// and `off == 0` is the solid default. Each line starts its own pattern (the dash distance is
|
||||||
|
// per-segment), and every segment here is built from the range's minimum, so the dashes of the
|
||||||
|
// vertical lines line up with each other -- the grid reads as one texture rather than as a
|
||||||
|
// jumble of unrelated dashes.
|
||||||
|
const Vec2 minor_dash = style_.grid_minor_dash_off > 0.0f
|
||||||
|
? Vec2{style_.device(style_.grid_minor_dash_on),
|
||||||
|
style_.device(style_.grid_minor_dash_off)}
|
||||||
|
: Vec2{};
|
||||||
draw_class(false, style_.grid_minor_visible, style_.grid_minor,
|
draw_class(false, style_.grid_minor_visible, style_.grid_minor,
|
||||||
style_.device(style_.grid_minor_width));
|
style_.device(style_.grid_minor_width), minor_dash);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------- axis chrome
|
// ------------------------------------------------------------- axis chrome
|
||||||
@@ -835,14 +840,18 @@ void Renderer::draw_axis_lines(const Plot& plot) {
|
|||||||
const Axis& axis = plot.x_axis(i);
|
const Axis& axis = plot.x_axis(i);
|
||||||
const f64 y = axis.place == AxisPlace::Top ? y_range.max : y_range.min;
|
const f64 y = axis.place == AxisPlace::Top ? y_range.max : y_range.min;
|
||||||
stage_segment(axis.range.min, axis.range.min, y, axis.range.max, y, axis.log, log_y0);
|
stage_segment(axis.range.min, axis.range.min, y, axis.range.max, y, axis.log, log_y0);
|
||||||
|
// Snapped, like the grid: an axis line is the frame of the plot, and it is the
|
||||||
|
// one line a reader compares every other against, so a smeared one is the worst
|
||||||
|
// place for the half-coverage pair.
|
||||||
flush_lines(mapping(plot, i, 0, axis.range.min), style_.axis_line,
|
flush_lines(mapping(plot, i, 0, axis.range.min), style_.axis_line,
|
||||||
style_.device(style_.line_width_px));
|
style_.device(style_.line_width_px), Vec2{}, true);
|
||||||
}
|
}
|
||||||
for (u32 i = 0; i < plot.y_axis_count(); ++i) {
|
for (u32 i = 0; i < plot.y_axis_count(); ++i) {
|
||||||
const Axis& axis = plot.y_axis(i);
|
const Axis& axis = plot.y_axis(i);
|
||||||
const f64 x = axis.place == AxisPlace::Right ? x_range.max : x_range.min;
|
const f64 x = axis.place == AxisPlace::Right ? x_range.max : x_range.min;
|
||||||
stage_segment(x, x, axis.range.min, x, axis.range.max, log_x0, axis.log);
|
stage_segment(x, x, axis.range.min, x, axis.range.max, log_x0, axis.log);
|
||||||
flush_lines(mapping(plot, 0, i, x), style_.axis_line, style_.device(style_.line_width_px));
|
flush_lines(mapping(plot, 0, i, x), style_.axis_line,
|
||||||
|
style_.device(style_.line_width_px), Vec2{}, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -968,6 +977,304 @@ void Renderer::draw_titles(const Plot& plot, const PlotLayout& layout) {
|
|||||||
|
|
||||||
// ------------------------------------------------------------------- data
|
// ------------------------------------------------------------------- data
|
||||||
|
|
||||||
|
u32 Renderer::stage_trace_columns(const Plot& plot, const PlotLayout& layout, const Trace& trace,
|
||||||
|
f64 x_origin, u32 points) {
|
||||||
|
const Transform xt = plot.x_transform(trace.axis_x);
|
||||||
|
const Transform yt = plot.y_transform(trace.axis_y);
|
||||||
|
const f32 left = layout.axes.x;
|
||||||
|
// One column per whole pixel of the axes, plus the partial one at the right-hand
|
||||||
|
// edge: `axes.w` is fractional, and the samples that land in that last fraction
|
||||||
|
// are inside the frame.
|
||||||
|
const u32 count = static_cast<u32>(layout.axes.w) + 1u;
|
||||||
|
constexpr f32 kInf = std::numeric_limits<f32>::infinity();
|
||||||
|
columns_.resize(2u * static_cast<usize>(count));
|
||||||
|
for (u32 c = 0; c < count; ++c) {
|
||||||
|
columns_[2u * c] = kInf; // the lowest screen y seen in this column
|
||||||
|
columns_[2u * c + 1u] = -kInf; // and the highest; `lo <= hi` is "present"
|
||||||
|
}
|
||||||
|
|
||||||
|
for (u32 k = 0; k < points; ++k) {
|
||||||
|
const f64 x_abs = x_origin + static_cast<f64>(gather_[2u * k]);
|
||||||
|
const f64 sx = xt.to_screen_x(x_abs);
|
||||||
|
const f64 sy = yt.to_screen_y(static_cast<f64>(gather_[2u * k + 1u]));
|
||||||
|
// A NaN sample, and a non-positive one on a log axis, have no position: both
|
||||||
|
// arrive here as a non-finite screen coordinate.
|
||||||
|
if (!std::isfinite(sx) || !std::isfinite(sy)) continue;
|
||||||
|
const f64 column = std::floor(sx - static_cast<f64>(left));
|
||||||
|
// Clamped, not dropped. The gathered window carries one sample of slack on each
|
||||||
|
// side, and that sample's ink belongs to the edge column -- it is what the
|
||||||
|
// polyline drew there, under the same clip. Nothing further out can arrive: the
|
||||||
|
// window is the visible range's.
|
||||||
|
const u32 c = !(column > 0.0) ? 0u
|
||||||
|
: column >= static_cast<f64>(count - 1u) ? count - 1u
|
||||||
|
: static_cast<u32>(column);
|
||||||
|
const f32 y = static_cast<f32>(sy);
|
||||||
|
if (y < columns_[2u * c]) columns_[2u * c] = y;
|
||||||
|
if (y > columns_[2u * c + 1u]) columns_[2u * c + 1u] = y;
|
||||||
|
}
|
||||||
|
|
||||||
|
const f32 width = style_.device(trace.width);
|
||||||
|
u32 staged = 0;
|
||||||
|
f32 prev_lo = 0.0f;
|
||||||
|
f32 prev_hi = 0.0f;
|
||||||
|
bool joined = false; // the column before this one is present, so there is a join to make
|
||||||
|
for (u32 c = 0; c < count; ++c) {
|
||||||
|
f32 lo = columns_[2u * c];
|
||||||
|
f32 hi = columns_[2u * c + 1u];
|
||||||
|
if (!(lo <= hi)) {
|
||||||
|
// No sample landed here. A hole in the data stays a hole: bridging it would
|
||||||
|
// draw a vertical line over x the series says nothing about, where the
|
||||||
|
// polyline drew a diagonal across the gap at most one pixel wide.
|
||||||
|
joined = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Connectivity. Two 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 exactly what the polyline's connecting segment prevented. The
|
||||||
|
// later column is extended to meet the earlier one's nearest bound, so the join
|
||||||
|
// is made inside the column that moved and no ink appears left of it.
|
||||||
|
if (joined) {
|
||||||
|
if (hi < prev_lo) {
|
||||||
|
hi = prev_lo;
|
||||||
|
} else if (lo > prev_hi) {
|
||||||
|
lo = prev_hi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prev_lo = lo;
|
||||||
|
prev_hi = hi;
|
||||||
|
joined = true;
|
||||||
|
// A span shorter than the stroke is drawn *as* the stroke: the 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 column of a flat trace, where `lo == hi`, nothing at all.
|
||||||
|
if (hi - lo < width) {
|
||||||
|
const f32 mid = 0.5f * (lo + hi);
|
||||||
|
lo = mid - 0.5f * width;
|
||||||
|
hi = mid + 0.5f * width;
|
||||||
|
}
|
||||||
|
// The column's centre, in framebuffer pixels. Every segment therefore shares one
|
||||||
|
// sub-pixel phase, which is the whole point (see `draw_traces`), and the flush's
|
||||||
|
// snap puts that one phase on the grid.
|
||||||
|
const f32 x = left + static_cast<f32>(c) + 0.5f;
|
||||||
|
lines_.add_segment({x, lo}, {x, hi});
|
||||||
|
++staged;
|
||||||
|
}
|
||||||
|
return staged;
|
||||||
|
}
|
||||||
|
|
||||||
|
u32 Renderer::stage_trace_density(const Plot& plot, const PlotLayout& layout, const Trace& trace,
|
||||||
|
f64 x_origin, u32 points) {
|
||||||
|
if (points < 2u) return 0u;
|
||||||
|
// At least two steps, or there is nothing to grade and the faintest step is also
|
||||||
|
// the brightest -- a one-step grading is the flat band drawn dimmer, which is not
|
||||||
|
// what any host asking for this wants.
|
||||||
|
const u32 steps = std::max(2u, style_.intensity_steps);
|
||||||
|
// Exactly `steps`, never merely at least: the flush loop reads `steps_.size()` as the
|
||||||
|
// step count and divides by it, so a batch left over from a larger setting would be
|
||||||
|
// flushed at the wrong alpha -- or, being uncleared, at all.
|
||||||
|
steps_.resize(steps);
|
||||||
|
for (u32 t = 0; t < steps; ++t) steps_[t].clear();
|
||||||
|
|
||||||
|
const Transform xt = plot.x_transform(trace.axis_x);
|
||||||
|
const Transform yt = plot.y_transform(trace.axis_y);
|
||||||
|
const f32 left = layout.axes.x;
|
||||||
|
const f32 top = layout.axes.y;
|
||||||
|
const u32 columns = static_cast<u32>(layout.axes.w) + 1u;
|
||||||
|
// One accumulator row per whole pixel of the axes, plus the partial one at the
|
||||||
|
// bottom edge, plus one past the end for a span's closing delta.
|
||||||
|
const u32 rows = static_cast<u32>(layout.axes.h) + 1u;
|
||||||
|
column_rows_.assign(static_cast<usize>(rows) + 1u, 0.0f);
|
||||||
|
|
||||||
|
const f32 width = style_.device(trace.width);
|
||||||
|
const f32 gamma = style_.intensity_gamma > 0.0f ? style_.intensity_gamma : 1.0f;
|
||||||
|
const f32 bottom = top + layout.axes.h;
|
||||||
|
|
||||||
|
u32 staged = 0;
|
||||||
|
u32 column = 0; // the column being accumulated
|
||||||
|
bool open = false; // it has at least one segment
|
||||||
|
u32 prev_column = 0; // the last column that emitted anything, for the join below
|
||||||
|
f32 prev_lo = 0.0f;
|
||||||
|
f32 prev_hi = 0.0f;
|
||||||
|
bool joined = false;
|
||||||
|
|
||||||
|
// Reads one column's accumulator out as graded runs and clears it, so the next column
|
||||||
|
// starts empty. Two walks of it, which is nothing -- it is one column wide and in L1 --
|
||||||
|
// and the first is what makes the second's grading *relative to this column*:
|
||||||
|
//
|
||||||
|
// the brightest row of a column is always the top tier. That is deliberate and it is
|
||||||
|
// the difference between grading and dimming. The alternative, one scale for the whole
|
||||||
|
// frame, needs a pass over every column before any of them can be drawn (an
|
||||||
|
// `axes.w * axes.h` image, 33 MB at 4K) and makes a column the signal flew through
|
||||||
|
// *uniformly* faint -- a trace that fades out where it moves fastest, which is where a
|
||||||
|
// reader is most often looking. Per column, the dwell structure *inside* each one is
|
||||||
|
// what the tiers spend their range on: the rails of a sine are bright and its fast
|
||||||
|
// middle is faint, in every column.
|
||||||
|
//
|
||||||
|
// The runs are maximal stretches of equal tier, so this is O(rows) segments per column
|
||||||
|
// at worst and a handful in practice: a column of a clean waveform is a bright ridge
|
||||||
|
// and a faint tail.
|
||||||
|
const auto emit_column = [&]() {
|
||||||
|
const f32 x = left + static_cast<f32>(column) + 0.5f;
|
||||||
|
f32 peak = 0.0f;
|
||||||
|
{
|
||||||
|
f32 cover = 0.0f;
|
||||||
|
for (u32 r = 0; r < rows; ++r) {
|
||||||
|
cover += column_rows_[r];
|
||||||
|
if (cover > peak) peak = cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const f32 inv = peak > 0.0f ? 1.0f / peak : 0.0f;
|
||||||
|
// What counts as covered at all. The deposits are fractions, so this cannot be an
|
||||||
|
// integer test: it is a share of the column's own peak, small enough to keep a row a
|
||||||
|
// single thin segment grazed and large enough not to light a row on rounding alone.
|
||||||
|
const f32 lit_floor = 0.01f * peak;
|
||||||
|
f32 cover = 0.0f;
|
||||||
|
bool in_run = false;
|
||||||
|
u32 run_tier = 0;
|
||||||
|
u32 run_start = 0;
|
||||||
|
f32 band_lo = 0.0f;
|
||||||
|
f32 band_hi = 0.0f;
|
||||||
|
bool any = false;
|
||||||
|
// One past the last row too: it closes a run that reaches the bottom edge.
|
||||||
|
for (u32 r = 0; r <= rows; ++r) {
|
||||||
|
cover += column_rows_[r];
|
||||||
|
column_rows_[r] = 0.0f;
|
||||||
|
const bool lit = r < rows && cover > lit_floor;
|
||||||
|
u32 tier = 0;
|
||||||
|
if (lit) {
|
||||||
|
const f32 share = std::min(1.0f, cover * inv);
|
||||||
|
const u32 t = static_cast<u32>(std::pow(share, gamma) * static_cast<f32>(steps));
|
||||||
|
tier = std::min(steps - 1u, t);
|
||||||
|
}
|
||||||
|
if (in_run && (!lit || tier != run_tier)) {
|
||||||
|
f32 lo = top + static_cast<f32>(run_start);
|
||||||
|
f32 hi = top + static_cast<f32>(r);
|
||||||
|
// A run shorter than the stroke is drawn *as* the stroke, for the reason
|
||||||
|
// `stage_trace_columns()` gives: the fragment shader ramps on the distance to
|
||||||
|
// the segment's axis and does not cap its ends.
|
||||||
|
if (hi - lo < width) {
|
||||||
|
const f32 mid = 0.5f * (lo + hi);
|
||||||
|
lo = mid - 0.5f * width;
|
||||||
|
hi = mid + 0.5f * width;
|
||||||
|
}
|
||||||
|
steps_[run_tier].add_segment({x, lo}, {x, hi});
|
||||||
|
++staged;
|
||||||
|
in_run = false;
|
||||||
|
}
|
||||||
|
if (lit && !in_run) {
|
||||||
|
run_start = r;
|
||||||
|
run_tier = tier;
|
||||||
|
in_run = true;
|
||||||
|
}
|
||||||
|
if (lit) {
|
||||||
|
if (!any) {
|
||||||
|
band_lo = top + static_cast<f32>(r);
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
band_hi = top + static_cast<f32>(r) + 1.0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
open = false;
|
||||||
|
if (!any) {
|
||||||
|
joined = false; // nothing visible here: a hole, like an uncovered column
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Connectivity, the same rule as the flat path: two columns' bands need not
|
||||||
|
// overlap -- a fast edge rises through a whole column -- and two disjoint
|
||||||
|
// verticals read as a broken trace. The gap is bridged by a segment of its own at
|
||||||
|
// the **faintest** tier rather than by extending a run, because a connector is
|
||||||
|
// inferred geometry and not a place the signal was measured to dwell: grading it
|
||||||
|
// bright would claim the opposite of what it is.
|
||||||
|
if (joined && column == prev_column + 1u) {
|
||||||
|
if (band_hi < prev_lo) {
|
||||||
|
steps_[0].add_segment({x, band_hi}, {x, prev_lo});
|
||||||
|
++staged;
|
||||||
|
} else if (band_lo > prev_hi) {
|
||||||
|
steps_[0].add_segment({x, prev_hi}, {x, band_lo});
|
||||||
|
++staged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prev_lo = band_lo;
|
||||||
|
prev_hi = band_hi;
|
||||||
|
prev_column = column;
|
||||||
|
joined = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
f64 px = 0.0;
|
||||||
|
f64 py = 0.0;
|
||||||
|
bool have_prev = false;
|
||||||
|
for (u32 k = 0; k < points; ++k) {
|
||||||
|
const f64 x_abs = x_origin + static_cast<f64>(gather_[2u * k]);
|
||||||
|
const f64 sx = xt.to_screen_x(x_abs);
|
||||||
|
const f64 sy = yt.to_screen_y(static_cast<f64>(gather_[2u * k + 1u]));
|
||||||
|
// A NaN sample, and a non-positive one on a log axis, have no position -- and it
|
||||||
|
// breaks the chain as well as skipping the point: a segment from the sample
|
||||||
|
// before it to the one after would cross x the series says nothing about.
|
||||||
|
if (!std::isfinite(sx) || !std::isfinite(sy)) {
|
||||||
|
have_prev = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (have_prev) {
|
||||||
|
// The *segment*, not the point, is what carries dwell: it is a stretch of the
|
||||||
|
// waveform, and where it went slowly it covers few rows and where it went fast it
|
||||||
|
// covers many. This is where a height-based heuristic cannot reach -- a column's
|
||||||
|
// min and max cannot tell a ramp from a pulse, and a column's segments can (see
|
||||||
|
// `Style::intensity_grading`).
|
||||||
|
//
|
||||||
|
// It is charged to the column its earlier end falls in. The gather is asked at
|
||||||
|
// `intensity_sub_columns` buckets per column, so a segment is a fraction of a
|
||||||
|
// column wide and splitting one across a boundary would buy a fraction of one
|
||||||
|
// sub-bucket's dwell.
|
||||||
|
const f64 c = std::floor(px - static_cast<f64>(left));
|
||||||
|
// Clamped, not dropped, for the reason `stage_trace_columns()` gives: the
|
||||||
|
// gathered window carries one sample of slack on each side and that sample's
|
||||||
|
// ink belongs to the edge column.
|
||||||
|
const u32 ci = !(c > 0.0) ? 0u
|
||||||
|
: c >= static_cast<f64>(columns - 1u) ? columns - 1u
|
||||||
|
: static_cast<u32>(c);
|
||||||
|
if (open && ci != column) emit_column(); // x is increasing, so ci > column
|
||||||
|
if (!open) {
|
||||||
|
column = ci;
|
||||||
|
open = true;
|
||||||
|
}
|
||||||
|
const f64 lo = std::min(py, sy);
|
||||||
|
const f64 hi = std::max(py, sy);
|
||||||
|
// Wholly outside the axes is wholly clipped, and a clamp would move its ink to
|
||||||
|
// the edge and invent a ridge there. A segment that *crosses* the edge is
|
||||||
|
// clamped instead, which is the coverage the clip leaves -- so a column's shares
|
||||||
|
// are shares of what is visible in it, which is what is being graded.
|
||||||
|
if (hi >= static_cast<f64>(top) && lo <= static_cast<f64>(bottom)) {
|
||||||
|
const f64 ra = std::floor(lo - static_cast<f64>(top));
|
||||||
|
const f64 rb = std::floor(hi - static_cast<f64>(top));
|
||||||
|
const u32 r0 = !(ra > 0.0) ? 0u
|
||||||
|
: ra >= static_cast<f64>(rows - 1u) ? rows - 1u
|
||||||
|
: static_cast<u32>(ra);
|
||||||
|
const u32 r1 = !(rb > 0.0) ? 0u
|
||||||
|
: rb >= static_cast<f64>(rows - 1u) ? rows - 1u
|
||||||
|
: static_cast<u32>(rb);
|
||||||
|
// A segment deposits one unit of dwell, **spread over the rows it covers** --
|
||||||
|
// which is the whole measurement, and counting the rows instead was wrong in a
|
||||||
|
// way worth recording: a sub-bucket holds about as many samples as any other, so
|
||||||
|
// a segment crossing a hundred rows is those samples *passing through* each of
|
||||||
|
// them, while one covering two rows is the same samples *sitting* in them. Giving
|
||||||
|
// each crossed row a full unit makes a sine's fast middle as bright as its rails,
|
||||||
|
// which is the pattern grading exists to show.
|
||||||
|
//
|
||||||
|
// Two writes whatever the height: the running sum over the column is then the
|
||||||
|
// dwell at each row.
|
||||||
|
const f32 w = 1.0f / static_cast<f32>(r1 - r0 + 1u);
|
||||||
|
column_rows_[r0] += w;
|
||||||
|
column_rows_[r1 + 1u] -= w;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
px = sx;
|
||||||
|
py = sy;
|
||||||
|
have_prev = true;
|
||||||
|
}
|
||||||
|
if (open) emit_column();
|
||||||
|
return staged;
|
||||||
|
}
|
||||||
|
|
||||||
void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
|
void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
|
||||||
// Both dimensions: a zero-width rect selects no level, and a zero-height one
|
// Both dimensions: a zero-width rect selects no level, and a zero-height one
|
||||||
// would flatten every trace onto a single row -- a frame that looks like a
|
// would flatten every trace onto a single row -- a frame that looks like a
|
||||||
@@ -984,43 +1291,136 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
|
|||||||
const u64 n = series.size();
|
const u64 n = series.size();
|
||||||
if (!(dx > 0.0) || n == 0) continue;
|
if (!(dx > 0.0) || n == 0) continue;
|
||||||
|
|
||||||
// Map the visible x range onto sample indices. One sample of slack on each
|
// The visible x range onto sample indices, through the series' *own* x.
|
||||||
// side covers a partially visible end bucket; the range is clamped to the
|
//
|
||||||
// series, so a zoomed-out view never asks for more than exists.
|
// Not the `(x - x0()) / dx()` grid: `dx()` is documented as a nominal spacing (the LOD
|
||||||
|
// level, a coarse snap) and for a series whose x is a streamed timestamp it is only the
|
||||||
|
// mean of the retained window. Walking that grid lands the window inside the visible range
|
||||||
|
// -- and the first and last *segments*, the ones that enter and leave the frame, are then
|
||||||
|
// not drawn at all, which is the empty margin at both edges of a zoomed-in view. A series
|
||||||
|
// with a uniform x answers the same question through the same call, so this is the one
|
||||||
|
// path. One sample of slack on each side keeps a partially visible end bucket whole, and
|
||||||
|
// the result is clamped to the series: a zoomed-out view never asks for more than exists,
|
||||||
|
// and a window that reaches past the data leaves the margin genuinely -- correctly -- empty.
|
||||||
const Range x_range = plot.x_range(trace.axis_x);
|
const Range x_range = plot.x_range(trace.axis_x);
|
||||||
f64 lo = (x_range.min - series.x0()) / dx;
|
// `dx` is still read above for the samples-per-pixel estimate -- a density, where a nominal
|
||||||
f64 hi = (x_range.max - series.x0()) / dx;
|
// spacing is exactly right -- and never for the window itself.
|
||||||
if (hi < lo) std::swap(lo, hi);
|
u64 first = 0;
|
||||||
const u64 first = clamp_index(std::floor(lo) - 1.0, n);
|
u64 last = n;
|
||||||
const u64 last = clamp_index(std::ceil(hi) + 1.0, n);
|
u64 i0 = 0;
|
||||||
|
u64 i1 = 0;
|
||||||
|
if (series.neighbours(x_range.min, i0, i1)) first = i0 > 0 ? i0 - 1 : 0;
|
||||||
|
if (series.neighbours(x_range.max, i0, i1)) last = std::min(n, i1 + 1);
|
||||||
if (last <= first) continue;
|
if (last <= first) continue;
|
||||||
|
|
||||||
// This is the whole performance contract: the window, never the series.
|
// This is the whole performance contract: the window, never the series.
|
||||||
const u64 visible = last - first;
|
const u64 visible = last - first;
|
||||||
const f64 samples_per_pixel = static_cast<f64>(visible) / static_cast<f64>(layout.axes.w);
|
const f64 samples_per_pixel = static_cast<f64>(visible) / static_cast<f64>(layout.axes.w);
|
||||||
const u32 level = samples_per_pixel >= static_cast<f64>(style_.dense_threshold_spp)
|
// Grading reads a column's *distribution*, which one envelope per column cannot
|
||||||
? series.pyramid().level_for(n, samples_per_pixel)
|
// carry (`Style::intensity_grading`), so the gather is asked for the level a
|
||||||
: 0u;
|
// fraction of a column implies. The *gate* is still the pixel grid's density: a
|
||||||
|
// window sparse enough to be drawn as a polyline is drawn as one, graded or not.
|
||||||
|
const bool grade = style_.intensity_grading && trace.mode == data::GatherMode::Envelope &&
|
||||||
|
style_.intensity_sub_columns > 1u && style_.intensity_steps > 1u;
|
||||||
|
const u32 flat_level = samples_per_pixel >= static_cast<f64>(style_.dense_threshold_spp)
|
||||||
|
? series.pyramid().level_for(n, samples_per_pixel)
|
||||||
|
: 0u;
|
||||||
|
// The finer level, floored at the pyramid's coarsest bucket level. Grading asks for a
|
||||||
|
// fraction of a column and `level_for` answers 0 -- raw samples -- as soon as that
|
||||||
|
// fraction falls under one bucket, so an unfloored ask turns every moderately dense
|
||||||
|
// frame into an O(visible) gather: measured at 1.3 MS/s over a 2 s window, 172 samples
|
||||||
|
// a pixel, where the flat path sits 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 a frame.
|
||||||
|
// The floor degrades the *look* instead: fewer sub-columns than asked for, down to
|
||||||
|
// whatever the coarsest buckets give, which at 64 samples a bucket is still two or three
|
||||||
|
// per column. Grading is a way of drawing a reduction and may not undo it.
|
||||||
|
const u32 level =
|
||||||
|
grade && flat_level > 0u
|
||||||
|
? std::max(1u, series.pyramid().level_for(
|
||||||
|
n, samples_per_pixel /
|
||||||
|
static_cast<f64>(style_.intensity_sub_columns)))
|
||||||
|
: flat_level;
|
||||||
|
|
||||||
gather_.clear();
|
gather_.clear();
|
||||||
series.gather(level, first, last, gather_, trace.mode);
|
// The visible range's minimum is the gather origin: the emitted f32 is then O(the span on
|
||||||
|
// screen), so its ULP is a fixed 2^-24 of the axis at every zoom. `series.x0()` -- the origin
|
||||||
|
// before -- is the start of the *retained window*, so the quantisation was a fraction of data
|
||||||
|
// the view need not even hold, and a deep zoom drew a sample visibly off the x a readout
|
||||||
|
// quotes for it. See `Series::gather()`.
|
||||||
|
series.gather(level, first, last, x_range.min, gather_, trace.mode);
|
||||||
if (gather_.size() < 4u) continue; // fewer than two points is no segment
|
if (gather_.size() < 4u) continue; // fewer than two points is no segment
|
||||||
|
|
||||||
stats_.visible_samples += visible;
|
stats_.visible_samples += visible;
|
||||||
|
// Markers for a window sparse enough that its samples are the shape: one dot per raw sample,
|
||||||
|
// in the trace's own colour. Level 0 only -- above it the geometry is *buckets*, which are not
|
||||||
|
// points a reader can find. Drawn after the polyline they sit on, so a dot is never half
|
||||||
|
// covered by its own line, and inside the axes only (a marker is a place, and a place outside
|
||||||
|
// the frame is not one).
|
||||||
|
if (level == 0u && style_.marker_below_samples > 0.0f && visible > 0u &&
|
||||||
|
visible <= static_cast<u64>(style_.marker_below_samples)) {
|
||||||
|
const auto& xt = plot.x_transform(trace.axis_x);
|
||||||
|
const auto& yt = plot.y_transform(trace.axis_y);
|
||||||
|
const f32 half = 0.5f * style_.device(style_.marker_px);
|
||||||
|
for (u32 k = 0; k + 1 < gather_.size(); k += 2) {
|
||||||
|
const f64 x_abs = x_range.min + static_cast<f64>(gather_[k]);
|
||||||
|
const f64 y_abs = static_cast<f64>(gather_[k + 1]);
|
||||||
|
if (!std::isfinite(x_abs) || !std::isfinite(y_abs)) continue;
|
||||||
|
const f32 sx = static_cast<f32>(xt.to_screen_x(x_abs));
|
||||||
|
const f32 sy = static_cast<f32>(yt.to_screen_y(y_abs));
|
||||||
|
if (!(sx >= layout.axes.x && sx <= layout.axes.right() && sy >= layout.axes.y &&
|
||||||
|
sy <= layout.axes.bottom())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
fill_pixels(Rect{sx - half, sy - half, 2.0f * half, 2.0f * half}, trace.color);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (level == 0u) {
|
if (level == 0u) {
|
||||||
++stats_.raw_traces;
|
++stats_.raw_traces;
|
||||||
} else {
|
} else {
|
||||||
++stats_.lod_traces;
|
++stats_.lod_traces;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gathered x is relative to `series.x0()` (the gather contract); the staging
|
// Gathered x is relative to `x_range.min` (the gather contract, with the origin this
|
||||||
// reconstructs the absolute data x in f64 and emits `to_space(x_abs) -
|
// function asked for); the staging reconstructs the absolute data x in f64 and emits
|
||||||
// to_space(origin)` -- the visible x range's minimum is the natural origin
|
// `to_space(x_abs) - to_space(origin)` about that same origin, so the f32 is O(visible
|
||||||
// for a trace -- so the f32 is O(visible pixels), not O(epoch) (B1). The
|
// pixels), not O(epoch) (B1). The mapping folds the origin back into the offset uniform,
|
||||||
// mapping folds the origin back into the offset uniform, in f64.
|
// in f64.
|
||||||
const bool log_x = plot.x_axis(trace.axis_x).log;
|
const bool log_x = plot.x_axis(trace.axis_x).log;
|
||||||
const bool log_y = plot.y_axis(trace.axis_y).log;
|
const bool log_y = plot.y_axis(trace.axis_y).log;
|
||||||
const Mapping map = mapping(plot, trace.axis_x, trace.axis_y, x_range.min);
|
const Mapping map = mapping(plot, trace.axis_x, trace.axis_y, x_range.min);
|
||||||
|
// Keep the longest **increasing suffix** of the gathered points, and drop what precedes it.
|
||||||
|
//
|
||||||
|
// A ring-backed series may answer a *clamped* stamp for the oldest slots of its window -- the ones
|
||||||
|
// the producer has already overwritten. The clamp is deliberate (the alternative was a sentinel
|
||||||
|
// that collapsed the whole index mapping), but it makes the stamps *non-increasing at the front*:
|
||||||
|
// the first few points carry the newest stamp, and everything after them is older. A polyline
|
||||||
|
// through that draws a segment from the newest sample back to the oldest -- a line across the
|
||||||
|
// whole panel, which is what a saturated ring shows as a flicker.
|
||||||
|
//
|
||||||
|
// A forward filter cannot express this: the bad points are at the *front* and everything behind
|
||||||
|
// them is legitimately smaller, so dropping "anything that does not advance" would delete the
|
||||||
|
// trace. Walking backwards keeps each point that is strictly older than the one kept after it,
|
||||||
|
// which stops at the first unreadable slot and leaves the real samples alone. The cost is one
|
||||||
|
// pass over the visible points, which the staging below is about to walk anyway.
|
||||||
|
{
|
||||||
|
const usize count = gather_.size() / 2u;
|
||||||
|
usize kept = 0;
|
||||||
|
f64 next = std::numeric_limits<f64>::infinity();
|
||||||
|
for (usize k = count; k > 0; --k) {
|
||||||
|
const usize p = k - 1u; // the point index; `i` above is the trace's
|
||||||
|
const f64 x = static_cast<f64>(gather_[2u * p]) + x_range.min;
|
||||||
|
if (!(x < next)) continue; // out of order with what follows: unreadable, and a NaN too
|
||||||
|
next = x;
|
||||||
|
gather_[2u * (count - 1u - kept)] = gather_[2u * p];
|
||||||
|
gather_[2u * (count - 1u - kept) + 1u] = gather_[2u * p + 1u];
|
||||||
|
++kept;
|
||||||
|
}
|
||||||
|
if (kept != count) {
|
||||||
|
// The kept points were written back-to-front into the tail; move them down to the front.
|
||||||
|
std::memmove(gather_.data(), gather_.data() + 2u * (count - kept), 2u * kept * sizeof(f32));
|
||||||
|
gather_.resize(2u * kept);
|
||||||
|
}
|
||||||
|
}
|
||||||
const u32 points = static_cast<u32>(gather_.size() / 2u);
|
const u32 points = static_cast<u32>(gather_.size() / 2u);
|
||||||
|
|
||||||
// A trace's dash rides on the same per-draw uniform the crosshair uses, in
|
// A trace's dash rides on the same per-draw uniform the crosshair uses, in
|
||||||
@@ -1035,8 +1435,72 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
|
|||||||
// trace/axis hierarchy on a high-DPI panel. `width == 0` is scatter: the
|
// trace/axis hierarchy on a high-DPI panel. `width == 0` is scatter: the
|
||||||
// markers below are the whole trace.
|
// markers below are the whole trace.
|
||||||
if (trace.width > 0.0f) {
|
if (trace.width > 0.0f) {
|
||||||
stage_xy(gather_.data(), points, series.x0(), x_range.min, log_x, log_y);
|
// A window denser than two gathered points per pixel column is drawn as *columns*
|
||||||
flush_lines(map, trace.color, style_.device(trace.width), dash);
|
// rather than as a polyline: one exactly vertical, pixel-snapped segment per column,
|
||||||
|
// spanning what that column's own points reach.
|
||||||
|
//
|
||||||
|
// The polyline's geometry at this density is a stack of near-vertical hairlines at
|
||||||
|
// *fractional* x -- an envelope bucket spans (spp/2, spp] samples, so there are one to
|
||||||
|
// two buckets per column, each emitting its own min->max hairline plus a connector to
|
||||||
|
// the next, about three and a half per column. Each is composited with its own
|
||||||
|
// sub-pixel coverage, so a column's brightness is a non-linear function of those
|
||||||
|
// phases; the phases repeat at the beat between the bucket pitch and the pixel pitch,
|
||||||
|
// which is a **moire** across the trace, and panning sweeps the beat so the pattern
|
||||||
|
// crawls. It is strongest exactly where a scope is used hardest: a high sample rate, a
|
||||||
|
// high signal frequency, or a noisy signal, all of which make the ribbon thin enough
|
||||||
|
// for its per-column coverage to be visible as texture.
|
||||||
|
//
|
||||||
|
// Reducing to one segment per column removes the cause rather than the symptom: there
|
||||||
|
// is one phase on the whole trace (and the snap puts it on the grid), so there is
|
||||||
|
// nothing left to beat. It is also O(axes.w) geometry instead of O(buckets), and it
|
||||||
|
// loses nothing a reader could have seen -- a column cannot show more than the extremes
|
||||||
|
// of what it covers, which is what the band is.
|
||||||
|
//
|
||||||
|
// Two conditions, and each refuses a case this would damage.
|
||||||
|
// - `Envelope` only. `Nearest` and `Mean` are one-point-per-bucket modes chosen so the
|
||||||
|
// trace reads as *samples* or as a smoothed line; a min/max band would answer a
|
||||||
|
// different question from the one the host asked.
|
||||||
|
// - Two points per column. Below that the gather is sparser than the grid, and the
|
||||||
|
// polyline's segments are the shape: `Series::gather()` also falls back to raw samples
|
||||||
|
// when no complete bucket fits the range, and that window's trace is drawn by the two
|
||||||
|
// segments that enter and leave the frame (see
|
||||||
|
// `a_sparse_window_between_samples_still_inks_both_margins`). The real envelope path
|
||||||
|
// emits two to four points per column by construction, so the test holds exactly when
|
||||||
|
// it ran.
|
||||||
|
const bool columns = trace.mode == data::GatherMode::Envelope &&
|
||||||
|
static_cast<f64>(points) >= 2.0 * static_cast<f64>(layout.axes.w);
|
||||||
|
// `snap = true` on geometry that is not chrome, which is the one carve-out in that
|
||||||
|
// uniform's rule ("a line that reports a data position may not be moved off it"): a
|
||||||
|
// column *is* a pixel column, and its x is already a screen coordinate derived from
|
||||||
|
// one -- there is no data position to move it off. The y bounds, which are what the
|
||||||
|
// segment reports, are untouched by the snap. It holds for both staging paths.
|
||||||
|
const u32 staged =
|
||||||
|
!columns ? 0u
|
||||||
|
: grade ? stage_trace_density(plot, layout, trace, x_range.min, points)
|
||||||
|
: stage_trace_columns(plot, layout, trace, x_range.min, points);
|
||||||
|
if (staged != 0u && grade) {
|
||||||
|
// One draw call per alpha step, faintest first: the line pipeline's colour is a
|
||||||
|
// uniform, so a per-segment alpha is not expressible and a step is a batch. They
|
||||||
|
// are flushed in order so the bright ridge composites over the faint tail rather
|
||||||
|
// than under it -- the runs within a column do not overlap, but their AA fringes
|
||||||
|
// do, and that is the order a phosphor display puts them in.
|
||||||
|
for (u32 t = 0; t < steps_.size(); ++t) {
|
||||||
|
if (steps_[t].segment_count() == 0u) continue;
|
||||||
|
Color color = trace.color;
|
||||||
|
color.a *= style_.intensity_floor +
|
||||||
|
(1.0f - style_.intensity_floor) * static_cast<f32>(t) /
|
||||||
|
static_cast<f32>(steps_.size() - 1u);
|
||||||
|
flush_lines(steps_[t], pixel_mapping(), color, style_.device(trace.width), dash, true);
|
||||||
|
}
|
||||||
|
++stats_.column_traces;
|
||||||
|
++stats_.graded_traces;
|
||||||
|
} else if (staged != 0u) {
|
||||||
|
flush_lines(pixel_mapping(), trace.color, style_.device(trace.width), dash, true);
|
||||||
|
++stats_.column_traces;
|
||||||
|
} else {
|
||||||
|
stage_xy(gather_.data(), points, x_range.min, log_x, log_y);
|
||||||
|
flush_lines(map, trace.color, style_.device(trace.width), dash);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Markers, staged as line segments through this same batch in *pixel* space
|
// Markers, staged as line segments through this same batch in *pixel* space
|
||||||
@@ -1053,7 +1517,7 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
|
|||||||
const f64 origin_s = space(x_range.min, log_x);
|
const f64 origin_s = space(x_range.min, log_x);
|
||||||
u32 staged = 0;
|
u32 staged = 0;
|
||||||
for (u32 p = 0; p < points; p += stride) {
|
for (u32 p = 0; p < points; p += stride) {
|
||||||
const f64 x_abs = static_cast<f64>(gather_[2u * p]) + series.x0();
|
const f64 x_abs = static_cast<f64>(gather_[2u * p]) + x_range.min;
|
||||||
const f32 rx = static_cast<f32>(space(x_abs, log_x) - origin_s);
|
const f32 rx = static_cast<f32>(space(x_abs, log_x) - origin_s);
|
||||||
const f32 ry = static_cast<f32>(space(gather_[2u * p + 1u], log_y));
|
const f32 ry = static_cast<f32>(space(gather_[2u * p + 1u], log_y));
|
||||||
const Vec2 centre{rx * map.scale.x + map.offset.x, ry * map.scale.y + map.offset.y};
|
const Vec2 centre{rx * map.scale.x + map.offset.x, ry * map.scale.y + map.offset.y};
|
||||||
@@ -1096,7 +1560,12 @@ void Renderer::draw_cursors(const Plot& plot, const PlotLayout& layout) {
|
|||||||
// belongs to the plot, and `to_screen_y()` of a range maximum can undershoot `axes.y` by
|
// belongs to the plot, and `to_screen_y()` of a range maximum can undershoot `axes.y` by
|
||||||
// a floating-point ulp, which a bare `<` would mistake for "outside". Only the ones
|
// a floating-point ulp, which a bare `<` would mistake for "outside". Only the ones
|
||||||
// beyond that margin are skipped.
|
// beyond that margin are skipped.
|
||||||
const f32 half = 0.5f * style_.device(on ? style_.handle_width_px : style_.line_width_px);
|
// The cursor's own width when it has one, else the style's; a hover or a grab can only make
|
||||||
|
// it thicker (the cue that the pointer has hold of something).
|
||||||
|
const f32 own = cursor.width > 0.0f ? style_.device(cursor.width)
|
||||||
|
: style_.device(style_.line_width_px);
|
||||||
|
const f32 width = on ? std::max(own, style_.device(style_.handle_width_px)) : own;
|
||||||
|
const f32 half = 0.5f * width;
|
||||||
if (horizontal ? (at < layout.axes.y - half || at > layout.axes.bottom() + half)
|
if (horizontal ? (at < layout.axes.y - half || at > layout.axes.bottom() + half)
|
||||||
: (at < layout.axes.x - half || at > layout.axes.right() + half)) {
|
: (at < layout.axes.x - half || at > layout.axes.right() + half)) {
|
||||||
continue;
|
continue;
|
||||||
@@ -1120,15 +1589,13 @@ void Renderer::draw_cursors(const Plot& plot, const PlotLayout& layout) {
|
|||||||
stage_segment(x_range.min, x_range.min, y_drawn, x_range.max, y_drawn, log_x,
|
stage_segment(x_range.min, x_range.min, y_drawn, x_range.max, y_drawn, log_x,
|
||||||
plot.y_axis(cursor.axis_y).log);
|
plot.y_axis(cursor.axis_y).log);
|
||||||
flush_lines(mapping(plot, 0, cursor.axis_y, x_range.min),
|
flush_lines(mapping(plot, 0, cursor.axis_y, x_range.min),
|
||||||
on ? style_.cursor : cursor.color,
|
on ? style_.cursor : cursor.color, width, cursor.dash);
|
||||||
style_.device(on ? style_.handle_width_px : style_.line_width_px));
|
|
||||||
} else {
|
} else {
|
||||||
const f64 x_drawn = plot.x_transform(cursor.axis_x).to_data_x(static_cast<f64>(drawn));
|
const f64 x_drawn = plot.x_transform(cursor.axis_x).to_data_x(static_cast<f64>(drawn));
|
||||||
stage_segment(x_drawn, x_drawn, y_range.min, x_drawn, y_range.max,
|
stage_segment(x_drawn, x_drawn, y_range.min, x_drawn, y_range.max,
|
||||||
plot.x_axis(cursor.axis_x).log, log_y);
|
plot.x_axis(cursor.axis_x).log, log_y);
|
||||||
flush_lines(mapping(plot, cursor.axis_x, 0, x_drawn),
|
flush_lines(mapping(plot, cursor.axis_x, 0, x_drawn),
|
||||||
on ? style_.cursor : cursor.color,
|
on ? style_.cursor : cursor.color, width, cursor.dash);
|
||||||
style_.device(on ? style_.handle_width_px : style_.line_width_px));
|
|
||||||
}
|
}
|
||||||
draw_cursor_label(plot, layout, cursor, drawn);
|
draw_cursor_label(plot, layout, cursor, drawn);
|
||||||
}
|
}
|
||||||
@@ -1322,6 +1789,28 @@ void Renderer::draw_legend(const Plot& plot, const PlotLayout& layout) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Renderer::draw_x_note(const Plot& plot, const PlotLayout& layout) {
|
||||||
|
const std::string& text = plot.x_note();
|
||||||
|
if (text.empty()) return;
|
||||||
|
const Rect& axes = layout.axes;
|
||||||
|
const f32 line_h = font_backend_.height();
|
||||||
|
const f32 width = font_backend_.measure(text.c_str());
|
||||||
|
if (!(width > 0.0f) || !(line_h > 0.0f)) return;
|
||||||
|
// The pad is the legend's, so the two panels read as the same kind of chrome.
|
||||||
|
const f32 pad = style_.device(4.0f);
|
||||||
|
const Rect panel{axes.x + pad, axes.bottom() - pad - line_h - 2.0f * pad, width + 2.0f * pad,
|
||||||
|
line_h + 2.0f * pad};
|
||||||
|
// A sliver of axes gets no caption rather than one hanging over the frame: the panel is chrome
|
||||||
|
// *inside* the axes, and a backplate crossing the axis line would read as a second plot.
|
||||||
|
if (panel.w > axes.w || panel.h > axes.h) return;
|
||||||
|
draw_readout_panel(panel, style_.axis_line);
|
||||||
|
// Bottom-left, beside the labels it qualifies -- the reader adds the caption to a tick, so it
|
||||||
|
// belongs at the end of the tick row and not in a corner of its own. `glyphs_under_` because
|
||||||
|
// the panel is flushed with the frame's line geometry and the under-text batch goes out after
|
||||||
|
// it: text staged into the over batch would be drawn before its own backplate.
|
||||||
|
stage_text(glyphs_under_, {panel.x + pad, panel.y + pad}, text.c_str(), style_.axis_text);
|
||||||
|
}
|
||||||
|
|
||||||
void Renderer::stage_marker(plot::Marker marker, Vec2 centre_px, f32 size_px) {
|
void Renderer::stage_marker(plot::Marker marker, Vec2 centre_px, f32 size_px) {
|
||||||
// Marker shapes as line segments, in viewport pixels: the trace's flush maps
|
// Marker shapes as line segments, in viewport pixels: the trace's flush maps
|
||||||
// them with `pixel_mapping()`, so the size is device pixels whatever the data
|
// them with `pixel_mapping()`, so the size is device pixels whatever the data
|
||||||
@@ -1441,19 +1930,28 @@ void Renderer::draw_crosshair_lines(const Plot& plot, const PlotLayout& layout)
|
|||||||
flush_lines(data, style_.crosshair, stroke, crosshair.dash);
|
flush_lines(data, style_.crosshair, stroke, crosshair.dash);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A small cross at each trace's intersection, in the trace's own colour -- opaque, like a
|
// A cross at each trace's readout point, in the trace's own colour -- opaque, like a legend's
|
||||||
// legend's swatch, so a trace drawn at 15% still has a marker that can be seen. The arm
|
// swatch, so a trace drawn at 15% still has a mark that can be seen. The position is the
|
||||||
// length is a logical size like every other marker's, and the position is the layout's: a
|
// layout's, and in the default `CrosshairValue::NearestSample` that is the *quoted sample*
|
||||||
// marker for a sample far outside the visible y range is staged where the trace is and the
|
// rather than the pointer's column: a mark for a sample outside the visible range is staged
|
||||||
// axes clip keeps it inside, rather than being skipped by a range test of this layer's own.
|
// where that sample is and the axes clip keeps it inside, rather than being skipped by a range
|
||||||
const f32 arm = style_.device(3.0f);
|
// test of this layer's own.
|
||||||
for (const CrosshairRow& row : crosshair.rows) {
|
//
|
||||||
if (!row.valid) continue;
|
// `crosshair_marker_px` is the cross' full extent and is larger than `marker_px`, which dots a
|
||||||
Color color = plot.trace(row.trace).color;
|
// trace's own samples at a deep zoom -- a snapped mark lands among those dots and has to read
|
||||||
color.a = 1.0f;
|
// as the one the panel is quoting. `0` across draws nothing, so a caller can have the panel
|
||||||
lines_.add_segment({row.marker.x - arm, row.marker.y}, {row.marker.x + arm, row.marker.y});
|
// and the lines without the marks.
|
||||||
lines_.add_segment({row.marker.x, row.marker.y - arm}, {row.marker.x, row.marker.y + arm});
|
const f32 arm = 0.5f * style_.device(style_.crosshair_marker_px);
|
||||||
flush_lines(pixel_mapping(), color, style_.device(1.0f));
|
const f32 mark_stroke = style_.device(style_.crosshair_marker_width);
|
||||||
|
if (arm > 0.0f) {
|
||||||
|
for (const CrosshairRow& row : crosshair.rows) {
|
||||||
|
if (!row.valid) continue;
|
||||||
|
Color color = plot.trace(row.trace).color;
|
||||||
|
color.a = 1.0f;
|
||||||
|
lines_.add_segment({row.marker.x - arm, row.marker.y}, {row.marker.x + arm, row.marker.y});
|
||||||
|
lines_.add_segment({row.marker.x, row.marker.y - arm}, {row.marker.x, row.marker.y + arm});
|
||||||
|
flush_lines(pixel_mapping(), color, mark_stroke);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+89
-7
@@ -20,6 +20,7 @@ uniform vec2 u_offset; // pixels
|
|||||||
uniform vec2 u_viewport; // render target size, pixels
|
uniform vec2 u_viewport; // render target size, pixels
|
||||||
uniform float u_width; // stroke width, pixels
|
uniform float u_width; // stroke width, pixels
|
||||||
uniform float u_aa; // AA fringe half-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
|
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
|
// Distance travelled from a_p0, pixels -- and the dash's phase anchor. Both of the
|
||||||
@@ -32,6 +33,40 @@ out float v_along;
|
|||||||
void main() {
|
void main() {
|
||||||
vec2 p0 = a_p0 * u_scale + u_offset;
|
vec2 p0 = a_p0 * u_scale + u_offset;
|
||||||
vec2 p1 = a_p1 * 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;
|
vec2 d = p1 - p0;
|
||||||
float len = length(d);
|
float len = length(d);
|
||||||
float half_w = u_width * 0.5 + u_aa; // + the AA fringe
|
float half_w = u_width * 0.5 + u_aa; // + the AA fringe
|
||||||
@@ -112,6 +147,10 @@ uniform vec2 u_glyph_uv; // glyph cell in the atlas, normalised (width, height)
|
|||||||
|
|
||||||
out vec2 v_uv; // normalised atlas coordinate of this fragment
|
out vec2 v_uv; // normalised atlas coordinate of this fragment
|
||||||
out vec4 v_colour; // the instance's tint
|
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() {
|
void main() {
|
||||||
// The cell's two axes: the run direction the instance carries and its
|
// The cell's two axes: the run direction the instance carries and its
|
||||||
@@ -131,24 +170,67 @@ void main() {
|
|||||||
// way round: u runs along the basis, v down the glyph.
|
// way round: u runs along the basis, v down the glyph.
|
||||||
v_uv = a_uv + corner * u_glyph_uv;
|
v_uv = a_uv + corner * u_glyph_uv;
|
||||||
v_colour = a_colour;
|
v_colour = a_colour;
|
||||||
|
v_cell0 = a_uv;
|
||||||
}
|
}
|
||||||
)";
|
)";
|
||||||
|
|
||||||
const char* const kTextFS = R"(#version 330 core
|
const char* const kTextFS = R"(#version 330 core
|
||||||
in vec2 v_uv; // normalised atlas coordinate of this fragment
|
in vec2 v_uv; // normalised atlas coordinate of this fragment
|
||||||
in vec4 v_colour; // the instance's tint
|
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 sampler2D u_atlas; // GL_R8 glyph atlas, GL_NEAREST
|
||||||
uniform vec2 u_tex_size; // atlas size in texels
|
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;
|
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() {
|
void main() {
|
||||||
// Sample the centre of the texel the fragment lands in. GL_NEAREST alone
|
// Bilinear over the four texels the fragment sits between, **clamped to this
|
||||||
// would tie at a texel boundary, where the driver may pick the neighbouring
|
// glyph's own cell**.
|
||||||
// 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));
|
// It used to be a single point sample of the texel the fragment landed in, and
|
||||||
float coverage = texture(u_atlas, (texel + vec2(0.5)) / u_tex_size).r;
|
// 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);
|
o_color = vec4(v_colour.rgb, v_colour.a * coverage);
|
||||||
}
|
}
|
||||||
)";
|
)";
|
||||||
|
|||||||
@@ -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
|
// no second create -- and it keeps the filter, which is why the filter is set
|
||||||
// on every upload rather than once at creation.
|
// on every upload rather than once at creation.
|
||||||
texture_.upload_r8(width, height, pixels);
|
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);
|
texture_.set_filter(gl::GL_NEAREST, gl::GL_NEAREST);
|
||||||
}
|
}
|
||||||
gl::glPixelStorei(gl::GL_UNPACK_ALIGNMENT, host_alignment);
|
gl::glPixelStorei(gl::GL_UNPACK_ALIGNMENT, host_alignment);
|
||||||
|
|||||||
@@ -263,6 +263,27 @@ PLOT_TEST(key_minus_zooms_out) {
|
|||||||
PLOT_CHECK(f.plot.x_range(f.ax).span() > 100.0);
|
PLOT_CHECK(f.plot.x_range(f.ax).span() > 100.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(ctrl_plus_zooms_only_x) {
|
||||||
|
Fixture f;
|
||||||
|
f.ay = f.plot.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||||
|
const f64 x_before = f.plot.x_range(f.ax).span();
|
||||||
|
const f64 y_before = f.plot.y_range(f.ay).span();
|
||||||
|
f.controller.on_key({plot::input::kKeyZoomIn, kCtrl, true});
|
||||||
|
PLOT_CHECK(f.plot.x_range(f.ax).span() < x_before);
|
||||||
|
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).span(), y_before, 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(alt_plus_zooms_only_y) {
|
||||||
|
Fixture f;
|
||||||
|
f.ay = f.plot.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||||
|
constexpr plot::u32 kAlt = static_cast<plot::u32>(plot::input::Mod::Alt);
|
||||||
|
const f64 x_before = f.plot.x_range(f.ax).span();
|
||||||
|
const f64 y_before = f.plot.y_range(f.ay).span();
|
||||||
|
f.controller.on_key({plot::input::kKeyZoomIn, kAlt, true});
|
||||||
|
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), x_before, 1e-12);
|
||||||
|
PLOT_CHECK(f.plot.y_range(f.ay).span() < y_before);
|
||||||
|
}
|
||||||
|
|
||||||
/// A host can hand the controller a coordinate that is not a number: ImGui reports
|
/// A host can hand the controller a coordinate that is not a number: ImGui reports
|
||||||
/// (-FLT_MAX, -FLT_MAX) for "no mouse position", and any host can produce a NaN out
|
/// (-FLT_MAX, -FLT_MAX) for "no mouse position", and any host can produce a NaN out
|
||||||
/// of a degenerate mapping of its own. The pan arithmetic runs through the axis
|
/// of a degenerate mapping of its own. The pan arithmetic runs through the axis
|
||||||
@@ -1788,6 +1809,47 @@ PLOT_TEST(a_middle_drag_on_a_figures_bottom_row_zooms_the_shared_x_axis) {
|
|||||||
// pointer) rather than on the live edge, and the window walked back off the newest sample
|
// pointer) rather than on the live edge, and the window walked back off the newest sample
|
||||||
// by a fifth of its width. These two cases pin the fix from the two ends: the gesture keeps
|
// by a fifth of its width. These two cases pin the fix from the two ends: the gesture keeps
|
||||||
// the edge, and the hint that makes it possible exists exactly while a group follows.
|
// the edge, and the hint that makes it possible exists exactly while a group follows.
|
||||||
|
/// A following group's window stays **on the data**: the offset bound is `retained - span`, not
|
||||||
|
/// `retained`. With the loose bound, scrolling back put the window's *right* edge on the oldest
|
||||||
|
/// retained sample, which leaves the window's whole span to its left over data the scope never
|
||||||
|
/// had -- an empty margin as wide as the view, reached by a pan or by a zoom-in near that edge.
|
||||||
|
/// That is the "sometimes zooming in, the left or right margin becomes empty" report, and it is
|
||||||
|
/// why the bound has to be about the window and not about one edge of it.
|
||||||
|
PLOT_TEST(a_following_group_keeps_its_whole_window_on_the_data) {
|
||||||
|
plot::data::StreamSeries series(1u << 12, 0.0, 1.0 / 1000.0);
|
||||||
|
plot::f32 samples[512];
|
||||||
|
for (u32 i = 0; i < 512u; ++i) samples[i] = static_cast<plot::f32>(i % 7u);
|
||||||
|
series.push(samples, 512u);
|
||||||
|
series.acquire();
|
||||||
|
|
||||||
|
plot::Figure figure(1u);
|
||||||
|
figure.set_viewport({0.0f, 0.0f, 800.0f, 600.0f});
|
||||||
|
figure.plot(0u).add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
|
||||||
|
figure.plot(0u).add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||||
|
figure.plot(0u).add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, 0u, 0u, "s"});
|
||||||
|
const u32 group = figure.add_x_link();
|
||||||
|
PLOT_CHECK(figure.link_x(0u, group));
|
||||||
|
PLOT_CHECK(figure.set_x_link_range(group, {0.0, 0.25}));
|
||||||
|
PLOT_CHECK(figure.set_x_link_follow(group, plot::XFollow::Live));
|
||||||
|
figure.layout();
|
||||||
|
|
||||||
|
const plot::Range data = series.x_bounds();
|
||||||
|
const f64 span = figure.x_link_range(group).span();
|
||||||
|
PLOT_CHECK(span > 0.0);
|
||||||
|
// Ask to scroll far past the oldest sample, the way a drag or a zoom-in near the left edge does.
|
||||||
|
PLOT_CHECK(figure.set_x_link_range(group, {data.min - 10.0 * span, data.min - 9.0 * span}));
|
||||||
|
figure.layout();
|
||||||
|
|
||||||
|
const plot::Range after = figure.x_link_range(group);
|
||||||
|
std::printf("[follow-bound] data=[%.4f..%.4f] span=%.4f after=[%.4f..%.4f]\n", data.min,
|
||||||
|
data.max, span, after.min, after.max);
|
||||||
|
// The window is still a window, and its *left* edge is on the data: that is the assertion the
|
||||||
|
// loose bound failed, where `after.min` came out a whole span below `data.min`.
|
||||||
|
PLOT_CHECK_NEAR(after.span(), span, 1e-9 * span);
|
||||||
|
PLOT_CHECK(after.min >= data.min - 1e-9);
|
||||||
|
PLOT_CHECK(after.max <= data.max + 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
PLOT_TEST(a_wheel_on_a_following_figures_axis_strip_keeps_the_live_edge) {
|
PLOT_TEST(a_wheel_on_a_following_figures_axis_strip_keeps_the_live_edge) {
|
||||||
constexpr u32 kFigureRows = 3u;
|
constexpr u32 kFigureRows = 3u;
|
||||||
constexpr f64 kWheelDx = 1.0 / 1000.0;
|
constexpr f64 kWheelDx = 1.0 / 1000.0;
|
||||||
@@ -2309,3 +2371,35 @@ PLOT_TEST(double_click_needs_both_timestamps_and_the_window) {
|
|||||||
f.controller.on_pointer({410.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
|
f.controller.on_pointer({410.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
|
||||||
true, 20.1});
|
true, 20.1});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A readout cursor is drawn but not offered to the pointer: the host that keeps it is showing
|
||||||
|
// a computed value, and a drag on it would be a drag on a number rather than on an instrument.
|
||||||
|
// The hit test is where that is enforced -- re-placing the marker every frame would only fight
|
||||||
|
// the drag, which is the shape this replaces.
|
||||||
|
PLOT_TEST(an_unselectable_cursor_is_neither_hovered_nor_grabbed) {
|
||||||
|
plot::Plot p;
|
||||||
|
p.set_viewport({0, 0, 800, 400});
|
||||||
|
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
|
||||||
|
const u32 readout = p.add_cursor(50.0, plot::Color::hex(0x00FF00));
|
||||||
|
p.set_cursor_selectable(readout, false);
|
||||||
|
PLOT_CHECK(p.cursor(readout).visible); // drawn all the same
|
||||||
|
PLOT_CHECK(!p.cursor(readout).selectable);
|
||||||
|
// A normal cursor right beside it stays grabbable, so the flag is a property of one cursor
|
||||||
|
// and not a switch on the plot.
|
||||||
|
const u32 normal = p.add_cursor(50.0, plot::Color::hex(0xFF0000));
|
||||||
|
|
||||||
|
plot::input::Controller c(p);
|
||||||
|
c.set_viewport({0, 0, 800, 400});
|
||||||
|
const f32 sx = screen_x_of(p, ax, 50.0);
|
||||||
|
c.on_pointer({sx, 200, 0, 0, plot::input::Button::Left, 0, false, false, false});
|
||||||
|
PLOT_CHECK(c.hovered_cursor() == normal); // the readout is not in the running
|
||||||
|
|
||||||
|
// Press and move: the pointer grabbed the *normal* cursor (the two are at the same x, so
|
||||||
|
// the readout would otherwise be the nearer hit), and the readout's own x never moves.
|
||||||
|
const f64 before = p.cursor(readout).x;
|
||||||
|
c.on_pointer({sx, 200, 0, 0, plot::input::Button::Left, 0, true, true, false});
|
||||||
|
PLOT_CHECK(c.grabbed_cursor() == normal);
|
||||||
|
c.on_pointer({sx + 40, 200, 40, 0, plot::input::Button::Left, 0, true, false, false});
|
||||||
|
PLOT_CHECK_NEAR(p.cursor(readout).x, before, 1e-12);
|
||||||
|
PLOT_CHECK(c.grabbed_cursor() == normal);
|
||||||
|
}
|
||||||
+69
-11
@@ -425,6 +425,52 @@ PLOT_TEST(figure_gutter_never_squeezes_the_axes_below_the_floor) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The other side of the same band. `axes` reached the viewport's right edge, which left
|
||||||
|
/// the rightmost x tick label -- centred on it -- half outside the plot, so a figure can
|
||||||
|
/// ask for an empty band there. One value for every row, like the left gutter, because
|
||||||
|
/// the rows' axes have to end on a common column as well as start on one.
|
||||||
|
PLOT_TEST(figure_rows_share_one_right_gutter) {
|
||||||
|
plot::Figure figure = different_magnitude_figure();
|
||||||
|
figure.layout();
|
||||||
|
const plot::f32 flush = figure.plot(0u).layout().axes.right();
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(figure.right_gutter()), 0.0, 1e-12);
|
||||||
|
// The default is the layout as it always was: nothing between the data and the frame.
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(flush), static_cast<plot::f64>(kW), 1e-4);
|
||||||
|
|
||||||
|
figure.set_right_gutter(30.0f);
|
||||||
|
figure.layout();
|
||||||
|
const plot::Rect first = figure.plot(0u).layout().axes;
|
||||||
|
for (plot::u32 row = 0; row < 3u; ++row) {
|
||||||
|
const plot::PlotLayout& rl = figure.plot(row).layout();
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.right_gutter), 30.0, 1e-4);
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.axes.right()), static_cast<plot::f64>(kW) - 30.0,
|
||||||
|
1e-4);
|
||||||
|
// The band comes off the right alone: the left gutter the rows agreed on is untouched,
|
||||||
|
// so a host can balance the two sides without the one moving when it sets the other.
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.axes.x), static_cast<plot::f64>(first.x), 1e-4);
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.axes.w), static_cast<plot::f64>(first.w), 1e-4);
|
||||||
|
// ... and the x strip follows the data area rather than the viewport, or the shared
|
||||||
|
// axis would be labelled over a band that holds no data.
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.x_label_area.w), static_cast<plot::f64>(rl.axes.w),
|
||||||
|
1e-4);
|
||||||
|
}
|
||||||
|
// Clamped by the same cap as the strips: a band wider than the whole figure cannot
|
||||||
|
// squeeze `axes` below the floor `Plot::layout()` guarantees.
|
||||||
|
figure.set_right_gutter(10.0f * kW);
|
||||||
|
figure.layout();
|
||||||
|
for (plot::u32 row = 0; row < 3u; ++row) {
|
||||||
|
const plot::Rect& axes = figure.plot(row).layout().axes;
|
||||||
|
PLOT_CHECK(axes.w >= 0.5f * kW - 1e-3f);
|
||||||
|
}
|
||||||
|
// Negative means "none", like the gutter and the row gap, and the data area goes back
|
||||||
|
// to the viewport's edge.
|
||||||
|
figure.set_right_gutter(-5.0f);
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(figure.right_gutter()), 0.0, 1e-12);
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(figure.plot(0u).layout().axes.right()),
|
||||||
|
static_cast<plot::f64>(flush), 1e-4);
|
||||||
|
}
|
||||||
|
|
||||||
/// The degenerate neighbour: a figure of one row imposes exactly what that row
|
/// The degenerate neighbour: a figure of one row imposes exactly what that row
|
||||||
/// measured, so it lays out as the plot would on its own -- the gutter cannot move a
|
/// measured, so it lays out as the plot would on its own -- the gutter cannot move a
|
||||||
/// row that has no sibling to line up with.
|
/// row that has no sibling to line up with.
|
||||||
@@ -844,20 +890,29 @@ PLOT_TEST(a_following_log_group_holds_decades) {
|
|||||||
|
|
||||||
// A member panned while the group follows: the adopted scroll state is the
|
// A member panned while the group follows: the adopted scroll state is the
|
||||||
// adopted range's span *in decades* -- two here (edge/1000 to edge/10 is a
|
// adopted range's span *in decades* -- two here (edge/1000 to edge/10 is a
|
||||||
// factor of 100) -- and the offset one decade behind the edge, both of which
|
// factor of 100) -- and the offset as far behind the edge as the retained data
|
||||||
// survive the next frames' advances.
|
// allows, both of which survive the next frames' advances.
|
||||||
|
//
|
||||||
|
// The pan *asks* for a full decade back, and the group may not take it: the offset is bounded by
|
||||||
|
// `retained - span`, so the window stops with its left edge on the oldest sample. Asserting
|
||||||
|
// `max == edge/10` here was asserting the *old* bound, which put a whole span of the window over
|
||||||
|
// data the members never held -- the empty-left-margin defect this bound exists for. The
|
||||||
|
// log-space assertions above are this test's subject and are unchanged.
|
||||||
const plot::f64 edge = left.x_bounds().max;
|
const plot::f64 edge = left.x_bounds().max;
|
||||||
figure.plot(1u).set_x_range(0u, {edge / 1000.0, edge / 10.0});
|
figure.plot(1u).set_x_range(0u, {edge / 1000.0, edge / 10.0});
|
||||||
figure.layout();
|
figure.layout();
|
||||||
PLOT_CHECK_NEAR(decades_of(figure.plot(0u)), 2.0, 1e-9);
|
PLOT_CHECK_NEAR(decades_of(figure.plot(0u)), 2.0, 1e-9);
|
||||||
PLOT_CHECK_NEAR(decades_of(figure.plot(1u)), 2.0, 1e-9);
|
PLOT_CHECK_NEAR(decades_of(figure.plot(1u)), 2.0, 1e-9);
|
||||||
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).max, edge / 10.0, 1e-8);
|
const plot::f64 oldest = std::min(left.x_bounds().min, right.x_bounds().min);
|
||||||
|
PLOT_CHECK(figure.plot(0u).x_range(0u).min >= oldest - 1e-6);
|
||||||
|
PLOT_CHECK(figure.plot(0u).x_range(0u).max <= edge + 1e-6);
|
||||||
for (int step = 0; step < 3; ++step) {
|
for (int step = 0; step < 3; ++step) {
|
||||||
left.push(0.5f);
|
left.push(0.5f);
|
||||||
left.acquire();
|
left.acquire();
|
||||||
figure.layout();
|
figure.layout();
|
||||||
PLOT_CHECK_NEAR(decades_of(figure.plot(0u)), 2.0, 1e-9);
|
PLOT_CHECK_NEAR(decades_of(figure.plot(0u)), 2.0, 1e-9);
|
||||||
PLOT_CHECK_NEAR(decades_of(figure.plot(1u)), 2.0, 1e-9);
|
PLOT_CHECK_NEAR(decades_of(figure.plot(1u)), 2.0, 1e-9);
|
||||||
|
PLOT_CHECK(figure.plot(0u).x_range(0u).min >= oldest - 1e-6);
|
||||||
}
|
}
|
||||||
PLOT_CHECK(figure.x_link_follow(group) == plot::XFollow::Live);
|
PLOT_CHECK(figure.x_link_follow(group) == plot::XFollow::Live);
|
||||||
}
|
}
|
||||||
@@ -868,8 +923,8 @@ namespace {
|
|||||||
|
|
||||||
/// The figure the crosshair cases hover: three rows at deliberately *different* sample rates on
|
/// The figure the crosshair cases hover: three rows at deliberately *different* sample rates on
|
||||||
/// the one shared x, plus a fourth whose data does not reach the hovered x at all. Rows that
|
/// the one shared x, plus a fourth whose data does not reach the hovered x at all. Rows that
|
||||||
/// sample at different rates are the point -- one x, three different interpolations -- and the
|
/// sample at different rates are the point -- one x, and each row reporting a sample off its own
|
||||||
/// last row is the case a readout must not invent a value for.
|
/// grid -- and the last row is the case a readout must not invent a value for.
|
||||||
plot::Figure crosshair_figure() {
|
plot::Figure crosshair_figure() {
|
||||||
plot::Figure figure(4u);
|
plot::Figure figure(4u);
|
||||||
figure.set_viewport(whole_figure());
|
figure.set_viewport(whole_figure());
|
||||||
@@ -897,7 +952,7 @@ plot::Figure crosshair_figure() {
|
|||||||
|
|
||||||
/// Spec §15, the sync: `Figure::set_crosshair()` applies **one** data x to every row, so one
|
/// Spec §15, the sync: `Figure::set_crosshair()` applies **one** data x to every row, so one
|
||||||
/// hover is one instant and N readouts rather than N independent ones. Rows at different sample
|
/// hover is one instant and N readouts rather than N independent ones. Rows at different sample
|
||||||
/// rates therefore report *their own* interpolations of the same x -- the shared axis is x, and
|
/// rates therefore report *their own* nearest sample to the same x -- the shared axis is x, and
|
||||||
/// each row's series is read on its own grid -- while a row whose series does not reach that x
|
/// each row's series is read on its own grid -- while a row whose series does not reach that x
|
||||||
/// reports no value at all instead of a fabricated one.
|
/// reports no value at all instead of a fabricated one.
|
||||||
PLOT_TEST(figure_crosshair_applies_one_x_to_every_row) {
|
PLOT_TEST(figure_crosshair_applies_one_x_to_every_row) {
|
||||||
@@ -921,14 +976,15 @@ PLOT_TEST(figure_crosshair_applies_one_x_to_every_row) {
|
|||||||
PLOT_CHECK(first.rows[0].label == "fast");
|
PLOT_CHECK(first.rows[0].label == "fast");
|
||||||
PLOT_CHECK_NEAR(first.rows[0].y, 30.0, 1e-9);
|
PLOT_CHECK_NEAR(first.rows[0].y, 30.0, 1e-9);
|
||||||
|
|
||||||
// Row 1 samples every *two* units: x = 3 is halfway between sample 1 (y = 100) and sample 2
|
// Row 1 samples every *two* units: x = 3 is halfway between sample 1 (x = 2, y = 100) and
|
||||||
// (y = 200), so it reports 150 -- a different number for the same x, which is what makes this
|
// sample 2 (x = 4, y = 200), which is the tie, so it reports sample 1's 100 -- a different
|
||||||
// case about the rows rather than about one series read four times.
|
// number for the same x, which is what makes this case about the rows rather than about one
|
||||||
|
// series read four times. Not 150: a readout quotes a sample, and this row has none at x = 3.
|
||||||
const plot::CrosshairLayout& second = figure.plot(1u).layout().crosshair;
|
const plot::CrosshairLayout& second = figure.plot(1u).layout().crosshair;
|
||||||
PLOT_CHECK(second.rows.size() == 1u);
|
PLOT_CHECK(second.rows.size() == 1u);
|
||||||
PLOT_CHECK(second.rows[0].valid);
|
PLOT_CHECK(second.rows[0].valid);
|
||||||
PLOT_CHECK(second.rows[0].label == "slow");
|
PLOT_CHECK(second.rows[0].label == "slow");
|
||||||
PLOT_CHECK_NEAR(second.rows[0].y, 150.0, 1e-9);
|
PLOT_CHECK_NEAR(second.rows[0].y, 100.0, 1e-9);
|
||||||
PLOT_CHECK(second.rows[0].value != first.rows[0].value);
|
PLOT_CHECK(second.rows[0].value != first.rows[0].value);
|
||||||
|
|
||||||
// Row 2's trace has no series yet: no row at all -- it is not a signal under the pointer.
|
// Row 2's trace has no series yet: no row at all -- it is not a signal under the pointer.
|
||||||
@@ -963,7 +1019,9 @@ PLOT_TEST(figure_crosshair_applies_one_x_to_every_row) {
|
|||||||
PLOT_CHECK_NEAR(figure.plot(row).crosshair().x, 1.0, 1e-12);
|
PLOT_CHECK_NEAR(figure.plot(row).crosshair().x, 1.0, 1e-12);
|
||||||
}
|
}
|
||||||
PLOT_CHECK_NEAR(figure.plot(0u).layout().crosshair.rows[0].y, 10.0, 1e-9);
|
PLOT_CHECK_NEAR(figure.plot(0u).layout().crosshair.rows[0].y, 10.0, 1e-9);
|
||||||
PLOT_CHECK_NEAR(figure.plot(1u).layout().crosshair.rows[0].y, 50.0, 1e-9); // 0 + 100 * 0.5
|
// Not 50: x = 1 is the tie between this row's samples at x = 0 (y = 0) and x = 2 (y = 100),
|
||||||
|
// and a readout quotes one of them.
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(1u).layout().crosshair.rows[0].y, 0.0, 1e-9);
|
||||||
PLOT_CHECK(!figure.plot(3u).layout().crosshair.rows[0].valid);
|
PLOT_CHECK(!figure.plot(3u).layout().crosshair.rows[0].valid);
|
||||||
|
|
||||||
// On *every* row at once, and only the rows free it.
|
// On *every* row at once, and only the rows free it.
|
||||||
|
|||||||
+241
-36
@@ -1939,11 +1939,11 @@ class CountingSeries final : public plot::data::Series {
|
|||||||
}
|
}
|
||||||
plot::Range y_bounds() const override { return {0.0, 99.0}; }
|
plot::Range y_bounds() const override { return {0.0, 99.0}; }
|
||||||
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
|
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
|
||||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out,
|
void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out,
|
||||||
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override {
|
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override {
|
||||||
++gather_calls_;
|
++gather_calls_;
|
||||||
samples_read_ += end - begin;
|
samples_read_ += end - begin;
|
||||||
gather_impl(level, begin, end, out, mode);
|
gather_impl(level, begin, end, x_origin, out, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
u64 gather_calls() const noexcept { return gather_calls_; }
|
u64 gather_calls() const noexcept { return gather_calls_; }
|
||||||
@@ -1965,20 +1965,21 @@ class CountingSeries final : public plot::data::Series {
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
/// Spec §15, the lookup: a readout's y is **linear interpolation between the two samples
|
/// Spec §15, the lookup: a readout's y is **the nearer of the two samples adjacent to the
|
||||||
/// adjacent to the crosshair's x**, and hand-computed here rather than checked against the
|
/// crosshair's x**, never a value between them. Every y asserted here is therefore one of the
|
||||||
/// implementation's own arithmetic. The four corners of the rule are all asserted: a query
|
/// series' own `tens` values -- a number *between* two of them, 15 or 22.5, is precisely what
|
||||||
/// between two samples, the two exact-sample ends of the window (where the span collapses onto
|
/// this refuses, and what it used to return. The corners of the rule are all covered: a query
|
||||||
/// one sample), and a one-sample series, whose window is that sample alone.
|
/// either side of the midpoint, a query exactly on it (the tie), the two exact-sample ends of
|
||||||
PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) {
|
/// the window (where the span collapses onto one sample), and a one-sample series.
|
||||||
|
PLOT_TEST(crosshair_value_is_the_nearest_sample) {
|
||||||
CrosshairScene scene;
|
CrosshairScene scene;
|
||||||
const u32 trace = scene.plot.add_trace(
|
const u32 trace = scene.plot.add_trace(
|
||||||
{&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay});
|
{&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay});
|
||||||
|
|
||||||
// The span itself, for a query halfway between sample 1 (x = 1, y = 10) and sample 2
|
// The span itself, for a query halfway between sample 1 (x = 1, y = 10) and sample 2
|
||||||
// (x = 2, y = 20): the two neighbours, and the fraction between them. Asserting the span and
|
// (x = 2, y = 20): the two neighbours, and the fraction between them. The span still reports
|
||||||
// not only the value is what pins the convention -- a nearest-sample lookup would report the
|
// both and the fraction between them -- `t` is a *selector* now, not a blend factor, so the
|
||||||
// span's `t` as 0 or 1, and an average of a *range* would not report a span at all.
|
// bracketing is unchanged and only what is built from it differs.
|
||||||
const plot::CrosshairSpan half = plot::crosshair_span(scene.series, 1.5);
|
const plot::CrosshairSpan half = plot::crosshair_span(scene.series, 1.5);
|
||||||
PLOT_CHECK(half.valid);
|
PLOT_CHECK(half.valid);
|
||||||
PLOT_CHECK(half.begin == 1u);
|
PLOT_CHECK(half.begin == 1u);
|
||||||
@@ -1986,14 +1987,25 @@ PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) {
|
|||||||
PLOT_CHECK_NEAR(half.t, 0.5, 1e-12);
|
PLOT_CHECK_NEAR(half.t, 0.5, 1e-12);
|
||||||
|
|
||||||
f64 y = 0.0;
|
f64 y = 0.0;
|
||||||
|
// Either side of the midpoint reads the sample it is nearer to, and *is* that sample: 10 and
|
||||||
|
// 20 are `tens` values, where an interpolation would have answered 11 and 19.
|
||||||
|
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.1, y));
|
||||||
|
PLOT_CHECK_NEAR(y, 10.0, 1e-9);
|
||||||
|
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.9, y));
|
||||||
|
PLOT_CHECK_NEAR(y, 20.0, 1e-9);
|
||||||
|
// Exactly halfway is the tie, and it reads the *earlier* sample -- the bias
|
||||||
|
// `crosshair_span()` already takes for a degenerate pair. 15.0 is the old answer.
|
||||||
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.5, y));
|
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.5, y));
|
||||||
PLOT_CHECK_NEAR(y, 15.0, 1e-9);
|
PLOT_CHECK_NEAR(y, 10.0, 1e-9);
|
||||||
// A quarter of the way through another pair: 20 + (30 - 20) * 0.25.
|
// A quarter of the way through another pair: sample 2, not 22.5.
|
||||||
PLOT_CHECK(scene.plot.crosshair_value(trace, 2.25, y));
|
PLOT_CHECK(scene.plot.crosshair_value(trace, 2.25, y));
|
||||||
PLOT_CHECK_NEAR(y, 22.5, 1e-9);
|
PLOT_CHECK_NEAR(y, 20.0, 1e-9);
|
||||||
|
// Just short of the next sample is that sample, so the readout steps rather than ramps.
|
||||||
|
PLOT_CHECK(scene.plot.crosshair_value(trace, 2.99, y));
|
||||||
|
PLOT_CHECK_NEAR(y, 30.0, 1e-9);
|
||||||
|
|
||||||
// The ends of the window are samples too, so a query exactly on one reads that sample --
|
// The ends of the window are samples too, so a query exactly on one reads that sample --
|
||||||
// and reads it *as* a span of one, with nothing to its left or right to interpolate with.
|
// and reads it *as* a span of one, with no neighbour on the far side.
|
||||||
const plot::CrosshairSpan first = plot::crosshair_span(scene.series, 0.0);
|
const plot::CrosshairSpan first = plot::crosshair_span(scene.series, 0.0);
|
||||||
PLOT_CHECK(first.valid);
|
PLOT_CHECK(first.valid);
|
||||||
PLOT_CHECK(first.begin == 0u);
|
PLOT_CHECK(first.begin == 0u);
|
||||||
@@ -2008,9 +2020,10 @@ PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) {
|
|||||||
PLOT_CHECK(scene.plot.crosshair_value(trace, 3.0, y));
|
PLOT_CHECK(scene.plot.crosshair_value(trace, 3.0, y));
|
||||||
PLOT_CHECK_NEAR(y, 30.0, 1e-9);
|
PLOT_CHECK_NEAR(y, 30.0, 1e-9);
|
||||||
|
|
||||||
// Outside the window there is no value at all -- not the nearest sample's, not the end of
|
// Outside the window there is no value at all. The nearest-sample rule is a rule for an x the
|
||||||
// the window's, which are the clamped lies the header refuses -- and a series with no
|
// series *brackets*; extending it past the ends would answer with the first or last sample for
|
||||||
// samples has no span at any x.
|
// any x whatever, which is the clamped lie the header refuses. A series with no samples has no
|
||||||
|
// span at any x.
|
||||||
PLOT_CHECK(!plot::crosshair_span(scene.series, -1e-9).valid);
|
PLOT_CHECK(!plot::crosshair_span(scene.series, -1e-9).valid);
|
||||||
PLOT_CHECK(!plot::crosshair_span(scene.series, 3.0 + 1e-9).valid);
|
PLOT_CHECK(!plot::crosshair_span(scene.series, 3.0 + 1e-9).valid);
|
||||||
PLOT_CHECK(!scene.plot.crosshair_value(trace, 4.0, y));
|
PLOT_CHECK(!scene.plot.crosshair_value(trace, 4.0, y));
|
||||||
@@ -2023,7 +2036,7 @@ PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) {
|
|||||||
PLOT_CHECK(!scene.plot.crosshair_value(/*trace=*/99u, 1.0, y)); // an id that names nothing
|
PLOT_CHECK(!scene.plot.crosshair_value(/*trace=*/99u, 1.0, y)); // an id that names nothing
|
||||||
|
|
||||||
// A one-sample series: the sample itself is the only query it answers, and it answers it
|
// A one-sample series: the sample itself is the only query it answers, and it answers it
|
||||||
// without a neighbour to interpolate against.
|
// with no second sample to be nearer to.
|
||||||
plot::data::ArraySeries single(std::vector<f32>{7.0f}, 5.0, 1.0);
|
plot::data::ArraySeries single(std::vector<f32>{7.0f}, 5.0, 1.0);
|
||||||
plot::Plot one;
|
plot::Plot one;
|
||||||
one.add_x_axis({"t", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false});
|
one.add_x_axis({"t", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false});
|
||||||
@@ -2039,7 +2052,9 @@ PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) {
|
|||||||
PLOT_CHECK(!one.crosshair_value(single_trace, 5.5, y));
|
PLOT_CHECK(!one.crosshair_value(single_trace, 5.5, y));
|
||||||
|
|
||||||
// The readout's row is the same lookup: the number a user reads off the panel is the value
|
// The readout's row is the same lookup: the number a user reads off the panel is the value
|
||||||
// this test computed by hand, not a second derivation of it.
|
// this test computed by hand, not a second derivation of it. x = 1.5 is the tie, so the row
|
||||||
|
// reads sample 1 -- and the formatted cell is `"10"`, where the interpolating readout showed
|
||||||
|
// `"15"`, a number the series does not contain.
|
||||||
scene.plot.set_crosshair(1.5, true);
|
scene.plot.set_crosshair(1.5, true);
|
||||||
const plot::CrosshairLayout& crosshair = scene.plot.layout().crosshair;
|
const plot::CrosshairLayout& crosshair = scene.plot.layout().crosshair;
|
||||||
PLOT_CHECK(crosshair.active);
|
PLOT_CHECK(crosshair.active);
|
||||||
@@ -2049,8 +2064,136 @@ PLOT_TEST(crosshair_value_is_interpolated_between_neighbours) {
|
|||||||
// the figure's case pins).
|
// the figure's case pins).
|
||||||
PLOT_CHECK(crosshair.rows.size() == 1u);
|
PLOT_CHECK(crosshair.rows.size() == 1u);
|
||||||
PLOT_CHECK(crosshair.rows[0].valid);
|
PLOT_CHECK(crosshair.rows[0].valid);
|
||||||
PLOT_CHECK_NEAR(crosshair.rows[0].y, 15.0, 1e-9);
|
PLOT_CHECK_NEAR(crosshair.rows[0].y, 10.0, 1e-9);
|
||||||
PLOT_CHECK(crosshair.rows[0].value == "15");
|
PLOT_CHECK(crosshair.rows[0].value == "10");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mark is **snapped to the sample the row quotes**, which is what makes it say *which*
|
||||||
|
/// value is being read. Placing it at the pointer's x with the sample's y put it on neither the
|
||||||
|
/// data nor the polyline, and the further a reader zoomed in the further off it sat -- a mark on
|
||||||
|
/// nothing.
|
||||||
|
///
|
||||||
|
/// The queries here are all **off** a sample on purpose, so "snapped" has something to be wrong
|
||||||
|
/// about: the assertion is that `row.x` is a `tens` grid position (0, 1, 2, 3) and not the query,
|
||||||
|
/// and that `row.marker` is that position mapped through the trace's own axes. The pixel column
|
||||||
|
/// is computed from the transform the plot already exposes, so this checks the two agree rather
|
||||||
|
/// than re-deriving the mapping.
|
||||||
|
PLOT_TEST(the_crosshair_marker_snaps_to_the_sample_it_reports) {
|
||||||
|
CrosshairScene scene;
|
||||||
|
const u32 trace = scene.plot.add_trace(
|
||||||
|
{&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay, "sine"});
|
||||||
|
|
||||||
|
// A query between samples 1 and 2, nearer 2: the point is sample 2 entire -- x = 2 as well as
|
||||||
|
// y = 20 -- so the mark lands on the datum and not a fifth of a division to its left.
|
||||||
|
f64 px = 0.0;
|
||||||
|
f64 py = 0.0;
|
||||||
|
PLOT_CHECK(scene.plot.crosshair_point(trace, 1.8, px, py));
|
||||||
|
PLOT_CHECK_NEAR(px, 2.0, 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(py, 20.0, 1e-9);
|
||||||
|
// The tie takes the earlier sample, and takes *its* x with it: the same bias in both
|
||||||
|
// coordinates, so the mark and the number can never name different samples.
|
||||||
|
PLOT_CHECK(scene.plot.crosshair_point(trace, 1.5, px, py));
|
||||||
|
PLOT_CHECK_NEAR(px, 1.0, 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(py, 10.0, 1e-9);
|
||||||
|
// `crosshair_value()` is the same call with the x dropped, so the two cannot disagree.
|
||||||
|
f64 y_only = 0.0;
|
||||||
|
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.5, y_only));
|
||||||
|
PLOT_CHECK_NEAR(y_only, py, 1e-12);
|
||||||
|
// A failing lookup leaves both outputs alone, as the value form does.
|
||||||
|
PLOT_CHECK(!scene.plot.crosshair_point(trace, 9.0, px, py));
|
||||||
|
PLOT_CHECK_NEAR(px, 1.0, 1e-12);
|
||||||
|
|
||||||
|
// And through the layout: the row carries the snapped x, and the marker is that x through the
|
||||||
|
// trace's own transform -- not the crosshair's column, which is where it used to be.
|
||||||
|
scene.plot.set_crosshair(1.8, true);
|
||||||
|
scene.plot.layout();
|
||||||
|
const plot::CrosshairRow& row = scene.plot.layout().crosshair.rows[0];
|
||||||
|
PLOT_CHECK(row.valid);
|
||||||
|
PLOT_CHECK_NEAR(row.x, 2.0, 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(row.y, 20.0, 1e-9);
|
||||||
|
const f32 sample_col = static_cast<f32>(scene.plot.x_transform(scene.ax).to_screen_x(2.0));
|
||||||
|
const f32 pointer_col = static_cast<f32>(scene.plot.x_transform(scene.ax).to_screen_x(1.8));
|
||||||
|
PLOT_CHECK_NEAR(row.marker.x, sample_col, 1e-3);
|
||||||
|
PLOT_CHECK_NEAR(row.marker.y,
|
||||||
|
static_cast<f32>(scene.plot.y_transform(scene.ay).to_screen_y(20.0)), 1e-3);
|
||||||
|
// The two columns are far apart at this scale, so the check above is not satisfied by both
|
||||||
|
// being nearly the same number.
|
||||||
|
PLOT_CHECK(std::abs(row.marker.x - pointer_col) > 10.0f);
|
||||||
|
// The vertical line stays on the *pointer*: it is where the hover is, and only the mark moved.
|
||||||
|
PLOT_CHECK_NEAR(scene.plot.layout().crosshair.vertical.x +
|
||||||
|
0.5f * scene.plot.layout().crosshair.vertical.w,
|
||||||
|
pointer_col, 1e-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The other mode, and the reason it exists: `CrosshairValue::Interpolated` answers "what is the
|
||||||
|
/// signal *at* this instant" by blending the two bracketing samples, for a caller whose data is a
|
||||||
|
/// continuous quantity sampled finely enough that the line between two samples is a model it
|
||||||
|
/// believes. `NearestSample` is the default because that line is an artefact of plotting for most
|
||||||
|
/// sources -- see `CrosshairValue` -- but the question is about the data, so the library offers
|
||||||
|
/// both rather than picking for the caller.
|
||||||
|
///
|
||||||
|
/// Every number here is one the *other* mode refuses, so the two are pinned apart rather than
|
||||||
|
/// merely exercised: 15 at the tie, 11 and 19 either side of it. The marker moves with the value,
|
||||||
|
/// which is the mode's second half -- an interpolated y belongs at the pointer's x, on the
|
||||||
|
/// polyline, and nowhere else.
|
||||||
|
PLOT_TEST(crosshair_interpolated_mode_blends_the_two_samples) {
|
||||||
|
CrosshairScene scene;
|
||||||
|
const u32 trace = scene.plot.add_trace(
|
||||||
|
{&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay, "sine"});
|
||||||
|
PLOT_CHECK(scene.plot.crosshair_value_mode() == plot::CrosshairValue::NearestSample);
|
||||||
|
scene.plot.set_crosshair_value_mode(plot::CrosshairValue::Interpolated);
|
||||||
|
PLOT_CHECK(scene.plot.crosshair_value_mode() == plot::CrosshairValue::Interpolated);
|
||||||
|
|
||||||
|
f64 y = 0.0;
|
||||||
|
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.5, y));
|
||||||
|
PLOT_CHECK_NEAR(y, 15.0, 1e-9);
|
||||||
|
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.1, y));
|
||||||
|
PLOT_CHECK_NEAR(y, 11.0, 1e-9);
|
||||||
|
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.9, y));
|
||||||
|
PLOT_CHECK_NEAR(y, 19.0, 1e-9);
|
||||||
|
// A query *on* a sample reads that sample back exactly in this mode too: the span collapses to
|
||||||
|
// one index, `t` is 0, and `crosshair_interpolate()`'s form returns `y_begin` unrounded.
|
||||||
|
PLOT_CHECK(scene.plot.crosshair_value(trace, 2.0, y));
|
||||||
|
PLOT_CHECK(y == 20.0);
|
||||||
|
PLOT_CHECK(plot::crosshair_interpolate(10.0, 20.0, 0.0) == 10.0);
|
||||||
|
PLOT_CHECK(plot::crosshair_interpolate(10.0, 20.0, 1.0) == 20.0);
|
||||||
|
PLOT_CHECK_NEAR(plot::crosshair_interpolate(10.0, 20.0, 0.25), 12.5, 1e-12);
|
||||||
|
// Out of the window is still no value: interpolating needs two samples to be between.
|
||||||
|
PLOT_CHECK(!scene.plot.crosshair_value(trace, 4.0, y));
|
||||||
|
|
||||||
|
// The point is the *query's* x here, not a sample's, so the mark sits on the polyline where the
|
||||||
|
// number is -- the opposite placement from `NearestSample`, and the mode's visible half.
|
||||||
|
f64 px = 0.0;
|
||||||
|
f64 py = 0.0;
|
||||||
|
PLOT_CHECK(scene.plot.crosshair_point(trace, 1.8, px, py));
|
||||||
|
PLOT_CHECK_NEAR(px, 1.8, 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(py, 18.0, 1e-9);
|
||||||
|
scene.plot.set_crosshair(1.8, true);
|
||||||
|
scene.plot.layout();
|
||||||
|
const plot::CrosshairRow& row = scene.plot.layout().crosshair.rows[0];
|
||||||
|
PLOT_CHECK_NEAR(row.x, 1.8, 1e-12);
|
||||||
|
PLOT_CHECK(row.value == "18");
|
||||||
|
const f32 pointer_col = static_cast<f32>(scene.plot.x_transform(scene.ax).to_screen_x(1.8));
|
||||||
|
PLOT_CHECK_NEAR(row.marker.x, pointer_col, 1e-3);
|
||||||
|
|
||||||
|
// Switching back is a plain state change, with no second placement to keep in step: the next
|
||||||
|
// layout reports the sample again.
|
||||||
|
scene.plot.set_crosshair_value_mode(plot::CrosshairValue::NearestSample);
|
||||||
|
scene.plot.layout();
|
||||||
|
PLOT_CHECK_NEAR(scene.plot.layout().crosshair.rows[0].x, 2.0, 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(scene.plot.layout().crosshair.rows[0].y, 20.0, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mark has to be **bigger than a trace's own sample dots**, because in the default mode it is
|
||||||
|
/// snapped onto one of them: at the same size it reads as one more sample rather than as the one
|
||||||
|
/// the panel is quoting. That is a property of the defaults, not of a drawing call, so it is
|
||||||
|
/// asserted where it is decided -- a later edit that tunes `marker_px` up past the crosshair's is
|
||||||
|
/// the regression, and nothing else in the library would notice.
|
||||||
|
PLOT_TEST(the_crosshair_mark_is_bigger_than_a_sample_dot) {
|
||||||
|
const plot::render::Style style;
|
||||||
|
PLOT_CHECK(style.crosshair_marker_px > style.marker_px);
|
||||||
|
// And heavier than the crosshair's own lines, which are chrome and recede on purpose.
|
||||||
|
PLOT_CHECK(style.crosshair_marker_width > style.crosshair_width);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spec §15, the readout's membership: **every visible trace gets a row, whether or not it is
|
/// Spec §15, the readout's membership: **every visible trace gets a row, whether or not it is
|
||||||
@@ -2080,14 +2223,16 @@ PLOT_TEST(crosshair_readout_lists_every_visible_trace_including_unnamed_ones) {
|
|||||||
PLOT_CHECK(crosshair.rows[1].label == plot::crosshair_unnamed_label(1u));
|
PLOT_CHECK(crosshair.rows[1].label == plot::crosshair_unnamed_label(1u));
|
||||||
PLOT_CHECK(crosshair.rows[1].label == "#1");
|
PLOT_CHECK(crosshair.rows[1].label == "#1");
|
||||||
|
|
||||||
// Both are listed with the value their own series has at x = 1.5, the swatch, the label's box
|
// Both are listed with the value their own series has nearest x = 1.5, the swatch, the label's
|
||||||
// and the value's box -- all placed by the plot, all inside the panel it measured.
|
// box and the value's box -- all placed by the plot, all inside the panel it measured. x = 1.5
|
||||||
|
// is the tie between samples 1 and 2, which reads the earlier one: 10, not the 15 an
|
||||||
|
// interpolating readout showed.
|
||||||
PLOT_CHECK(crosshair.panel.w > 0.0f && crosshair.panel.h > 0.0f);
|
PLOT_CHECK(crosshair.panel.w > 0.0f && crosshair.panel.h > 0.0f);
|
||||||
for (const plot::CrosshairRow& row : crosshair.rows) {
|
for (const plot::CrosshairRow& row : crosshair.rows) {
|
||||||
PLOT_CHECK(row.valid);
|
PLOT_CHECK(row.valid);
|
||||||
PLOT_CHECK(row.listed);
|
PLOT_CHECK(row.listed);
|
||||||
PLOT_CHECK_NEAR(row.y, 15.0, 1e-9);
|
PLOT_CHECK_NEAR(row.y, 10.0, 1e-9);
|
||||||
PLOT_CHECK(row.value == "15");
|
PLOT_CHECK(row.value == "10");
|
||||||
PLOT_CHECK(row.swatch.x >= crosshair.panel.x);
|
PLOT_CHECK(row.swatch.x >= crosshair.panel.x);
|
||||||
PLOT_CHECK(row.swatch.right() <= crosshair.panel.right());
|
PLOT_CHECK(row.swatch.right() <= crosshair.panel.right());
|
||||||
PLOT_CHECK(row.swatch.y >= crosshair.panel.y);
|
PLOT_CHECK(row.swatch.y >= crosshair.panel.y);
|
||||||
@@ -2148,7 +2293,7 @@ PLOT_TEST(crosshair_readout_lists_every_visible_trace_including_unnamed_ones) {
|
|||||||
PLOT_CHECK(on.rows[0].listed);
|
PLOT_CHECK(on.rows[0].listed);
|
||||||
PLOT_CHECK(on.rows[1].listed);
|
PLOT_CHECK(on.rows[1].listed);
|
||||||
PLOT_CHECK(on.panel.w > 0.0f && on.panel.h > 0.0f);
|
PLOT_CHECK(on.panel.w > 0.0f && on.panel.h > 0.0f);
|
||||||
PLOT_CHECK(on.rows[0].value == "15");
|
PLOT_CHECK(on.rows[0].value == "10");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spec §15, the panel's placement: near the pointer, flipped over it where it would otherwise
|
/// Spec §15, the panel's placement: near the pointer, flipped over it where it would otherwise
|
||||||
@@ -2253,11 +2398,14 @@ PLOT_TEST(crosshair_lookup_does_not_scan) {
|
|||||||
p.add_y_axis({"v", {0.0, 99.0}, false, plot::AxisPlace::Left, false});
|
p.add_y_axis({"v", {0.0, 99.0}, false, plot::AxisPlace::Left, false});
|
||||||
const u32 trace = p.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
|
const u32 trace = p.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
|
||||||
|
|
||||||
// A query between two samples: 1234 % 100 = 34, the next is 35, halfway between them.
|
// A query between two samples: 1234 % 100 = 34, the next is 35, and 1234.5 is the tie, which
|
||||||
|
// reads the earlier -- 34, where the interpolating readout answered 34.5. Both samples are
|
||||||
|
// still read: the bound below is on how much data the lookup touches, not on which end it
|
||||||
|
// then reports.
|
||||||
f64 y = 0.0;
|
f64 y = 0.0;
|
||||||
const u64 before_reads = series.samples_read();
|
const u64 before_reads = series.samples_read();
|
||||||
PLOT_CHECK(p.crosshair_value(trace, 1234.5, y));
|
PLOT_CHECK(p.crosshair_value(trace, 1234.5, y));
|
||||||
PLOT_CHECK_NEAR(y, 34.5, 1e-9);
|
PLOT_CHECK_NEAR(y, 34.0, 1e-9);
|
||||||
PLOT_CHECK(series.samples_read() - before_reads <= 2u);
|
PLOT_CHECK(series.samples_read() - before_reads <= 2u);
|
||||||
PLOT_CHECK(series.gather_calls() <= 1u);
|
PLOT_CHECK(series.gather_calls() <= 1u);
|
||||||
|
|
||||||
@@ -2288,7 +2436,7 @@ PLOT_TEST(crosshair_lookup_does_not_scan) {
|
|||||||
const plot::CrosshairLayout& crosshair = p.layout().crosshair;
|
const plot::CrosshairLayout& crosshair = p.layout().crosshair;
|
||||||
PLOT_CHECK(crosshair.rows.size() == 1u);
|
PLOT_CHECK(crosshair.rows.size() == 1u);
|
||||||
PLOT_CHECK(crosshair.rows[0].valid);
|
PLOT_CHECK(crosshair.rows[0].valid);
|
||||||
PLOT_CHECK_NEAR(crosshair.rows[0].y, 34.5, 1e-9);
|
PLOT_CHECK_NEAR(crosshair.rows[0].y, 34.0, 1e-9);
|
||||||
PLOT_CHECK(series.samples_read() - reads <= 2u);
|
PLOT_CHECK(series.samples_read() - reads <= 2u);
|
||||||
PLOT_CHECK(series.gather_calls() - calls <= 1u);
|
PLOT_CHECK(series.gather_calls() - calls <= 1u);
|
||||||
// And the readout is not vacuous: the series it read two samples of really does hold a
|
// And the readout is not vacuous: the series it read two samples of really does hold a
|
||||||
@@ -2323,9 +2471,9 @@ class XyFixture final : public plot::data::Series {
|
|||||||
}
|
}
|
||||||
plot::Range y_bounds() const override { return {0.0, 20.0}; }
|
plot::Range y_bounds() const override { return {0.0, 20.0}; }
|
||||||
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
|
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
|
||||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out,
|
void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out,
|
||||||
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override {
|
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override {
|
||||||
gather_impl(level, begin, end, out, mode);
|
gather_impl(level, begin, end, x_origin, out, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
f64 x_at(u64 i) const override { return x_[static_cast<std::size_t>(i)]; }
|
f64 x_at(u64 i) const override { return x_[static_cast<std::size_t>(i)]; }
|
||||||
@@ -2511,9 +2659,12 @@ PLOT_TEST(scrolled_back_live_window_keeps_its_decades) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The XY routing, crosshair half: the span brackets the samples the *array*
|
/// The XY routing, crosshair half: the span brackets the samples the *array*
|
||||||
/// names, and the interpolation fraction is measured on the samples' own positions
|
/// names, and `t` is measured on the samples' own positions -- (5-1)/(10-1) over the
|
||||||
/// -- (5-1)/(10-1) over the wide gap, where a uniform-grid guess would have read
|
/// wide gap, where a uniform-grid guess would have read samples that do not exist at
|
||||||
/// samples that do not exist at the positions it assumed.
|
/// the positions it assumed. The gap is what makes the value worth asserting too:
|
||||||
|
/// x = 5 sits 4 from sample 1 and 5 from sample 2, so the nearest-sample rule has a
|
||||||
|
/// real choice to get right, and it only has that choice because `t` came from the
|
||||||
|
/// array.
|
||||||
PLOT_TEST(xy_series_crosshair_brackets_its_own_samples) {
|
PLOT_TEST(xy_series_crosshair_brackets_its_own_samples) {
|
||||||
XyScene scene;
|
XyScene scene;
|
||||||
const u32 trace = scene.plot.add_trace(
|
const u32 trace = scene.plot.add_trace(
|
||||||
@@ -2526,7 +2677,11 @@ PLOT_TEST(xy_series_crosshair_brackets_its_own_samples) {
|
|||||||
PLOT_CHECK_NEAR(span.t, 4.0 / 9.0, 1e-12);
|
PLOT_CHECK_NEAR(span.t, 4.0 / 9.0, 1e-12);
|
||||||
f64 y = 0.0;
|
f64 y = 0.0;
|
||||||
PLOT_CHECK(scene.plot.crosshair_value(trace, 5.0, y));
|
PLOT_CHECK(scene.plot.crosshair_value(trace, 5.0, y));
|
||||||
PLOT_CHECK_NEAR(y, 10.0 + 10.0 * (4.0 / 9.0), 1e-9);
|
PLOT_CHECK_NEAR(y, 10.0, 1e-9); // sample 1, the nearer end of the gap
|
||||||
|
// And past the gap's midpoint it is the other sample, so the choice is `t`'s and not a
|
||||||
|
// fixed preference for one end.
|
||||||
|
PLOT_CHECK(scene.plot.crosshair_value(trace, 6.0, y));
|
||||||
|
PLOT_CHECK_NEAR(y, 20.0, 1e-9);
|
||||||
|
|
||||||
// On a sample, at both ends of the array's window, and outside it: the same
|
// On a sample, at both ends of the array's window, and outside it: the same
|
||||||
// closed-window rule a uniform series follows, on the array's own coordinates.
|
// closed-window rule a uniform series follows, on the array's own coordinates.
|
||||||
@@ -2584,3 +2739,53 @@ PLOT_TEST(xy_series_measurement_uses_the_true_nearest_samples) {
|
|||||||
PLOT_CHECK_NEAR(stats.min, 10.0, 1e-9);
|
PLOT_CHECK_NEAR(stats.min, 10.0, 1e-9);
|
||||||
PLOT_CHECK_NEAR(stats.max, 10.0, 1e-9);
|
PLOT_CHECK_NEAR(stats.max, 10.0, 1e-9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A host whose annotation is a readout -- the point where a cursor crosses a trace --
|
||||||
|
// moves it every frame. The id has to survive that, and a stale id must be ignored
|
||||||
|
// rather than read or written out of bounds, exactly as the cursors' setters behave.
|
||||||
|
// A readout cursor says which readout it belongs to with its colour; the setter keeps the
|
||||||
|
// id, and a stale one changes nothing.
|
||||||
|
PLOT_TEST(a_cursor_can_be_recoloured_by_id) {
|
||||||
|
plot::Plot p;
|
||||||
|
const u32 id = p.add_cursor(2.0, plot::Color::hex(0x111111));
|
||||||
|
PLOT_CHECK(p.cursor(id).color.b == plot::Color::hex(0x111111).b);
|
||||||
|
|
||||||
|
p.set_cursor_color(id, plot::Color::hex(0x9BE8A0));
|
||||||
|
PLOT_CHECK(p.cursor(id).color.g == plot::Color::hex(0x9BE8A0).g);
|
||||||
|
PLOT_CHECK(p.cursor_count() == 1u); // recoloured, not added
|
||||||
|
PLOT_CHECK_NEAR(p.cursor(id).x, 2.0, 1e-12);
|
||||||
|
|
||||||
|
p.set_cursor_color(42u, plot::Color::hex(0xFFFFFF));
|
||||||
|
PLOT_CHECK(p.cursor(id).color.g == plot::Color::hex(0x9BE8A0).g);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(an_annotation_can_be_moved_by_id) {
|
||||||
|
plot::Plot p;
|
||||||
|
const plot::Annotation pin{1.0, 2.0, "first", plot::Color::hex(0xE8E8E8), 0u, 0u, true};
|
||||||
|
const u32 id = p.add_annotation(pin);
|
||||||
|
PLOT_CHECK(p.annotation(id).x == 1.0);
|
||||||
|
PLOT_CHECK(p.annotation(id).text == std::string("first"));
|
||||||
|
|
||||||
|
plot::Annotation moved = pin;
|
||||||
|
moved.x = 3.5;
|
||||||
|
moved.y = -1.25;
|
||||||
|
moved.text = "moved";
|
||||||
|
p.set_annotation(id, moved);
|
||||||
|
PLOT_CHECK_NEAR(p.annotation(id).x, 3.5, 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(p.annotation(id).y, -1.25, 1e-12);
|
||||||
|
PLOT_CHECK(p.annotation(id).text == std::string("moved"));
|
||||||
|
PLOT_CHECK(p.annotation_count() == 1u); // moved, not added
|
||||||
|
|
||||||
|
// Parked rather than removed, like a cursor: the id stays valid and reads back hidden.
|
||||||
|
plot::Annotation parked = moved;
|
||||||
|
parked.visible = false;
|
||||||
|
p.set_annotation(id, parked);
|
||||||
|
PLOT_CHECK(!p.annotation(id).visible);
|
||||||
|
PLOT_CHECK(p.annotation_count() == 1u);
|
||||||
|
|
||||||
|
// A stale id changes nothing (and reads a default, as the accessors promise).
|
||||||
|
plot::Annotation other = pin;
|
||||||
|
other.x = 99.0;
|
||||||
|
p.set_annotation(42u, other);
|
||||||
|
PLOT_CHECK_NEAR(p.annotation(id).x, 3.5, 1e-12);
|
||||||
|
}
|
||||||
+1200
-24
File diff suppressed because it is too large.
Load diff
+60
-20
@@ -24,7 +24,7 @@ PLOT_TEST(array_series_bounds_and_gather) {
|
|||||||
PLOT_CHECK_NEAR(s.x_bounds().max, 11.5, 1e-9);
|
PLOT_CHECK_NEAR(s.x_bounds().max, 11.5, 1e-9);
|
||||||
PLOT_CHECK_NEAR(s.y_bounds().min, -2.0, 1e-6);
|
PLOT_CHECK_NEAR(s.y_bounds().min, -2.0, 1e-6);
|
||||||
std::vector<f32> out;
|
std::vector<f32> out;
|
||||||
s.gather(0, 0, 4, out);
|
s.gather(0, 0, 4, s.x0(), out);
|
||||||
PLOT_CHECK(out.size() == 8); // interleaved x,y
|
PLOT_CHECK(out.size() == 8); // interleaved x,y
|
||||||
// x is relative to x0(): sample 0 emits 0, not 10 (the relative-x contract).
|
// x is relative to x0(): sample 0 emits 0, not 10 (the relative-x contract).
|
||||||
PLOT_CHECK_NEAR(out[0], 0.0, 1e-6);
|
PLOT_CHECK_NEAR(out[0], 0.0, 1e-6);
|
||||||
@@ -104,7 +104,7 @@ PLOT_TEST(stream_series_exact_queries_survive_ring_wrap) {
|
|||||||
PLOT_CHECK_NEAR(s.sumsq(0, n), sumsq, 1e-1);
|
PLOT_CHECK_NEAR(s.sumsq(0, n), sumsq, 1e-1);
|
||||||
|
|
||||||
std::vector<f32> out;
|
std::vector<f32> out;
|
||||||
s.gather(0, 0, n, out); // level 0: raw samples
|
s.gather(0, 0, n, s.x0(), out); // level 0: raw samples
|
||||||
PLOT_CHECK(out.size() == 2 * n);
|
PLOT_CHECK(out.size() == 2 * n);
|
||||||
// x is relative to x0(): the oldest retained sample emits 0, the newest
|
// x is relative to x0(): the oldest retained sample emits 0, the newest
|
||||||
// (n - 1) * dx, never the absolute data coordinates (relative-x contract).
|
// (n - 1) * dx, never the absolute data coordinates (relative-x contract).
|
||||||
@@ -157,7 +157,7 @@ PLOT_TEST(stream_series_push_and_read_are_race_free) {
|
|||||||
(void)s.sum(0, n);
|
(void)s.sum(0, n);
|
||||||
(void)s.sumsq(0, n);
|
(void)s.sumsq(0, n);
|
||||||
std::vector<f32> out;
|
std::vector<f32> out;
|
||||||
s.gather(0, 0, n, out); // reads the oldest edge
|
s.gather(0, 0, n, s.x0(), out); // reads the oldest edge
|
||||||
if (out.size() != 2 * n) sizes_ok = false;
|
if (out.size() != 2 * n) sizes_ok = false;
|
||||||
}
|
}
|
||||||
++reads;
|
++reads;
|
||||||
@@ -258,7 +258,7 @@ PLOT_TEST(stream_series_sliding_window_queries_match_bruteforce) {
|
|||||||
// wholly at or after window index 0, `last` one past the last wholly inside
|
// wholly at or after window index 0, `last` one past the last wholly inside
|
||||||
// the window -- so every emitted bucket's samples lie inside the window.
|
// the window -- so every emitted bucket's samples lie inside the window.
|
||||||
std::vector<f32> out;
|
std::vector<f32> out;
|
||||||
s.gather(1, 0, n, out);
|
s.gather(1, 0, n, s.x0(), out);
|
||||||
const u64 first =
|
const u64 first =
|
||||||
std::max(base / span + (base % span == 0 ? u64{0} : u64{1}), lbase); // ceil, then clamp
|
std::max(base / span + (base % span == 0 ? u64{0} : u64{1}), lbase); // ceil, then clamp
|
||||||
const u64 last = std::min((base + n) / span, lbase + lsize);
|
const u64 last = std::min((base + n) / span, lbase + lsize);
|
||||||
@@ -374,7 +374,7 @@ PLOT_TEST(gather_at_a_lod_level_reaches_both_ends_of_the_window) {
|
|||||||
|
|
||||||
const auto check_ends = [&](u32 level) {
|
const auto check_ends = [&](u32 level) {
|
||||||
std::vector<f32> out;
|
std::vector<f32> out;
|
||||||
s.gather(level, 0, n, out);
|
s.gather(level, 0, n, s.x0(), out);
|
||||||
PLOT_CHECK(out.size() >= 8u);
|
PLOT_CHECK(out.size() >= 8u);
|
||||||
// Oldest: the first emitted pair is the retained sample at index 0, not the
|
// Oldest: the first emitted pair is the retained sample at index 0, not the
|
||||||
// first whole bucket (which starts `lead` samples later). x is relative to
|
// first whole bucket (which starts `lead` samples later). x is relative to
|
||||||
@@ -403,7 +403,7 @@ PLOT_TEST(gather_at_a_lod_level_reaches_both_ends_of_the_window) {
|
|||||||
PLOT_CHECK((ny % 256u) != 0u); // 5000 = 19 * 256 + 136 at level 2
|
PLOT_CHECK((ny % 256u) != 0u); // 5000 = 19 * 256 + 136 at level 2
|
||||||
plot::data::ArraySeries a(y, 0.0, 1e-6);
|
plot::data::ArraySeries a(y, 0.0, 1e-6);
|
||||||
std::vector<f32> out;
|
std::vector<f32> out;
|
||||||
a.gather(2, 0, ny, out);
|
a.gather(2, 0, ny, a.x0(), out);
|
||||||
PLOT_CHECK(out.size() >= 8u);
|
PLOT_CHECK(out.size() >= 8u);
|
||||||
PLOT_CHECK_NEAR(out[0], 0.0, 1e-9); // level 2 starts on the grid
|
PLOT_CHECK_NEAR(out[0], 0.0, 1e-9); // level 2 starts on the grid
|
||||||
PLOT_CHECK_NEAR(out[out.size() - 2], f32(f64(ny - 1) * 1e-6), 1e-9);
|
PLOT_CHECK_NEAR(out[out.size() - 2], f32(f64(ny - 1) * 1e-6), 1e-9);
|
||||||
@@ -469,7 +469,7 @@ PLOT_TEST(gather_envelope_stays_inside_an_unaligned_range) {
|
|||||||
|
|
||||||
const auto check_range = [&](u32 level) {
|
const auto check_range = [&](u32 level) {
|
||||||
std::vector<f32> out;
|
std::vector<f32> out;
|
||||||
s.gather(level, b, e, out);
|
s.gather(level, b, e, s.x0(), out);
|
||||||
PLOT_CHECK(out.size() >= 4u);
|
PLOT_CHECK(out.size() >= 4u);
|
||||||
for (plot::usize i = 0; i + 1 < out.size(); i += 2) {
|
for (plot::usize i = 0; i + 1 < out.size(); i += 2) {
|
||||||
PLOT_CHECK(f64(out[i]) >= f64(b)); // emitted x inside [begin, end)
|
PLOT_CHECK(f64(out[i]) >= f64(b)); // emitted x inside [begin, end)
|
||||||
@@ -499,7 +499,7 @@ PLOT_TEST(gather_envelope_stays_inside_an_unaligned_range) {
|
|||||||
const u64 buckets = last - first;
|
const u64 buckets = last - first;
|
||||||
PLOT_CHECK(lead > b && last * span < e); // both edge spans really are partial
|
PLOT_CHECK(lead > b && last * span < e); // both edge spans really are partial
|
||||||
std::vector<f32> out;
|
std::vector<f32> out;
|
||||||
s.gather(level, b, e, out);
|
s.gather(level, b, e, s.x0(), out);
|
||||||
PLOT_CHECK(out.size() == 2 * ((lead - b) + 2 * buckets + (e - last * span)));
|
PLOT_CHECK(out.size() == 2 * ((lead - b) + 2 * buckets + (e - last * span)));
|
||||||
plot::usize o = 0;
|
plot::usize o = 0;
|
||||||
for (u64 i = b; i < lead; ++i, o += 2) { // the partial leading bucket, raw
|
for (u64 i = b; i < lead; ++i, o += 2) { // the partial leading bucket, raw
|
||||||
@@ -544,7 +544,7 @@ PLOT_TEST(stream_gather_envelope_stays_inside_an_unaligned_range) {
|
|||||||
const plot::Range exact = s.reduce(b, e);
|
const plot::Range exact = s.reduce(b, e);
|
||||||
PLOT_CHECK(exact.max < 1.0 && exact.min > -1.0);
|
PLOT_CHECK(exact.max < 1.0 && exact.min > -1.0);
|
||||||
std::vector<f32> out;
|
std::vector<f32> out;
|
||||||
s.gather(1, b, e, out);
|
s.gather(1, b, e, s.x0(), out);
|
||||||
PLOT_CHECK(out.size() >= 4u);
|
PLOT_CHECK(out.size() >= 4u);
|
||||||
for (plot::usize i = 0; i + 1 < out.size(); i += 2) {
|
for (plot::usize i = 0; i + 1 < out.size(); i += 2) {
|
||||||
PLOT_CHECK(f64(out[i]) >= f64(b)); // relative x inside [begin, end)
|
PLOT_CHECK(f64(out[i]) >= f64(b)); // relative x inside [begin, end)
|
||||||
@@ -614,11 +614,11 @@ PLOT_TEST(gather_level_beyond_the_pyramid_clamps) {
|
|||||||
for (u64 i = 0; i < y.size(); ++i) y[i] = f32(i % 13) - 6.0f;
|
for (u64 i = 0; i < y.size(); ++i) y[i] = f32(i % 13) - 6.0f;
|
||||||
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
|
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
|
||||||
std::vector<f32> deepest;
|
std::vector<f32> deepest;
|
||||||
s.gather(plot::data::kMaxPyramidLevel, 0, s.size(), deepest);
|
s.gather(plot::data::kMaxPyramidLevel, 0, s.size(), s.x0(), deepest);
|
||||||
PLOT_CHECK(!deepest.empty());
|
PLOT_CHECK(!deepest.empty());
|
||||||
for (const u32 level : {u32{58}, u32{200}, u32{1000}, ~u32{0}}) {
|
for (const u32 level : {u32{58}, u32{200}, u32{1000}, ~u32{0}}) {
|
||||||
std::vector<f32> out;
|
std::vector<f32> out;
|
||||||
s.gather(level, 0, s.size(), out);
|
s.gather(level, 0, s.size(), s.x0(), out);
|
||||||
PLOT_CHECK(out == deepest); // clamped to the same deepest geometry
|
PLOT_CHECK(out == deepest); // clamped to the same deepest geometry
|
||||||
}
|
}
|
||||||
// The cap is where the span shift still fits: level 57 spans exactly 2^63.
|
// The cap is where the span shift still fits: level 57 spans exactly 2^63.
|
||||||
@@ -657,9 +657,9 @@ PLOT_TEST(gather_modes_envelope_nearest_mean) {
|
|||||||
PLOT_CHECK(buckets == 16);
|
PLOT_CHECK(buckets == 16);
|
||||||
|
|
||||||
std::vector<f32> env, near, mean;
|
std::vector<f32> env, near, mean;
|
||||||
s.gather(level, 0, kN, env, plot::data::GatherMode::Envelope);
|
s.gather(level, 0, kN, s.x0(), env, plot::data::GatherMode::Envelope);
|
||||||
s.gather(level, 0, kN, near, plot::data::GatherMode::Nearest);
|
s.gather(level, 0, kN, s.x0(), near, plot::data::GatherMode::Nearest);
|
||||||
s.gather(level, 0, kN, mean, plot::data::GatherMode::Mean);
|
s.gather(level, 0, kN, s.x0(), mean, plot::data::GatherMode::Mean);
|
||||||
|
|
||||||
// Envelope: two points per bucket, min at the bucket's first sample, max at
|
// Envelope: two points per bucket, min at the bucket's first sample, max at
|
||||||
// its last (this sawtooth's per-bucket min is its first sample's value and
|
// its last (this sawtooth's per-bucket min is its first sample's value and
|
||||||
@@ -692,7 +692,7 @@ PLOT_TEST(gather_modes_envelope_nearest_mean) {
|
|||||||
|
|
||||||
// Level 0 and sub-bucket ranges are the raw samples for every mode.
|
// Level 0 and sub-bucket ranges are the raw samples for every mode.
|
||||||
std::vector<f32> raw;
|
std::vector<f32> raw;
|
||||||
s.gather(0, 5, 300, raw, plot::data::GatherMode::Mean);
|
s.gather(0, 5, 300, s.x0(), raw, plot::data::GatherMode::Mean);
|
||||||
PLOT_CHECK(raw.size() == 2 * (300 - 5));
|
PLOT_CHECK(raw.size() == 2 * (300 - 5));
|
||||||
PLOT_CHECK_NEAR(raw[0], 5.0, 1e-9);
|
PLOT_CHECK_NEAR(raw[0], 5.0, 1e-9);
|
||||||
}
|
}
|
||||||
@@ -712,17 +712,17 @@ PLOT_TEST(gather_skips_all_nan_buckets) {
|
|||||||
|
|
||||||
// first = 0, last = 256/128 = 2: buckets 0 and 1, no lead/trail raw spans.
|
// first = 0, last = 256/128 = 2: buckets 0 and 1, no lead/trail raw spans.
|
||||||
std::vector<f32> env, mean, near;
|
std::vector<f32> env, mean, near;
|
||||||
s.gather(1, 0, 256, env, plot::data::GatherMode::Envelope);
|
s.gather(1, 0, 256, s.x0(), env, plot::data::GatherMode::Envelope);
|
||||||
PLOT_CHECK(env.size() == 4); // bucket 0's two pairs only
|
PLOT_CHECK(env.size() == 4); // bucket 0's two pairs only
|
||||||
PLOT_CHECK_NEAR(env[0], 0.0, 1e-9);
|
PLOT_CHECK_NEAR(env[0], 0.0, 1e-9);
|
||||||
PLOT_CHECK_NEAR(env[1], 1.0, 1e-9);
|
PLOT_CHECK_NEAR(env[1], 1.0, 1e-9);
|
||||||
PLOT_CHECK_NEAR(env[2], 127.0, 1e-9);
|
PLOT_CHECK_NEAR(env[2], 127.0, 1e-9);
|
||||||
PLOT_CHECK_NEAR(env[3], 1.0, 1e-9);
|
PLOT_CHECK_NEAR(env[3], 1.0, 1e-9);
|
||||||
s.gather(1, 0, 256, mean, plot::data::GatherMode::Mean);
|
s.gather(1, 0, 256, s.x0(), mean, plot::data::GatherMode::Mean);
|
||||||
PLOT_CHECK(mean.size() == 2); // bucket 1 has no finite mean: skipped
|
PLOT_CHECK(mean.size() == 2); // bucket 1 has no finite mean: skipped
|
||||||
PLOT_CHECK_NEAR(mean[0], 64.0, 1e-9);
|
PLOT_CHECK_NEAR(mean[0], 64.0, 1e-9);
|
||||||
PLOT_CHECK_NEAR(mean[1], 1.0, 1e-9);
|
PLOT_CHECK_NEAR(mean[1], 1.0, 1e-9);
|
||||||
s.gather(1, 0, 256, near, plot::data::GatherMode::Nearest);
|
s.gather(1, 0, 256, s.x0(), near, plot::data::GatherMode::Nearest);
|
||||||
PLOT_CHECK(near.size() == 4); // every bucket still has a centre sample
|
PLOT_CHECK(near.size() == 4); // every bucket still has a centre sample
|
||||||
PLOT_CHECK_NEAR(near[0], 64.0, 1e-9);
|
PLOT_CHECK_NEAR(near[0], 64.0, 1e-9);
|
||||||
PLOT_CHECK_NEAR(near[1], 1.0, 1e-9);
|
PLOT_CHECK_NEAR(near[1], 1.0, 1e-9);
|
||||||
@@ -739,7 +739,7 @@ PLOT_TEST(stream_series_freeze_copies_the_last_acquire_window) {
|
|||||||
const u64 n = s.size();
|
const u64 n = s.size();
|
||||||
const plot::Range bounds = s.y_bounds();
|
const plot::Range bounds = s.y_bounds();
|
||||||
std::vector<f32> gathered;
|
std::vector<f32> gathered;
|
||||||
s.gather(1, 0, n, gathered);
|
s.gather(1, 0, n, s.x0(), gathered);
|
||||||
const plot::Range reduced = s.reduce(0, n);
|
const plot::Range reduced = s.reduce(0, n);
|
||||||
|
|
||||||
// Freeze now, while the ring still holds exactly the published window.
|
// Freeze now, while the ring still holds exactly the published window.
|
||||||
@@ -754,7 +754,7 @@ PLOT_TEST(stream_series_freeze_copies_the_last_acquire_window) {
|
|||||||
PLOT_CHECK_NEAR(fr.max, reduced.max, 1e-6);
|
PLOT_CHECK_NEAR(fr.max, reduced.max, 1e-6);
|
||||||
PLOT_CHECK_NEAR(frozen.sum(0, n), s.sum(0, n), 1e-3);
|
PLOT_CHECK_NEAR(frozen.sum(0, n), s.sum(0, n), 1e-3);
|
||||||
std::vector<f32> frozen_gathered;
|
std::vector<f32> frozen_gathered;
|
||||||
frozen.gather(1, 0, n, frozen_gathered);
|
frozen.gather(1, 0, n, frozen.x0(), frozen_gathered);
|
||||||
PLOT_CHECK(frozen_gathered == gathered); // same geometry, byte for byte
|
PLOT_CHECK(frozen_gathered == gathered); // same geometry, byte for byte
|
||||||
|
|
||||||
// The producer now laps the ring (700 more pushes overwrite the oldest
|
// The producer now laps the ring (700 more pushes overwrite the oldest
|
||||||
@@ -769,6 +769,46 @@ PLOT_TEST(stream_series_freeze_copies_the_last_acquire_window) {
|
|||||||
PLOT_CHECK(frozen.reduce(0, n).max == reduced.max);
|
PLOT_CHECK(frozen.reduce(0, n).max == reduced.max);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- The gather origin: f32 precision against the view, not the window -------
|
||||||
|
|
||||||
|
PLOT_TEST(a_gather_origin_at_the_view_keeps_a_deep_zoom_on_the_sample_it_quotes) {
|
||||||
|
// 20 s of retained data at 1 kS/s examined through a 10 us window -- the shape of
|
||||||
|
// a scope zoomed in on a long ring. gather() stages x as f32, so what the origin
|
||||||
|
// decides is the span that f32 has to resolve: the whole retained window (which
|
||||||
|
// is what `x0()`, the old implicit origin, names) or the part of it on screen. A
|
||||||
|
// readout quotes `x_at()` in exact f64, so the difference between the two is the
|
||||||
|
// distance between where a sample is *drawn* and the x reported for it.
|
||||||
|
constexpr u64 kN = 20001;
|
||||||
|
std::vector<f32> y(static_cast<plot::usize>(kN));
|
||||||
|
for (u64 i = 0; i < kN; ++i) y[static_cast<plot::usize>(i)] = f32(i % 71);
|
||||||
|
const plot::data::ArraySeries s(std::move(y), /*x0=*/0.0, /*dx=*/1e-3);
|
||||||
|
PLOT_CHECK_NEAR(s.x_bounds().max, 20.0, 1e-9);
|
||||||
|
|
||||||
|
const f64 centre = s.x_at(kN - 2u); // 19.999 s, near the far end
|
||||||
|
const plot::Range view{centre - 5e-6, centre + 5e-6}; // 10 us across
|
||||||
|
const f64 px_per_x = 700.0 / (view.max - view.min); // a typical axis width
|
||||||
|
|
||||||
|
const auto worst_error_px = [&](f64 origin) {
|
||||||
|
std::vector<f32> out;
|
||||||
|
s.gather(0, kN - 3u, kN, origin, out);
|
||||||
|
PLOT_CHECK(out.size() == 6u);
|
||||||
|
f64 worst = 0.0;
|
||||||
|
for (u64 k = 0; k < 3u; ++k) {
|
||||||
|
// Exactly the renderer's reconstruction: the origin added back in f64.
|
||||||
|
const f64 drawn = origin + f64(out[static_cast<plot::usize>(2u * k)]);
|
||||||
|
worst = std::max(worst, std::abs(drawn - s.x_at(kN - 3u + k)) * px_per_x);
|
||||||
|
}
|
||||||
|
return worst;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The visible range's minimum: the f32 resolves 2^-24 of what is on screen, so
|
||||||
|
// the error is a fixed fraction of a pixel at every zoom.
|
||||||
|
PLOT_CHECK(worst_error_px(view.min) < 0.05);
|
||||||
|
// The start of the retained window -- what every caller used to get -- resolves
|
||||||
|
// 2^-24 of 20 s instead, which at this zoom is most of the axis.
|
||||||
|
PLOT_CHECK(worst_error_px(s.x0()) > 10.0);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Incremental append: ArraySeries::push() ----------------------------------
|
// --- Incremental append: ArraySeries::push() ----------------------------------
|
||||||
|
|
||||||
PLOT_TEST(array_series_push_matches_commit) {
|
PLOT_TEST(array_series_push_matches_commit) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#include "harness.hpp"
|
#include "harness.hpp"
|
||||||
|
#include <plot/plot.hpp>
|
||||||
#include <plot/plot/ticks.hpp>
|
#include <plot/plot/ticks.hpp>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
@@ -158,6 +159,52 @@ PLOT_TEST(epoch_window_yields_labelled_majors) {
|
|||||||
PLOT_CHECK(deep_majors >= 9);
|
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) {
|
PLOT_TEST(si_labels_are_utf8_and_never_overrun) {
|
||||||
char b[32];
|
char b[32];
|
||||||
plot::format_si(0.0000015, b, sizeof b);
|
plot::format_si(0.0000015, b, sizeof b);
|
||||||
@@ -189,6 +236,11 @@ PLOT_TEST(si_labels_are_utf8_and_never_overrun) {
|
|||||||
PLOT_TEST(format_time_shapes) {
|
PLOT_TEST(format_time_shapes) {
|
||||||
char b[32];
|
char b[32];
|
||||||
plot::format_time(0.5, b, sizeof b); PLOT_CHECK(std::string(b) == "500ms");
|
plot::format_time(0.5, b, sizeof b); PLOT_CHECK(std::string(b) == "500ms");
|
||||||
|
plot::format_time(0.005, b, sizeof b); PLOT_CHECK(std::string(b) == "5ms");
|
||||||
|
plot::format_time(0.0005, b, sizeof b); PLOT_CHECK(std::string(b) == "500\u00b5s");
|
||||||
|
plot::format_time(5.0e-6, b, sizeof b); PLOT_CHECK(std::string(b) == "5\u00b5s");
|
||||||
|
plot::format_time(5.0e-9, b, sizeof b); PLOT_CHECK(std::string(b) == "5ns");
|
||||||
|
plot::format_time(5.0e-12, b, sizeof b); PLOT_CHECK(std::string(b) == "5ps");
|
||||||
plot::format_time(1.5, b, sizeof b); PLOT_CHECK(std::string(b) == "1.5s");
|
plot::format_time(1.5, b, sizeof b); PLOT_CHECK(std::string(b) == "1.5s");
|
||||||
plot::format_time(90.0, b, sizeof b); PLOT_CHECK(std::string(b) == "1:30");
|
plot::format_time(90.0, b, sizeof b); PLOT_CHECK(std::string(b) == "1:30");
|
||||||
plot::format_time(3661.0, b, sizeof b); PLOT_CHECK(std::string(b) == "1:01:01");
|
plot::format_time(3661.0, b, sizeof b); PLOT_CHECK(std::string(b) == "1:01:01");
|
||||||
@@ -211,6 +263,18 @@ PLOT_TEST(format_time_never_lets_a_field_reach_its_radix) {
|
|||||||
plot::format_time(86399.5, b, sizeof b); PLOT_CHECK(std::string(b) == "1d");
|
plot::format_time(86399.5, b, sizeof b); PLOT_CHECK(std::string(b) == "1d");
|
||||||
plot::format_time(86400.0, b, sizeof b); PLOT_CHECK(std::string(b) == "1d");
|
plot::format_time(86400.0, b, sizeof b); PLOT_CHECK(std::string(b) == "1d");
|
||||||
|
|
||||||
|
// A mantissa may not reach its own radix either: three significant figures of a value a
|
||||||
|
// hair under a rung's top round up to 1000, and `%.3g` writes that `1e+03`. A one-second
|
||||||
|
// tick computed as 0.9999999 s read `1e+03ms` on a live axis, which is where this was seen.
|
||||||
|
plot::format_time(0.9999999, b, sizeof b); PLOT_CHECK(std::string(b) == "1s");
|
||||||
|
plot::format_time(0.9995, b, sizeof b); PLOT_CHECK(std::string(b) == "1s");
|
||||||
|
plot::format_time(0.9994, b, sizeof b); PLOT_CHECK(std::string(b) == "999ms");
|
||||||
|
plot::format_time(0.9999999e-3, b, sizeof b); PLOT_CHECK(std::string(b) == "1ms");
|
||||||
|
plot::format_time(0.9994e-3, b, sizeof b); PLOT_CHECK(std::string(b) == "999\u00b5s");
|
||||||
|
plot::format_time(0.9999999e-6, b, sizeof b); PLOT_CHECK(std::string(b) == "1\u00b5s");
|
||||||
|
plot::format_time(0.9999999e-9, b, sizeof b); PLOT_CHECK(std::string(b) == "1ns");
|
||||||
|
plot::format_time(-0.9999999, b, sizeof b); PLOT_CHECK(std::string(b) == "-1s");
|
||||||
|
|
||||||
// Sweep the boundary neighbourhoods and check every emitted field.
|
// Sweep the boundary neighbourhoods and check every emitted field.
|
||||||
const double values[] = {0.4, 0.9995, 1.0, 59.4, 59.4999, 59.5, 59.9, 60.0, 90.0, 119.5, 3599.4,
|
const double values[] = {0.4, 0.9995, 1.0, 59.4, 59.4999, 59.5, 59.9, 60.0, 90.0, 119.5, 3599.4,
|
||||||
3599.5, 3599.9, 86399.4, 86399.5, 86399.9, 86400.0, 172800.0, -119.5,
|
3599.5, 3599.9, 86399.4, 86399.5, 86399.9, 86400.0, 172800.0, -119.5,
|
||||||
@@ -222,3 +286,79 @@ PLOT_TEST(format_time_never_lets_a_field_reach_its_radix) {
|
|||||||
check_no_field_reaches_its_radix(label);
|
check_no_field_reaches_its_radix(label);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An axis origin shifts what the labels *say* and which grid the ticks sit on: a window
|
||||||
|
// zoomed into 12.345 s must read `0, 0.5ms, 1ms` against its own left edge, because three
|
||||||
|
// significant digits cannot tell 12.345671 s from 12.345672 s. A host prints the origin once
|
||||||
|
// beside the axis and the user adds.
|
||||||
|
PLOT_TEST(ticks_are_written_relative_to_an_axis_origin) {
|
||||||
|
// The same window twice: absolute, and relative to its own start.
|
||||||
|
const plot::Range window{12.345, 12.346};
|
||||||
|
std::vector<plot::Tick> absolute;
|
||||||
|
plot::generate_ticks(window, 400.0f, 80.0f, absolute, /*include_minors=*/false, 0.0);
|
||||||
|
PLOT_CHECK(!absolute.empty());
|
||||||
|
PLOT_CHECK(absolute.front().value >= window.min);
|
||||||
|
|
||||||
|
std::vector<plot::Tick> relative;
|
||||||
|
plot::generate_ticks(window, 400.0f, 80.0f, relative, /*include_minors=*/false, window.min);
|
||||||
|
PLOT_CHECK(!relative.empty());
|
||||||
|
// The walk starts on the origin's grid: the first major is the origin itself, and every
|
||||||
|
// major is an exact multiple of the step away from it.
|
||||||
|
PLOT_CHECK_NEAR(relative.front().value, window.min, 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(relative[1].value - relative[0].value,
|
||||||
|
(absolute[1].value - absolute[0].value), 1e-12);
|
||||||
|
for (const plot::Tick& t : relative) {
|
||||||
|
PLOT_CHECK(t.value >= window.min - 1e-12);
|
||||||
|
PLOT_CHECK(t.value <= window.max + 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
// And through an axis: the *labels* are the relative values, the positions the absolute
|
||||||
|
// ones. That is the whole contract -- `format_value()` is where the origin is applied.
|
||||||
|
plot::Plot p;
|
||||||
|
p.set_viewport({0, 0, 800, 400});
|
||||||
|
const u32 ax = p.add_x_axis({"time", window, false, plot::AxisPlace::Bottom, false,
|
||||||
|
plot::TickFormat::Time});
|
||||||
|
p.x_axis(ax);
|
||||||
|
PLOT_CHECK_NEAR(p.x_axis_origin(ax), 0.0, 1e-12);
|
||||||
|
p.set_x_axis_origin(ax, window.min);
|
||||||
|
PLOT_CHECK_NEAR(p.x_axis_origin(ax), window.min, 1e-12);
|
||||||
|
char buf[64];
|
||||||
|
p.x_axis(ax).format_value(window.min, buf, sizeof buf);
|
||||||
|
PLOT_CHECK(std::string(buf) == "0"); // the window's start reads zero
|
||||||
|
p.x_axis(ax).format_value(window.min + 0.0005, buf, sizeof buf);
|
||||||
|
PLOT_CHECK(std::string(buf) == "500\u00b5s"); // half a millisecond reads in microseconds
|
||||||
|
// An id that names no axis is ignored; the axis that exists is untouched by it.
|
||||||
|
p.set_x_axis_origin(7u, 1234.0);
|
||||||
|
PLOT_CHECK_NEAR(p.x_axis_origin(ax), window.min, 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The moving-window case, which is why the mode exists: the origin is re-taken from the range
|
||||||
|
// at refresh, so the tick on the left edge reads exactly zero however far the window has
|
||||||
|
// scrolled since the host last looked. A host cannot predict the edge (the follow places it
|
||||||
|
// from the newest sample), and a value it wrote a frame earlier is a frame of scroll stale --
|
||||||
|
// at 1 MS/s that is more than a millisecond-scale window shows.
|
||||||
|
PLOT_TEST(a_range_origin_puts_the_zero_tick_on_the_left_edge) {
|
||||||
|
plot::Plot p;
|
||||||
|
p.set_viewport({0, 0, 800, 400});
|
||||||
|
const plot::Range window{12.345, 12.347};
|
||||||
|
const u32 ax = p.add_x_axis({"time", window, false, plot::AxisPlace::Bottom, false,
|
||||||
|
plot::TickFormat::Time});
|
||||||
|
p.set_x_axis_origin_from_range(ax, true);
|
||||||
|
|
||||||
|
// `layout()` is the library's own path to the ticks (it ends in `refresh_ticks()`), so this
|
||||||
|
// is the order a frame really runs in.
|
||||||
|
p.layout();
|
||||||
|
PLOT_CHECK(!p.x_axis(ax).ticks.empty());
|
||||||
|
const plot::Tick& first = p.x_axis(ax).ticks.front();
|
||||||
|
PLOT_CHECK(first.major);
|
||||||
|
PLOT_CHECK_NEAR(f64(first.value), window.min, 1e-12); // exactly the left edge ...
|
||||||
|
PLOT_CHECK(first.label == "0"); // ... and it reads zero
|
||||||
|
|
||||||
|
// The window slides: the origin follows it, so the zero tick is on the *new* edge.
|
||||||
|
const plot::Range moved{12.6, 12.602};
|
||||||
|
p.set_x_range(ax, moved);
|
||||||
|
p.layout();
|
||||||
|
PLOT_CHECK_NEAR(f64(p.x_axis(ax).ticks.front().value), moved.min, 1e-12);
|
||||||
|
PLOT_CHECK(p.x_axis(ax).ticks.front().label == "0");
|
||||||
|
PLOT_CHECK_NEAR(p.x_axis_origin(ax), moved.min, 1e-12); // and the host reads it back
|
||||||
|
}
|
||||||
@@ -52,7 +52,8 @@ PLOT_TEST(xy_series_reports_its_own_grid) {
|
|||||||
PLOT_CHECK_NEAR(s.y_bounds().max, 48.0, 1e-6);
|
PLOT_CHECK_NEAR(s.y_bounds().max, 48.0, 1e-6);
|
||||||
|
|
||||||
// Epoch-style offset: the same shape shifted by 1.7e9 keeps exact bounds, and
|
// Epoch-style offset: the same shape shifted by 1.7e9 keeps exact bounds, and
|
||||||
// gather() emits x relative to x0() -- small numbers, not quantised epochs.
|
// gather() emits x relative to the origin the caller names -- x0() here, so
|
||||||
|
// small numbers, not quantised epochs.
|
||||||
std::vector<f64> epoch = spread_x(500);
|
std::vector<f64> epoch = spread_x(500);
|
||||||
const std::vector<f64> es = epoch; // un-moved copy for the expectations
|
const std::vector<f64> es = epoch; // un-moved copy for the expectations
|
||||||
for (auto& v : epoch) v += 1.7e9;
|
for (auto& v : epoch) v += 1.7e9;
|
||||||
@@ -60,7 +61,7 @@ PLOT_TEST(xy_series_reports_its_own_grid) {
|
|||||||
PLOT_CHECK_NEAR(e.x_bounds().min, 1.7e9, 1e-3);
|
PLOT_CHECK_NEAR(e.x_bounds().min, 1.7e9, 1e-3);
|
||||||
PLOT_CHECK_NEAR(e.x_bounds().max, 1.7e9 + (xs.back() - xs.front()), 1e-3);
|
PLOT_CHECK_NEAR(e.x_bounds().max, 1.7e9 + (xs.back() - xs.front()), 1e-3);
|
||||||
std::vector<f32> out;
|
std::vector<f32> out;
|
||||||
e.gather(0, 0, 500, out);
|
e.gather(0, 0, 500, e.x0(), out);
|
||||||
PLOT_CHECK(out.size() == 1000);
|
PLOT_CHECK(out.size() == 1000);
|
||||||
// x is relative to x0(): x_at(i) - x0(), the array's own offsets -- small
|
// x is relative to x0(): x_at(i) - x0(), the array's own offsets -- small
|
||||||
// values even though the absolute epoch is 1.7e9. The expectations come from
|
// values even though the absolute epoch is 1.7e9. The expectations come from
|
||||||
@@ -72,9 +73,9 @@ PLOT_TEST(xy_series_reports_its_own_grid) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
PLOT_TEST(xy_series_neighbour_lookups_crosshair_style) {
|
PLOT_TEST(xy_series_neighbour_lookups_crosshair_style) {
|
||||||
// The crosshair asks neighbours() at an arbitrary x and interpolates between
|
// The crosshair asks neighbours() at an arbitrary x and reports whichever of
|
||||||
// the two samples it gets back. On a non-uniform grid that needs real
|
// the two samples it gets back is nearer. On a non-uniform grid that needs
|
||||||
// searches: a uniform-grid formula would land megabytes off here.
|
// real searches: a uniform-grid formula would land megabytes off here.
|
||||||
auto x = spread_x(1000);
|
auto x = spread_x(1000);
|
||||||
const std::vector<f64> xs = x;
|
const std::vector<f64> xs = x;
|
||||||
plot::data::XYSeries s(std::move(x), saw_y(1000));
|
plot::data::XYSeries s(std::move(x), saw_y(1000));
|
||||||
@@ -186,7 +187,7 @@ PLOT_TEST(xy_series_gather_envelope_matches_exact_reduces) {
|
|||||||
const u64 span = u64{kBucket} << level;
|
const u64 span = u64{kBucket} << level;
|
||||||
const u64 b = 500 + 33, e = 3500 - 57; // unaligned on the bucket grid
|
const u64 b = 500 + 33, e = 3500 - 57; // unaligned on the bucket grid
|
||||||
std::vector<f32> out;
|
std::vector<f32> out;
|
||||||
s.gather(level, b, e, out, plot::data::GatherMode::Envelope);
|
s.gather(level, b, e, s.x0(), out, plot::data::GatherMode::Envelope);
|
||||||
PLOT_CHECK(out.size() >= 8u);
|
PLOT_CHECK(out.size() >= 8u);
|
||||||
// Emitted x stays inside [x_at(begin), x_at(end)) and the geometry reaches
|
// Emitted x stays inside [x_at(begin), x_at(end)) and the geometry reaches
|
||||||
// both the range's extremes (the lead/trail raw spans carry them).
|
// both the range's extremes (the lead/trail raw spans carry them).
|
||||||
|
|||||||
Reference in new issue
Block a user