Compare commits

...
5 Commits
Author SHA1 Message Date
Martino FerrariandClaude Opus 4.6 cc908efa36 feat(render): a reduced column can be graded by dwell, not drawn as one flat band
CI / debug (tests + all adapters) (push) Canceled after 0s
CI / coverage (floor + measured table) (push) Canceled after 0s
CI / asan (AddressSanitizer) (push) Canceled after 0s
A pixel column reduced from a dense envelope is honest about where the signal
went and silent about how long it was there: its min and max are identical
whether its samples fill the span or pile at its two ends, so a ramp and a pulse
are one band. Style::intensity_grading grades it instead -- bright where the
signal sits, faint where it only passes through -- by asking the gather for a
level finer than the pixel grid and reading the distribution from those
sub-buckets. Off by default: a change of look, not a correction of one.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 02:00:25 +02:00
Martino FerrariandClaude Opus 4.6 bee318870f feat(plot): a figure can keep an empty band to the right of its data
CI / debug (tests + all adapters) (push) Canceled after 0s
CI / coverage (floor + measured table) (push) Canceled after 0s
CI / asan (AddressSanitizer) (push) Canceled after 0s
`layout_.axes` reached the viewport's right edge, so the rightmost x tick
label -- centred on its tick -- had half of itself outside the plot, and
the newest sample's ink landed on the frame. `Plot::set_right_gutter()`
reserves a band there, out of the same `1 - kMinAxesShare` allowance the
label and title strips are served from, so it can no more squeeze `axes`
below the half-viewport floor than they can. `Figure` sets one value on
every row, the left gutter's reason: the rows' axes have to end on a
common column as well as start on one.

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

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

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

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

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

Guarded by `a_gather_origin_at_the_view_keeps_a_deep_zoom_on_the_sample_it_quotes`,
which measures the error in pixels both ways: under 0.05 px with the view's
minimum, over 10 px with `x0()`.
2026-10-05 18:43:50 +02:00
24 changed files with 1183 additions and 103 deletions

No files matched your search

+12
View File
@@ -37,6 +37,18 @@ run touched a GPU, so read it as an upper bound and a record of the software pat
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`,
+110
View File
@@ -166,6 +166,116 @@ series therefore stages O(visible pixels) vertices, not O(samples), and `FrameSt
which path each trace took. Below the threshold the raw samples are drawn instead; both
sides go through the same instanced anti-aliased quad pipeline.
## Dense traces are reduced onto the pixel grid
A gathered window denser than the axis is **not** staged as a polyline. For each integer
column of `layout.axes`, `Renderer::draw_traces` emits one exactly vertical segment from the
lowest to the highest screen y any gathered point put in that column, flushed in framebuffer
pixels with `u_snap` on. `FrameStats::column_traces` counts the traces that took this path.
It exists because a polyline over such a window **moirés**, and three things compound to make
it. `MinMaxPyramid::level_for()` picks the level whose buckets hold about
`samples_per_pixel` samples, so a bucket spans **one to two pixel columns**;
`GatherMode::Envelope` emits two points per bucket, so each bucket contributes a near-vertical
min→max hairline *and* a near-vertical connector to the next — about 3.6 near-vertical
hairlines per column; and each of those lands at a **fractional** x, so with ordinary
src-alpha compositing a column's brightness is a non-linear function of the sub-pixel phases
overlapping it. The phase pattern repeats at the beat between the bucket pitch and the pixel
pitch, which is a visible banding, and panning sweeps the beat so it crawls. Reducing to one
segment per column leaves the trace **one** sub-pixel phase, so there is nothing left to beat,
and the snap then puts that one phase on the grid.
Four properties of the reduction are load-bearing:
- **It is gated on the gathered *density*, not on the level.** It runs when the window is
`GatherMode::Envelope` and carries at least two gathered points per column of the axes —
the raw path at 1.5–64 samples per pixel has the same beat and the same cure. The density
term is also what keeps it away from a *sparse* window, whose trace is drawn by the two
segments that enter and leave the frame (`a_sparse_window_between_samples_still_inks_both_margins`);
`Nearest` and `Mean` are excluded because they are one-point-per-bucket modes a band defeats.
- **A hole stays a hole.** A column no gathered point landed in emits nothing. Bridging it
would draw a vertical line over x the series says nothing about.
- **Disjoint columns are joined.** Two neighbouring columns' extremes need not overlap — a
fast edge rises through a whole column — and two disjoint vertical segments read as a broken
trace, which is what the polyline's connector prevented. The later column is extended to
meet the earlier one's nearest bound, so the join is made inside the column that moved.
- **A span shorter than the stroke is drawn *as* the stroke.** The line fragment shader ramps
on the distance to the segment's axis and does not cap its ends, so a vertical segment
shorter than its own width rasterises a sliver of itself, and a flat trace's column, where
the two bounds are equal, nothing at all.
This is the **one carve-out** in `u_snap`'s rule that chrome may be snapped and data may not
(see `LineProgram::set_snap()`): a column *is* a pixel column and its x is already a screen
coordinate, so there is no data position to move it off, and the y bounds the segment reports
are untouched by the snap.
`a_dense_trace_is_drawn_as_whole_pixel_columns` is the guard, and what it measures is the
**number of distinct coverage levels** in the ink: 4 with the reduction against 126 with the
polyline, over one sine period across 60 000 samples. A count of *partially covered pixels* is
**not** the reading and was tried first — a 1.5 px stroke with a 1 px anti-aliasing fringe
always spills into its neighbour column, so the two renderings measure 577 and 652, which
separates nothing. The frame's whole geometry collapses with it: 627 segments against 60 054.
## A reduced column can be graded by dwell
`Style::intensity_grading` draws each of those columns **graded** rather than as one flat band:
bright where the signal sits, faint where it only passes through, the way a digital-phosphor
scope reads. It is off by default — a change of look, not a correction of one — and
`FrameStats::graded_traces` counts the traces that took it, a subset of `column_traces`, since
grading is a way of drawing the reduction and not a third path.
What it puts back is the information the reduction throws away: a column's min and max are the
**same** whether its samples fill the span evenly or pile at its two ends, so a ramp and a pulse
are one band. The distribution cannot be recovered from the band, which is why the gather is
asked at a level **finer than the pixel grid** (`intensity_sub_columns` sub-buckets per column)
and the dwell read from those sub-buckets.
Five things carry it, and each was arrived at by being wrong first:
- **The measure is dwell per row, each segment depositing one unit spread over the rows it
spans** (`1 / height`). Counting +1 per row instead grades a sine's fast middle as brightly as
its rails, which is the pattern inverted — the middle is where the signal spends the *least*
time.
- **The accumulator is one column wide.** Gathered x is monotonic, so points arrive column by
column: accumulate, read, clear. A full `axes.w × axes.h` density image is 33 MB at 4K; one
column of `axes.h` floats is L1-resident. A span is written as a `+1/height` delta at its first
row and the negation one past its last, so it costs two writes whatever its height, and the
running sum over the column is the per-row dwell.
- **Normalisation is per column, against the column's own peak.** A frame-wide scale needs the
whole image to exist before anything can be drawn, *and* reads worse: a column the signal flew
through uniformly comes out faint at every row, so the trace fades out exactly where it moves
fastest. The cost of the local scale is one extra non-clearing walk of the accumulator to find
the peak.
- **Alpha is quantised into `intensity_steps` tiers, one `LineBatch` and one draw call each.**
The line pipeline's colour is a **uniform**, not per-instance (`LineInstance` is four floats of
geometry; only `TextInstance` carries `a_colour`), so a per-segment alpha is inexpressible and
the tiers are what make the gradient drawable at all. `intensity_floor` keeps the faintest tier
visible — a fast riser is one sub-bucket deep everywhere and must not be dimmed out of
existence — and `intensity_gamma` lifts the faint end. A connector between two disjoint columns
is emitted at the **faintest** tier: it is inferred geometry, not a place the signal was
measured to dwell.
- **Grading may not undo the reduction.** `level_for()` answers 0 — raw samples — as soon as the
asked-for fraction of a column falls under one bucket (`kBucket` is 64), so the finer ask is
**floored at level 1** whenever the flat path would have used the pyramid. Without the floor
every moderately dense frame became an O(visible) gather: measured in udpscope at 1.3 MS/s over
a 2 s window, 172 samples a pixel, the flat path sat on the pyramid and grading dropped the
whole figure to raw (`raw=1154 lod=0` against `raw=710 lod=453`) and cost 2.3 → 6.4 ms of draw
mean. The floor degrades the *look* instead — fewer sub-columns than asked for, down to what the
coarsest buckets give, which at 64 samples a bucket is still two or three per column. That is
also why raising `intensity_sub_columns` past about 8 buys nothing: the bucket size is the
limit, not the number.
`a_dense_trace_can_be_graded_by_dwell` is the guard, and it draws the same frame both ways. It
profiles the mean peak coverage over the band's two outer fifths against its middle fifth: the
flat band is flat (**191 and 191**, one ink) and the graded one is **191 against 131**, a middle
at 0.69 of the edge. That ratio is a *tier* ratio and not the dwell ratio — the default gamma
0.45 and floor 0.3 compress it on purpose. The geometry stays bounded by the axis and not by its
area: **8303 segments over a 572 px axis**, 14.5 runs a column, against the flat path's 627 at
one end and `572 × 156` rows at the other. A fourth frame over a window wide enough that the
flat path is on the pyramid is what exercises the floor (`raw=0 lod=1 … seg=634
visible=200001`); the test's first window sits at 105 samples a pixel, where both paths are raw
and the floor is never reached.
## One sample or a statistic of many
Every number the library reports is one the samples actually hold. What differs between
+1 -1
View File
@@ -33,7 +33,7 @@ class ArraySeries final : public Series {
Range x_bounds() const override;
Range y_bounds() const override;
const MinMaxPyramid& pyramid() const override;
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode = GatherMode::Envelope) const override;
protected:
+17 -9
View File
@@ -59,12 +59,20 @@ class Series {
/// Appends interleaved (x, y) pairs for level `level`.
///
/// **x is relative to `x0()`**: the emitted value is `x_at(i) - x0()` as
/// f32, never the absolute coordinate. A consumer reconstructs the absolute
/// x in f64 (`x0() + emitted`) before mapping it to the screen; staging an
/// absolute epoch-time x as f32 would quantise it to a grid coarser than
/// the pixel (f32 carries 24 bits -- 1 s at 1.7e9), while the relative
/// value `i*dx` is small and exact. y is unchanged.
/// **x is relative to `x_origin`**: the emitted value is `x_at(i) - x_origin` as f32, never
/// the absolute coordinate. A consumer reconstructs the absolute x in f64
/// (`x_origin + emitted`) before mapping it to the screen; staging an absolute epoch-time x
/// as f32 would quantise it to a grid coarser than the pixel (f32 carries 24 bits -- 1 s at
/// 1.7e9). y is unchanged.
///
/// **The caller names the origin because only the caller knows the span the f32 has to be
/// exact against.** The origin was `x0()` -- the start of the series' *retained window* --
/// and a relative value's ULP is then a fraction of the whole window however little of it is
/// on screen: 1.9 us over 20 s of retained data, which at a view 10 us wide is more than a
/// hundred pixels between where a sample is *drawn* and the x a readout quotes for the same
/// sample (`x_at()`, exact f64). Passing the visible range's minimum instead makes the error
/// 2^-24 of the *visible span* -- a fixed 4e-5 of a pixel over a 700 px axis -- at every
/// zoom. A caller with no view in mind may pass `x0()` and get the old behaviour.
///
/// Level 0 emits the raw samples of [begin, end). Level >= 1 emits one
/// geometry element per *complete* level bucket wholly inside the retained
@@ -85,7 +93,7 @@ class Series {
/// level_for() picks), and never reaches outside the retained window -- nor
/// outside [begin, end). Pairs are appended to out_xy, which is never
/// cleared.
virtual void gather(u32 level, u64 begin, u64 end, 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;
/// Absolute x of sample `i` (`x0() + i*dx()` on a uniform-grid series).
@@ -121,7 +129,7 @@ class Series {
/// The shared body of the concrete gather() overrides: raw samples at level 0,
/// 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;
private:
@@ -143,7 +151,7 @@ class Series {
/// 0 is entirely this, and a level >= 1 gather uses it for the two partial edge
/// buckets a pyramid of complete buckets cannot answer for. Precondition:
/// begin < end, end <= size().
void gather_raw(u64 begin, u64 end, std::vector<f32>& out_xy) const;
void gather_raw(u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy) const;
};
} // namespace plot::data
+1 -1
View File
@@ -92,7 +92,7 @@ class StreamSeries final : public Series {
Range x_bounds() const override;
Range y_bounds() const override;
const MinMaxPyramid& pyramid() const override;
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode = GatherMode::Envelope) const override;
/// Always true: this series' `x_bounds()` is the retained ring window, which
/// slides with every `acquire()` -- the property `XFollow::Live` follows.
+2 -2
View File
@@ -53,8 +53,8 @@ class XYSeries final : public Series {
Range x_bounds() const override;
Range y_bounds() const override;
const MinMaxPyramid& pyramid() const override;
/// Same contract as `Series::gather`, with x emitted relative to `x0()`.
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
/// Same contract as `Series::gather`, with x emitted relative to `x_origin`.
void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode = GatherMode::Envelope) const override;
/// The sample's own x.
+11
View File
@@ -138,6 +138,16 @@ class Figure {
}
f32 min_y_gutter() const noexcept { return min_y_gutter_; }
/// The empty band every row keeps to the right of its `axes`, in device pixels (0.0
/// by default, so `axes` reaches the viewport's right edge). It is set here rather
/// than row by row for the left gutter's reason: the rows' axes have to line up, and
/// they end on a common column only if they are told one width. See
/// `Plot::set_right_gutter()` for what it is for. Negative values are clamped to 0.
void set_right_gutter(f32 device_px) noexcept {
right_gutter_ = device_px > 0.0f ? device_px : 0.0f;
}
f32 right_gutter() const noexcept { return right_gutter_; }
/// Whether the shared x axis is the figure's: every row reserves no x label strip
/// and one shared axis is drawn under the last row (the default), or every row
/// draws its own labels and the figure draws nothing extra.
@@ -326,6 +336,7 @@ class Figure {
FigureLayout layout_{};
f32 row_gap_ = 0.0f;
f32 min_y_gutter_ = 0.0f;
f32 right_gutter_ = 0.0f;
bool labels_bottom_only_ = true;
};
+22
View File
@@ -88,6 +88,11 @@ struct PlotLayout {
/// no text backend there is no title to lay out at all and both stay 0.0f.
f32 y_title_width = 0.0f;
f32 x_title_height = 0.0f;
/// The empty band kept between `axes`' right edge and the viewport's, 0.0f unless a
/// caller asked for one (see `Plot::set_right_gutter()`). Reported because it is
/// clamped by the same cap as the strips above, so what was asked for and what was
/// reserved are not always the same number.
f32 right_gutter = 0.0f;
/// The measured legend: inside `axes`, never reserving any part of it, and empty
/// when there is nothing to draw. It is measured with the same text backend the
/// strips above are, and against the same `axes` rect the caller reads back, so
@@ -640,6 +645,20 @@ class Plot {
}
f32 min_y_gutter() const noexcept { return min_y_gutter_; }
/// An empty band reserved between `axes`' right edge and the viewport's, in device
/// pixels. 0.0 -- the default -- is what the layout has always done: `axes` reaches
/// the viewport's right edge, which leaves the rightmost x tick label, *centred* on
/// that edge, half of it to be drawn in and nothing to draw it in -- and puts the
/// newest sample's ink on the frame. It is the mirror of the left gutter in purpose
/// and not in kind: nothing is drawn here, so there is nothing to measure and the
/// value is the caller's to choose. Reserved inside the same cap as the label and
/// title strips, so it can no more squeeze `axes` below the half-viewport floor than
/// they can. Negative values are clamped to 0.
void set_right_gutter(f32 device_px) noexcept {
right_gutter_ = device_px > 0.0f ? device_px : 0.0f;
}
f32 right_gutter() const noexcept { return right_gutter_; }
/// Grid segments for the primary axis pair (x axis 0, y axis 0), in data space.
const Grid& grid() const noexcept { return grid_; }
@@ -867,6 +886,9 @@ class Plot {
/// The floor a figure imposes on the left gutter; see set_min_y_gutter(). 0.0 means
/// none, which is what a plot standing on its own keeps.
f32 min_y_gutter_ = 0.0f;
/// The empty band kept to the right of `axes`; see set_right_gutter(). 0.0 means none,
/// which is what the layout did before the setter existed.
f32 right_gutter_ = 0.0f;
/// Which handles a pointer is on, published by the input layer (see HotHandles).
HotHandles hot_{};
+63 -5
View File
@@ -29,6 +29,15 @@ namespace plot {
struct FrameStats {
u32 raw_traces = 0; ///< traces drawn from raw samples (level 0)
u32 lod_traces = 0; ///< traces drawn from a min/max pyramid level
/// Traces whose geometry was reduced onto the pixel grid: one vertical segment
/// per column, lit from that column's own extremes. Counts a trace from either
/// path above -- the reduction is about the *density* of the gathered points,
/// not about which level produced them.
u32 column_traces = 0;
/// Traces whose columns were graded by dwell rather than drawn as one flat band
/// (`Style::intensity_grading`). A subset of `column_traces`: grading is a way of
/// drawing the reduction, not a third path.
u32 graded_traces = 0;
u64 visible_samples = 0; ///< samples the gathered windows covered, summed
u32 staged_segments = 0; ///< line segments staged, all colour groups
u32 staged_markers = 0; ///< marker shapes staged over the traces
@@ -404,6 +413,13 @@ class Renderer {
/// axis-aligned line by up to half a pixel. Set on every flush, like `dash`.
void flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash = Vec2{},
bool snap = false) noexcept;
/// The same, for a batch other than the renderer's own `lines_`. The graded trace
/// pass stages one batch per alpha step, and a step has to be a draw call of its
/// own because the line pipeline takes its colour from a uniform -- so it needs to
/// flush a batch it chose rather than the member one. Every other caller flushes
/// `lines_` and uses the overload above.
void flush_lines(render::LineBatch& batch, const Mapping& map, Color color, f32 width,
Vec2 dash = Vec2{}, bool snap = false) noexcept;
/// Draws every staged glyph in `batch` -- each instance carries its own tint
/// (`a_colour`) -- and empties the batch. Text is texture-bound, so this runs
/// once per text batch, after the line geometry it belongs over.
@@ -445,11 +461,12 @@ class Renderer {
/// `origin_x` back in, in f64, before the single f32 narrowing (B1), so a
/// large-magnitude x -- an epoch axis -- never loses sub-pixel precision.
void stage_segment(f64 origin_x, f64 x0, f64 y0, f64 x1, f64 y1, bool log_x, bool log_y);
/// Stages `n` interleaved `x,y` points of a gathered window (see
/// `Series::gather`): x **relative to the series' `x0()`**, y absolute. The
/// absolute data x is reconstructed in f64 (`series_x0 + rel`) and staged as
/// Stages `n` interleaved `x,y` points of a gathered window (see `Series::gather`): x
/// **relative to `origin_x`**, which the caller asked the gather for, y absolute. On linear
/// axes that is already what the batch wants and the buffer is handed over as it is; a log
/// axis reconstructs the absolute x in f64 per segment and stages
/// `to_space(x_abs) - to_space(origin_x)` -- O(visible pixels), not O(epoch).
void stage_xy(const f32* xy, u32 n, f64 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
/// `batch`, shifting it into viewport space and tinting it `color` (the
/// per-instance `a_colour`). Returns the pen past the run.
@@ -499,6 +516,29 @@ class Renderer {
/// The data helpers take it too; they read the axes rect and the transforms it
/// pins.
void draw_traces(const Plot& plot, const PlotLayout& layout);
/// One gathered window reduced onto the pixel grid and staged in framebuffer
/// pixels: for each integer column of `layout.axes`, one exactly vertical
/// segment from the lowest to the highest screen y any gathered point put in
/// it. Returns how many columns were staged (0 stages nothing); the caller
/// flushes with `pixel_mapping()` and `snap = true`.
///
/// `gather_` is the input, read with the same origin `draw_traces` asked the
/// series for. See `draw_traces` for when this path is taken and why.
u32 stage_trace_columns(const Plot& plot, const PlotLayout& layout, const Trace& trace,
f64 x_origin, u32 points);
/// The same reduction, graded by **dwell**: a column is drawn as a stack of runs,
/// each at the alpha step its own share of the column's samples earns, so a flat
/// top is bright and the edge leading to it is faint (`Style::intensity_grading`).
///
/// Stages into `steps_`, one `LineBatch` per alpha step, which the caller flushes
/// in turn -- the line pipeline's colour is a uniform, so a step is a draw call.
/// Returns the segments staged across all steps (0 stages nothing).
///
/// `gather_` is the input and must have been asked for at the *finer* level
/// `Style::intensity_sub_columns` implies; a column's dwell is not recoverable
/// from its own min and max (see `Style::intensity_grading`).
u32 stage_trace_density(const Plot& plot, const PlotLayout& layout, const Trace& trace,
f64 x_origin, u32 points);
/// The cursors, both orientations: one stroke per cursor, placed at the coordinate its own
/// orientation owns (`Plot::cursor_screen_coordinate()`) and spanning the other axis' whole
/// range, in its own colour -- or in the accent at handle width while it is hovered or
@@ -610,7 +650,25 @@ class Renderer {
render::TextBatch glyphs_under_;
render::TextBatch glyphs_over_;
std::vector<f32> gather_; ///< scratch for one gathered window, re-used
std::vector<f32> staged_; ///< scratch for one staged window, re-used
/// One (lowest y, highest y) pair per pixel column of the axes, re-used:
/// `stage_trace_columns()`'s working set. Two floats per column, so an 8K-wide
/// axis is 64 kB -- bounded by the *screen*, where `gather_` is bounded by the
/// window.
std::vector<f32> columns_;
/// `stage_trace_density()`'s working set, and deliberately **one column wide**: the
/// gathered points arrive in x order, so a column can be accumulated, read and
/// cleared before the next one starts. A full-axes density image would be
/// `axes.w * axes.h` (33 MB at 4K); this is `axes.h` (a few kB) and stays in L1.
///
/// Each sub-bucket's span is written as a +1 at its first row and a -1 one past
/// its last, so accumulating a span costs two writes whatever its height; the
/// running sum over the column is then how many sub-buckets cover each row, which
/// is the dwell.
std::vector<f32> column_rows_;
/// One batch per alpha step of the grading (`Style::intensity_steps`), re-used
/// across frames: the line pipeline takes its colour from a uniform, so segments
/// at different alphas cannot share a draw call and must be staged apart.
std::vector<render::LineBatch> steps_;
mutable std::vector<u8> readback_; ///< dump_frame_ppm's row buffer
/// The host's `GL_COLOR_CLEAR_VALUE`, queried on the first `begin_frame()` (GL
+76
View File
@@ -177,6 +177,82 @@ struct Style {
///< sides draw through the one instanced
///< anti-aliased quad path.
// --- intensity grading: counts and ratios, never sizes -------------------
/// Draw a dense trace's pixel columns **graded by how long the signal dwells at
/// each height**, the way a digital-phosphor scope does, instead of as one flat
/// band per column: bright where samples pile up (a flat top, a peak), faint
/// where they only pass through (an edge, a noise excursion).
///
/// Off by default, because it is a change of *look* and not a correction: the
/// flat band is the honest answer to "what did this column reach", and a host
/// that wants that -- or that draws few enough samples never to take the column
/// path at all -- should not have its traces restyled by an upgrade. A scope-like
/// host turns it on.
///
/// It is not derivable from the flat band. A column's min and max are the same
/// whether its samples fill the span or sit piled at its two ends, which is
/// exactly the difference between a ramp and a pulse -- so grading needs the
/// gather asked at a *finer* level than the pixel grid (`intensity_sub_columns`)
/// and reads the distribution from those sub-buckets.
///
/// The measure is *dwell per row*, and the weighting is the whole of it: each
/// sub-column segment deposits **one unit spread over the rows it spans**, so a
/// tall crossing leaves a thin smear and a short one at a turning point leaves a
/// bright mark. Counting a row at +1 per segment that touches it instead -- the
/// obvious thing -- grades a sine's fast middle as brightly as its rails, which
/// is the pattern inverted.
bool intensity_grading = false;
/// Sub-buckets per pixel column the gather is asked for when grading: the level
/// is chosen for `samples_per_pixel / intensity_sub_columns`, so a column's
/// density is read from this many envelopes instead of from one.
///
/// It buys resolution in *dwell*, linearly, for the same factor in gathered
/// points -- and the points stay bounded by the screen, which is the property
/// that matters (8 of them over a 1000 px axis is 16 000 points whatever the
/// window holds). Below about 4 a pulse's flat levels and its edge are too few
/// samples apart to separate.
///
/// It is a *want*, not a promise: the renderer floors the level it asks for at the
/// pyramid's coarsest bucket level, so a window too sparse to hold this many buckets
/// a column is graded from however many it does hold. Without that floor `level_for`
/// answers 0 -- raw samples -- the moment a sub-column falls under one bucket, which
/// turns a frame the flat path draws off the pyramid into an O(visible) gather. So
/// raising this past about 8 costs resolution nothing and buys it nothing either: the
/// bucket size is the limit, not this number.
u32 intensity_sub_columns = 8;
/// Quantisation of the grading, in alpha steps. Each step is one draw call (the
/// line pipeline's colour is a uniform, so a per-segment alpha would mean a
/// per-instance one), and the segments are bounded by the columns, so the cost
/// of a step is a draw call over a few hundred instances.
///
/// Four is enough to read a waveform's structure and few enough that a step is a
/// visible tier rather than a gradient -- the point is to separate "dwelt here"
/// from "passed through", not to measure.
u32 intensity_steps = 4;
/// Alpha the faintest graded step is drawn at, as a fraction of the trace's own.
///
/// Not 0: a column the signal merely passed through is still a column the signal
/// *reached*, and a riser dimmed out of existence is a square wave drawn as two
/// disconnected lines. This is the floor that keeps the trace continuous.
f32 intensity_floor = 0.3f;
/// Exponent the normalised dwell is raised to before it is quantised. Below 1 it
/// lifts the faint end, which is what makes a fast edge visible beside a flat top
/// whose dwell is three orders of magnitude greater.
///
/// The dwell is normalised **against the column's own peak**, not against a scale
/// for the whole frame, so the steps spend their range on the structure *inside*
/// each column. One scale for the frame needs the whole `w * h` density image
/// (33 MB at 4K, which is why the accumulator is one column wide) and reads
/// worse: a column the signal flew straight through holds little dwell at any
/// height, so it would grade uniformly faint -- a trace that fades out exactly
/// where it moves fastest.
f32 intensity_gamma = 0.45f;
/// `logical` in device pixels: the single conversion point for every logical
/// metric above, so the arithmetic appears once per consumer instead of a `*
/// ui_scale` scattered over the renderer and the layout.
+2 -2
View File
@@ -75,9 +75,9 @@ Range ArraySeries::y_bounds() const { return y_bounds_; }
const MinMaxPyramid& ArraySeries::pyramid() const { return pyramid_; }
void ArraySeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
void ArraySeries::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
GatherMode mode) const {
gather_impl(level, begin, end, out_xy, mode);
gather_impl(level, begin, end, x_origin, out_xy, mode);
}
void ArraySeries::copy_raw(u64 begin, u64 end, f32* out) const {
+15 -11
View File
@@ -176,21 +176,24 @@ u64 Series::snap_index(f64 x) const {
return static_cast<u64>(k);
}
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();
// Sample i sits at `x0() + i*step`, so this is `x_at(i) - x_origin` with the subtraction done
// once in f64 rather than per sample: see gather() for why the caller owns the origin.
const f64 shift = x0() - x_origin;
f32 raw[kGatherChunk];
for (u64 i = begin; i < end;) {
const u64 take = std::min<u64>(kGatherChunk, end - i);
copy_raw(i, i + take, raw);
for (u64 k = 0; k < take; ++k) {
out_xy.push_back(f32(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]);
}
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 {
const u64 n = size();
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 (level == 0) {
gather_raw(b, e, out_xy);
gather_raw(b, e, x_origin, out_xy);
return;
}
// Public levels clamp to the deepest representable one (R3): past
@@ -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
// the requested range exactly, for every mode.
const f64 step = dx();
const f64 shift = x0() - x_origin; // as in gather_raw(): x_at(i) - x_origin = shift + i*step
const u64 span = u64{kBucket} << level;
const u64 origin = pyramid().window_base();
const u64 lbase = pyramid().level_base(level);
@@ -235,11 +239,11 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
if (first >= last) {
// No complete bucket of this level is inside the range -- a window squeezed
// into one bucket -- so the raw samples are the only exact geometry there is.
gather_raw(b, e, out_xy);
gather_raw(b, e, x_origin, out_xy);
return;
}
const u64 lead = first * span - origin; // window index the first emitted bucket starts at
if (b < lead) gather_raw(b, std::min(lead, e), out_xy);
if (b < lead) gather_raw(b, std::min(lead, e), x_origin, out_xy);
switch (mode) {
case GatherMode::Envelope: {
@@ -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 u64 first_sample = bi * span - origin; // >= 0: stored buckets start inside the window
const u64 last_sample = std::min((bi + 1) * span, origin + e) - 1 - origin;
out_xy.push_back(f32(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(f32(f64(last_sample) * step));
out_xy.push_back(f32(shift + f64(last_sample) * step));
out_xy.push_back(m.max);
}
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
f32 v = 0.0f;
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);
}
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);
if (count == 0) continue;
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)));
}
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
if (trail < e) gather_raw(trail, e, out_xy);
if (trail < e) gather_raw(trail, e, x_origin, out_xy);
}
} // namespace plot::data
+2 -2
View File
@@ -87,9 +87,9 @@ Range StreamSeries::y_bounds() const { return y_bounds_; }
const MinMaxPyramid& StreamSeries::pyramid() const { return pyramid_; }
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 {
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 {
+4 -4
View File
@@ -100,16 +100,16 @@ u64 XYSeries::snap_index(f64 x) const {
: 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 {
const u64 n = size();
const u64 b = std::min(begin, n);
const u64 e = std::min(std::max(end, b), n);
if (b >= e) return;
// x emitted relative to 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.
const f64 origin = x0();
const f64 origin = x_origin;
if (level == 0) {
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
// edge spans around them are read from the raw arrays -- so no emitted
// geometry comes from outside [begin, end), and the emitted x values are the
// array's own coordinates relative to x0().
// array's own coordinates relative to the caller's origin.
const u64 span = u64{kBucket} << level;
const u64 lend = pyramid().level_size(level);
const u64 first = std::min(ceil_div(b, span), lend);
+4
View File
@@ -451,6 +451,10 @@ const FigureLayout& Figure::layout() {
// not drawn once per row. A figure told to label every row keeps them everywhere,
// and then there is no shared axis to speak of: each row has its own.
p.set_x_strip_mode(labels_bottom_only_ && row != bottom_row ? XStrip::None : XStrip::Both);
// Before either pass below, and the same value for every row: nothing is measured on
// the right, so unlike the left gutter this needs no measurement pass -- the rows end
// on a common column because they are all told the one width.
p.set_right_gutter(right_gutter_);
}
// After the viewports, so a row's ticks and grid -- built from its axes rect -- and
// its transforms describe this frame's rects; and before the rows lay themselves
+17 -5
View File
@@ -1039,12 +1039,21 @@ const PlotLayout& Plot::layout() {
x_height = std::min(x_height, x_budget);
const f32 y_title_width = std::min(y_title_w, y_budget - y_width);
const f32 x_title_height = std::min(x_title_h, x_budget - x_height);
// The right-hand band, out of what is left of the same allowance the two left
// strips were served from -- so the three together cannot take more than
// `1 - kMinAxesShare` of the width however much each asks for. Nothing is drawn
// in it, so unlike the strips above it is a want and never a measurement, and it
// cannot feed the fixed point below: the y labels are chosen from the row's range
// and height, so a narrower `axes` does not change what they need.
const f32 right_gutter =
std::min(right_gutter_, y_budget - (y_width + y_title_width));
layout_.y_axis_width = y_width;
layout_.x_axis_height = x_height;
layout_.y_title_width = y_title_width;
layout_.x_title_height = x_title_height;
layout_.right_gutter = right_gutter;
layout_.axes = Rect{viewport_.x + y_width + y_title_width, viewport_.y,
vw - (y_width + y_title_width),
vw - (y_width + y_title_width + right_gutter),
vh - (x_height + x_title_height)};
layout_.y_title_area = Rect{viewport_.x, layout_.axes.y, y_title_width, layout_.axes.h};
layout_.y_label_area =
@@ -1464,7 +1473,10 @@ bool Plot::crosshair_point(u32 trace, f64 x, f64& out_x, f64& out_y) const {
// when it fell between them. The scratch is cleared and refilled rather than reallocated,
// and sized for two pairs from construction, so the pointer path allocates nothing.
crosshair_scratch_.clear();
series->gather(0u, span.begin, span.end + 1u, crosshair_scratch_);
// The origin is the query itself: only y is read out of the pairs below -- the x comes back
// through `x_at()` -- so this names the one origin that cannot lose precision for a caller
// that might.
series->gather(0u, span.begin, span.end + 1u, x, crosshair_scratch_);
const usize pairs = static_cast<usize>(span.end - span.begin + 1u);
if (crosshair_scratch_.size() != 2u * pairs) return false; // a series that produced nothing
const f64 y_begin = static_cast<f64>(crosshair_scratch_[1u]);
@@ -1480,9 +1492,9 @@ bool Plot::crosshair_point(u32 trace, f64 x, f64& out_x, f64& out_y) const {
// `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
// `x0()` by the `Series::gather()` contract -- adding it back here would be a second
// reconstruction of a coordinate the series will hand over directly.
// 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;
+9 -2
View File
@@ -98,7 +98,10 @@ void format_time(f64 seconds, char* buf, usize n) {
const char* const sign = seconds < 0.0 ? "-" : "";
const f64 t = std::fabs(seconds);
if (t < 1.0) {
// `0.9995` and not `1.0`: three significant figures of 0.9999 s are `1e+03ms`, so a value
// that *rounds* to a second belongs to the whole-second branch below, which writes it `1s`.
// The same spill one rung down is handled inside the ladder.
if (t < 0.9995) {
// Sub-second: the prefix that keeps the mantissa in [1, 1000) -- the same rule the SI
// formatter uses on a y axis, and the reading a zoomed-in window needs. A fixed
// millisecond unit printed `0.005ms` where `5us` is what the axis is showing, and
@@ -119,8 +122,12 @@ void format_time(f64 seconds, char* buf, usize n) {
{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) continue;
if (t < u.scale * 0.9995) continue;
std::snprintf(buf, n, "%s%.3g%s", sign, t / u.scale, u.unit);
return;
}
+413 -28
View File
@@ -616,9 +616,14 @@ void Renderer::upload_text_vertices(std::span<const f32> interleaved) noexcept {
void Renderer::flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash,
bool snap) noexcept {
const u32 segments = lines_.segment_count();
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_) {
upload_vertices(lines_.data());
upload_vertices(batch.data());
line_program_.bind();
line_program_.set_scale(map.scale);
line_program_.set_offset(map.offset);
@@ -643,7 +648,7 @@ void Renderer::flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash
stats_.staged_segments += segments;
++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 {
@@ -736,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))});
}
void Renderer::stage_xy(const f32* xy, u32 n, f64 series_x0, f64 origin_x, bool log_x,
bool log_y) {
void Renderer::stage_xy(const f32* xy, u32 n, f64 origin_x, bool log_x, bool log_y) {
if (!log_x && !log_y) {
// The common case: reconstruct the absolute data x in f64 (`x0 + rel` --
// the gather contract's own reconstruction), stage it relative to the
// draw's origin, and the f32 is O(visible pixels), not O(epoch).
staged_.clear();
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);
// The common case costs nothing at all: the gather emitted x relative to this very origin
// (`draw_traces` asks it for `x_range.min`, which is the draw's origin too) and a linear y
// needs no mapping, so the gathered buffer *is* the staged one.
lines_.add_polyline_xy(xy, n);
return;
}
for (u32 i = 0; i + 1u < n; ++i) {
stage_segment(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);
}
}
@@ -980,6 +977,304 @@ void Renderer::draw_titles(const Plot& plot, const PlotLayout& layout) {
// ------------------------------------------------------------------- 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) {
// 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
@@ -1021,12 +1316,38 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
// This is the whole performance contract: the window, never the series.
const u64 visible = last - first;
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
// carry (`Style::intensity_grading`), so the gather is asked for the level a
// 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();
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
stats_.visible_samples += visible;
@@ -1041,7 +1362,7 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
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 = series.x0() + static_cast<f64>(gather_[k]);
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));
@@ -1059,11 +1380,11 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
++stats_.lod_traces;
}
// Gathered x is relative to `series.x0()` (the gather contract); the staging
// reconstructs the absolute data x in f64 and emits `to_space(x_abs) -
// to_space(origin)` -- the visible x range's minimum is the natural origin
// for a trace -- so the f32 is O(visible pixels), not O(epoch) (B1). The
// mapping folds the origin back into the offset uniform, in f64.
// Gathered x is relative to `x_range.min` (the gather contract, with the origin this
// function asked for); the staging reconstructs the absolute data x in f64 and emits
// `to_space(x_abs) - to_space(origin)` about that same origin, so the f32 is O(visible
// pixels), not O(epoch) (B1). The mapping folds the origin back into the offset uniform,
// in f64.
const bool log_x = plot.x_axis(trace.axis_x).log;
const bool log_y = plot.y_axis(trace.axis_y).log;
const Mapping map = mapping(plot, trace.axis_x, trace.axis_y, x_range.min);
@@ -1087,7 +1408,7 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
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]) + series.x0();
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];
@@ -1114,9 +1435,73 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
// trace/axis hierarchy on a high-DPI panel. `width == 0` is scatter: the
// markers below are the whole trace.
if (trace.width > 0.0f) {
stage_xy(gather_.data(), points, series.x0(), x_range.min, log_x, log_y);
// A window denser than two gathered points per pixel column is drawn as *columns*
// 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
// (so their size is device pixels whatever the data scale is). The stride is
@@ -1132,7 +1517,7 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
const f64 origin_s = space(x_range.min, log_x);
u32 staged = 0;
for (u32 p = 0; p < points; p += stride) {
const f64 x_abs = static_cast<f64>(gather_[2u * p]) + 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 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};
+46
View File
@@ -425,6 +425,52 @@ PLOT_TEST(figure_gutter_never_squeezes_the_axes_below_the_floor) {
}
}
/// The other side of the same band. `axes` reached the viewport's right edge, which left
/// the rightmost x tick label -- centred on it -- half outside the plot, so a figure can
/// ask for an empty band there. One value for every row, like the left gutter, because
/// the rows' axes have to end on a common column as well as start on one.
PLOT_TEST(figure_rows_share_one_right_gutter) {
plot::Figure figure = different_magnitude_figure();
figure.layout();
const plot::f32 flush = figure.plot(0u).layout().axes.right();
PLOT_CHECK_NEAR(static_cast<plot::f64>(figure.right_gutter()), 0.0, 1e-12);
// The default is the layout as it always was: nothing between the data and the frame.
PLOT_CHECK_NEAR(static_cast<plot::f64>(flush), static_cast<plot::f64>(kW), 1e-4);
figure.set_right_gutter(30.0f);
figure.layout();
const plot::Rect first = figure.plot(0u).layout().axes;
for (plot::u32 row = 0; row < 3u; ++row) {
const plot::PlotLayout& rl = figure.plot(row).layout();
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.right_gutter), 30.0, 1e-4);
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.axes.right()), static_cast<plot::f64>(kW) - 30.0,
1e-4);
// The band comes off the right alone: the left gutter the rows agreed on is untouched,
// so a host can balance the two sides without the one moving when it sets the other.
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.axes.x), static_cast<plot::f64>(first.x), 1e-4);
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.axes.w), static_cast<plot::f64>(first.w), 1e-4);
// ... and the x strip follows the data area rather than the viewport, or the shared
// axis would be labelled over a band that holds no data.
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.x_label_area.w), static_cast<plot::f64>(rl.axes.w),
1e-4);
}
// Clamped by the same cap as the strips: a band wider than the whole figure cannot
// squeeze `axes` below the floor `Plot::layout()` guarantees.
figure.set_right_gutter(10.0f * kW);
figure.layout();
for (plot::u32 row = 0; row < 3u; ++row) {
const plot::Rect& axes = figure.plot(row).layout().axes;
PLOT_CHECK(axes.w >= 0.5f * kW - 1e-3f);
}
// Negative means "none", like the gutter and the row gap, and the data area goes back
// to the viewport's edge.
figure.set_right_gutter(-5.0f);
PLOT_CHECK_NEAR(static_cast<plot::f64>(figure.right_gutter()), 0.0, 1e-12);
figure.layout();
PLOT_CHECK_NEAR(static_cast<plot::f64>(figure.plot(0u).layout().axes.right()),
static_cast<plot::f64>(flush), 1e-4);
}
/// The degenerate neighbour: a figure of one row imposes exactly what that row
/// measured, so it lays out as the plot would on its own -- the gutter cannot move a
/// row that has no sibling to line up with.
+4 -4
View File
@@ -1939,11 +1939,11 @@ class CountingSeries final : public plot::data::Series {
}
plot::Range y_bounds() const override { return {0.0, 99.0}; }
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out,
void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out,
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override {
++gather_calls_;
samples_read_ += end - begin;
gather_impl(level, begin, end, out, mode);
gather_impl(level, begin, end, x_origin, out, mode);
}
u64 gather_calls() const noexcept { return gather_calls_; }
@@ -2471,9 +2471,9 @@ class XyFixture final : public plot::data::Series {
}
plot::Range y_bounds() const override { return {0.0, 20.0}; }
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
void gather(u32 level, u64 begin, u64 end, 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 {
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)]; }
+276 -4
View File
@@ -38,6 +38,23 @@ constexpr plot::u32 kH = 360;
/// 1M-wide window holds ~16 of them (the dense envelope spans the full height in
/// every column) while a 640-wide one is a nearly flat line (the sparse path's
/// polyline crosses every column exactly once).
/// A sine of **one period per pixel column** for the window
/// `a_dense_trace_can_be_graded_by_dwell` draws (60 000 x-units over a 572 px axis, so
/// 104.9 samples a column). That is the one density at which a column *has* a dwell
/// structure to grade: its envelope spans the full amplitude, so the band is tall, and
/// the sub-buckets inside it are a fraction of a period each, so the ones at the sine's
/// turning points are short and the ones crossing its middle are long. A period of many
/// columns leaves every band a thin ribbon with nothing to distinguish, and a period of a
/// fraction of a column fills every sub-bucket to the full amplitude -- also nothing.
std::vector<plot::f32> fast_sine_samples() {
std::vector<plot::f32> y(1'000'000);
const double w = 2.0 * 3.14159265358979323846 / 104.9;
for (plot::u64 i = 0; i < y.size(); ++i) {
y[i] = static_cast<plot::f32>(std::sin(static_cast<double>(i) * w));
}
return y;
}
std::vector<plot::f32> sine_samples() {
std::vector<plot::f32> y(1'000'000);
for (plot::u64 i = 0; i < y.size(); ++i) {
@@ -723,11 +740,11 @@ class LyingGridSeries final : public plot::data::Series {
if (x_at(i0) != x && i0 + 1u < kN) i1 = i0 + 1u;
return true;
}
void gather(u32, u64 begin, u64 end, std::vector<f32>& out,
void gather(u32, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out,
plot::data::GatherMode) const override {
++gather_calls_;
for (u64 i = begin; i < end; ++i) {
out.push_back(static_cast<f32>(x_at(i) - x0()));
out.push_back(static_cast<f32>(x_at(i) - x_origin));
out.push_back(kY);
}
}
@@ -868,6 +885,260 @@ PLOT_TEST(dense_and_sparse_paths_agree_on_extent) {
ctx.destroy();
}
/// A window denser than the pixel grid inks **whole columns**, which is what kills the moire.
///
/// Reported as "strong moire patterns due to a large number of vertical lines" on a high rate,
/// a high frequency or a noisy signal. The polyline at this density is a stack of near-vertical
/// hairlines at *fractional* x -- one to two envelope buckets per column, each a min->max
/// hairline plus a connector -- so a column's brightness is a non-linear function of those
/// sub-pixel phases, and the phases repeat at the beat between the bucket pitch and the pixel
/// pitch. That beat is the pattern, and panning sweeps it, so it crawls.
///
/// The observable is the beat's own cause, measured: how many **distinct coverage levels** the
/// trace's ink takes. One segment per column puts every column's ink at one sub-pixel phase, so
/// the AA ramp is evaluated at the same few distances everywhere and the ink lands on **4**
/// levels; the polyline's hairlines sit at arbitrary phases and its ink spreads over **126** of
/// them. That spread *is* the texture, and it is what beats against the pixel pitch.
///
/// A count of partially covered pixels is *not* the reading, which is worth knowing before
/// adding one: both renderings put about the same amount of ink in the fringe (577 against 652
/// here, of 1400-odd inked pixels), because a 1.5 px stroke with a 1 px fringe always spills
/// into its neighbour. The fix is not that the fringe is gone, it is that it is the *same*
/// fringe in every column.
///
/// The window is one period of the sine over 107 samples per pixel: dense enough for the
/// reduction and slow enough that the band is a *thin ribbon*, which is where the beat shows.
/// A window whose buckets each span several periods paints a solid block and hides it.
PLOT_TEST(a_dense_trace_is_drawn_as_whole_pixel_columns) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
plot::data::ArraySeries s(sine_samples(), 0.0, 1.0);
plot::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
const plot::u32 ax = p.add_x_axis({"t", {0.0, 60'000.0}, false, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(p);
r.end_frame();
dump_if_requested(r, "columns.ppm");
const plot::Rect a = p.layout().axes;
// Blue over the background minus red is the trace's coverage, linearly: 8 at no coverage
// and 199 at full (0x40C0FF over 0x101418). Nothing else in this frame is blue-dominant --
// the grid and the axis line are grey -- so the two bands below are the trace's own ink,
// split at three quarters covered.
plot::u32 inked = 0;
plot::u32 histogram[256] = {};
for (plot::u32 row = 0; row < static_cast<plot::u32>(a.h); ++row) {
for (plot::u32 col = 0; col < static_cast<plot::u32>(a.w); ++col) {
plot::u8 px[4] = {};
const plot::u32 x = static_cast<plot::u32>(a.x) + col;
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - (static_cast<plot::u32>(a.y) + row), px));
const int cover = static_cast<int>(px[2]) - static_cast<int>(px[0]);
if (cover > 40) { // the trace's ink, at a quarter covered or better
++inked;
++histogram[cover];
}
}
}
// How many *distinct* coverages the ink takes is the beat, measured. A column's ink is
// placed at one sub-pixel phase, so the AA ramp is evaluated at the same few distances in
// every column and the ink lands on a handful of levels; a polyline's hairlines sit at
// arbitrary phases, so its ink spreads over the whole range of them. The floor of 4 pixels
// per level ignores the stragglers at the ribbon's turning points.
plot::u32 levels = 0;
for (plot::u32 c = 0; c < 256u; ++c) {
if (histogram[c] >= 4u) ++levels;
}
std::printf("[columns] axes=[%.1f %.1f %.1f %.1f] raw=%u lod=%u columns=%u segments=%u "
"visible=%llu inked=%u levels=%u\n",
a.x, a.y, a.w, a.h, r.stats().raw_traces, r.stats().lod_traces,
r.stats().column_traces, r.stats().staged_segments,
static_cast<unsigned long long>(r.stats().visible_samples), inked, levels);
// The reduction ran, and on the *raw* path: the gate is the gathered density, not the level.
PLOT_CHECK(r.stats().column_traces == 1);
PLOT_CHECK(r.stats().visible_samples > 50'000u);
// One segment per column, so the frame's whole geometry -- grid and axis lines included --
// is bounded by the axis' width: 627 against the polyline's 60 054.
PLOT_CHECK(r.stats().staged_segments < static_cast<plot::u32>(a.w) + 64u);
// The ribbon is there, and its ink sits on a handful of coverage levels (4) rather than on a
// continuum of them (126).
PLOT_CHECK(inked > static_cast<plot::u32>(a.w));
PLOT_CHECK(levels <= 8u);
PLOT_CHECK(!r.last_frame_had_gl_error());
ctx.destroy();
}
/// `Style::intensity_grading`: a reduced column is drawn **graded by dwell** -- bright
/// where the signal sits, faint where it only passes through -- which is the second half
/// of the moire answer. The columns alone removed the beat; this is what puts back the
/// information a flat band throws away, the way a phosphor display does.
///
/// The observable is the thing the feature claims and nothing weaker: within one column,
/// the rows at the **edges** of the band are brighter than the rows in its **middle**.
/// For a sine of one period per column that is exactly right -- it dwells at its turning
/// points and crosses its middle at full speed -- and it is a property of the *rendering*
/// rather than of the waveform, because the same frame drawn ungraded puts the band's
/// edge and its middle at the same coverage. Both frames are measured here, so what is
/// asserted is the difference between them.
///
/// It also pins what grading may *not* cost. The reduction still runs (`column_traces`),
/// so the beat is still gone; the geometry is still bounded by the axis' width times a
/// small factor, because a run is a stretch of equal tier and not a row; and the draw
/// calls are bounded by the steps, since the line pipeline's colour is a uniform.
PLOT_TEST(a_dense_trace_can_be_graded_by_dwell) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
plot::data::ArraySeries s(fast_sine_samples(), 0.0, 1.0);
plot::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
const plot::u32 ax = p.add_x_axis({"t", {0.0, 60'000.0}, false, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
// The band's edges against its middle, averaged over columns across the axis. The
// shares are of the band's own height, so the reading does not depend on where the
// band happens to sit or on how tall it is: the outer fifth at each end against the
// middle fifth.
struct Profile {
plot::u32 edge = 0; ///< mean peak coverage over the band's two outer fifths
plot::u32 middle = 0; ///< mean peak coverage over its middle fifth
plot::u32 columns = 0; ///< columns that held a band at all
};
auto profile = [&](const plot::Rect& a) -> Profile {
const plot::u32 rows = static_cast<plot::u32>(a.h);
std::vector<int> cover(rows, 0);
plot::u64 edge_sum = 0;
plot::u64 middle_sum = 0;
Profile out;
for (plot::u32 col = 8; col + 8 < static_cast<plot::u32>(a.w); col += 16) {
plot::u32 lo = rows;
plot::u32 hi = 0;
for (plot::u32 row = 0; row < rows; ++row) {
plot::u8 px[4] = {};
const plot::u32 x = static_cast<plot::u32>(a.x) + col;
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - (static_cast<plot::u32>(a.y) + row), px));
cover[row] = static_cast<int>(px[2]) - static_cast<int>(px[0]);
if (cover[row] > 20) { // the faintest step is 0.3 of 191, so this is under it
if (row < lo) lo = row;
hi = row;
}
}
// A band of fewer than 15 rows has no middle fifth to speak of; at this density
// there is none, but the reading would be meaningless if there were.
if (lo >= hi || hi - lo < 15u) continue;
const plot::u32 h = hi - lo + 1u;
const plot::u32 fifth = h / 5u;
int edge = 0;
int middle = 0;
for (plot::u32 row = lo; row <= hi; ++row) {
const plot::u32 d = row - lo;
if (d < fifth || d >= h - fifth) {
if (cover[row] > edge) edge = cover[row];
} else if (d >= 2u * fifth && d < 3u * fifth) {
if (cover[row] > middle) middle = cover[row];
}
}
edge_sum += static_cast<plot::u64>(edge);
middle_sum += static_cast<plot::u64>(middle);
++out.columns;
}
if (out.columns != 0) {
out.edge = static_cast<plot::u32>(edge_sum / out.columns);
out.middle = static_cast<plot::u32>(middle_sum / out.columns);
}
return out;
};
// Ungraded first: the reference the grading is read against.
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(p);
r.end_frame();
dump_if_requested(r, "ungraded.ppm");
const plot::Rect a = p.layout().axes;
const Profile flat = profile(a);
const plot::u32 flat_segments = r.stats().staged_segments;
const plot::u32 flat_draws = r.stats().draw_calls;
PLOT_CHECK(r.stats().column_traces == 1 && r.stats().graded_traces == 0);
PLOT_CHECK(!r.last_frame_had_gl_error());
r.style().intensity_grading = true;
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(p);
r.end_frame();
dump_if_requested(r, "graded.ppm");
const Profile lit = profile(a);
std::printf("[graded] axes=[%.1f %.1f %.1f %.1f] flat edge=%u middle=%u cols=%u seg=%u "
"draws=%u | graded edge=%u middle=%u cols=%u seg=%u draws=%u columns=%u "
"graded_traces=%u\n",
a.x, a.y, a.w, a.h, flat.edge, flat.middle, flat.columns, flat_segments,
flat_draws, lit.edge, lit.middle, lit.columns, r.stats().staged_segments,
r.stats().draw_calls, r.stats().column_traces, r.stats().graded_traces);
// Both frames measured a band in most of the columns they sampled, or the two
// profiles are not comparable and everything below is vacuous.
PLOT_CHECK(flat.columns > 20u && lit.columns == flat.columns);
// The flat band is flat: its edge and its middle are one ink (191 and 191).
PLOT_CHECK(flat.middle > 0u && flat.edge * 100u < flat.middle * 115u);
// The graded one is not, and in the direction a sine dwells in: 191 against 131, a
// middle at 0.69 of the edge. The figure is a *tier* ratio and not the dwell ratio --
// the per-row dwell at the rails is several times the crossing's, and
// `intensity_gamma` (0.45) and `intensity_floor` (0.3) are there to compress exactly
// that into a range an eye reads as shading rather than as a gap. So the threshold is
// what the quantised ink does, with room for a step either way.
PLOT_CHECK(lit.middle > 0u && lit.middle * 100u < lit.edge * 80u);
// The reduction still ran, so the beat the columns removed is still gone.
PLOT_CHECK(r.stats().column_traces == 1 && r.stats().graded_traces == 1);
// A run is a stretch of equal tier, not a row: the geometry stays bounded by the
// axis' width times a small factor, nothing like `w * h`. Measured 8303 over a 572 px
// axis, 14.5 runs a column -- the dwell profile's own structure (four tiers from the
// dim middle out to each rail) plus the quantiser's dither at each boundary. The flat
// path's 627 is the other end of the scale, and 572 * 156 rows is what this is not.
PLOT_CHECK(r.stats().staged_segments < 24u * static_cast<plot::u32>(a.w));
// And the draw calls by the steps: one per tier that staged anything, over the flat
// frame's single trace draw.
PLOT_CHECK(r.stats().draw_calls <= flat_draws + r.style().intensity_steps);
// And the level floor, which the window above cannot see: at 105 samples a pixel the
// flat path is already on raw samples, so grading asks for nothing finer. A wider
// window puts the flat path on the pyramid, and there a sub-column is `spp / 8` -- which
// falls under one bucket long before the column does, so an unfloored ask answers level
// 0 and gathers the whole visible window. That is a real cost and it was measured: at
// 1.3 MS/s over a 2 s window udpscope's own frame report went from `raw=710 lod=453` to
// `raw=1154 lod=0` and 2.3 -> 6.4 ms a frame. The floor is one bucket level, so the
// reading is simply that a graded frame stays on the pyramid.
p.set_x_range(ax, {0.0, 200'000.0});
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(p);
r.end_frame();
std::printf("[graded] wide: raw=%u lod=%u columns=%u graded=%u seg=%u visible=%llu\n",
r.stats().raw_traces, r.stats().lod_traces, r.stats().column_traces,
r.stats().graded_traces, r.stats().staged_segments,
static_cast<unsigned long long>(r.stats().visible_samples));
PLOT_CHECK(r.stats().visible_samples > 100'000u);
PLOT_CHECK(r.stats().lod_traces == 1 && r.stats().raw_traces == 0);
PLOT_CHECK(r.stats().column_traces == 1 && r.stats().graded_traces == 1);
PLOT_CHECK(r.stats().staged_segments < 24u * static_cast<plot::u32>(a.w));
PLOT_CHECK(!r.last_frame_had_gl_error());
ctx.destroy();
}
// Resolution #1 of the brief: the renderer draws into whatever framebuffer is
// bound and clears only its viewport, so a host that embeds the plot in a
// sub-rect (Qt/GTK/ImGui) keeps the rest of its surface. Coordinates are given
@@ -1512,9 +1783,10 @@ PLOT_TEST(a_clamped_front_does_not_draw_a_segment_across_the_panel) {
if (x_at(k) != x && k + 1u < 8u) i1 = k + 1u;
return true;
}
void gather(u32, u64 begin, u64 end, std::vector<f32>& out, plot::data::GatherMode) const override {
void gather(u32, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out,
plot::data::GatherMode) const override {
for (u64 i = begin; i < end; ++i) {
out.push_back(static_cast<f32>(x_at(i) - x0()));
out.push_back(static_cast<f32>(x_at(i) - x_origin));
out.push_back(i < 2u ? -1.0f : 1.0f);
}
}
+60 -20
View File
@@ -24,7 +24,7 @@ PLOT_TEST(array_series_bounds_and_gather) {
PLOT_CHECK_NEAR(s.x_bounds().max, 11.5, 1e-9);
PLOT_CHECK_NEAR(s.y_bounds().min, -2.0, 1e-6);
std::vector<f32> out;
s.gather(0, 0, 4, out);
s.gather(0, 0, 4, s.x0(), out);
PLOT_CHECK(out.size() == 8); // interleaved x,y
// x is relative to x0(): sample 0 emits 0, not 10 (the relative-x contract).
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);
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);
// x is relative to x0(): the oldest retained sample emits 0, the newest
// (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.sumsq(0, n);
std::vector<f32> out;
s.gather(0, 0, n, out); // reads the oldest edge
s.gather(0, 0, n, s.x0(), out); // reads the oldest edge
if (out.size() != 2 * n) sizes_ok = false;
}
++reads;
@@ -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
// the window -- so every emitted bucket's samples lie inside the window.
std::vector<f32> out;
s.gather(1, 0, n, out);
s.gather(1, 0, n, s.x0(), out);
const u64 first =
std::max(base / span + (base % span == 0 ? u64{0} : u64{1}), lbase); // ceil, then clamp
const u64 last = std::min((base + n) / span, lbase + lsize);
@@ -374,7 +374,7 @@ PLOT_TEST(gather_at_a_lod_level_reaches_both_ends_of_the_window) {
const auto check_ends = [&](u32 level) {
std::vector<f32> out;
s.gather(level, 0, n, out);
s.gather(level, 0, n, s.x0(), out);
PLOT_CHECK(out.size() >= 8u);
// Oldest: the first emitted pair is the retained sample at index 0, not the
// first whole bucket (which starts `lead` samples later). 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::data::ArraySeries a(y, 0.0, 1e-6);
std::vector<f32> out;
a.gather(2, 0, ny, out);
a.gather(2, 0, ny, a.x0(), out);
PLOT_CHECK(out.size() >= 8u);
PLOT_CHECK_NEAR(out[0], 0.0, 1e-9); // level 2 starts on the grid
PLOT_CHECK_NEAR(out[out.size() - 2], f32(f64(ny - 1) * 1e-6), 1e-9);
@@ -469,7 +469,7 @@ PLOT_TEST(gather_envelope_stays_inside_an_unaligned_range) {
const auto check_range = [&](u32 level) {
std::vector<f32> out;
s.gather(level, b, e, out);
s.gather(level, b, e, s.x0(), out);
PLOT_CHECK(out.size() >= 4u);
for (plot::usize i = 0; i + 1 < out.size(); i += 2) {
PLOT_CHECK(f64(out[i]) >= f64(b)); // emitted x inside [begin, end)
@@ -499,7 +499,7 @@ PLOT_TEST(gather_envelope_stays_inside_an_unaligned_range) {
const u64 buckets = last - first;
PLOT_CHECK(lead > b && last * span < e); // both edge spans really are partial
std::vector<f32> out;
s.gather(level, b, e, out);
s.gather(level, b, e, s.x0(), out);
PLOT_CHECK(out.size() == 2 * ((lead - b) + 2 * buckets + (e - last * span)));
plot::usize o = 0;
for (u64 i = b; i < lead; ++i, o += 2) { // the partial leading bucket, raw
@@ -544,7 +544,7 @@ PLOT_TEST(stream_gather_envelope_stays_inside_an_unaligned_range) {
const plot::Range exact = s.reduce(b, e);
PLOT_CHECK(exact.max < 1.0 && exact.min > -1.0);
std::vector<f32> out;
s.gather(1, b, e, out);
s.gather(1, b, e, s.x0(), out);
PLOT_CHECK(out.size() >= 4u);
for (plot::usize i = 0; i + 1 < out.size(); i += 2) {
PLOT_CHECK(f64(out[i]) >= f64(b)); // relative x inside [begin, end)
@@ -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;
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
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());
for (const u32 level : {u32{58}, u32{200}, u32{1000}, ~u32{0}}) {
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
}
// 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);
std::vector<f32> env, near, mean;
s.gather(level, 0, kN, env, plot::data::GatherMode::Envelope);
s.gather(level, 0, kN, near, plot::data::GatherMode::Nearest);
s.gather(level, 0, kN, mean, plot::data::GatherMode::Mean);
s.gather(level, 0, kN, s.x0(), env, plot::data::GatherMode::Envelope);
s.gather(level, 0, kN, s.x0(), near, plot::data::GatherMode::Nearest);
s.gather(level, 0, kN, s.x0(), mean, plot::data::GatherMode::Mean);
// Envelope: two points per bucket, min at the bucket's first sample, max at
// its last (this sawtooth's per-bucket min is its first sample's value and
@@ -692,7 +692,7 @@ PLOT_TEST(gather_modes_envelope_nearest_mean) {
// Level 0 and sub-bucket ranges are the raw samples for every mode.
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_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.
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_NEAR(env[0], 0.0, 1e-9);
PLOT_CHECK_NEAR(env[1], 1.0, 1e-9);
PLOT_CHECK_NEAR(env[2], 127.0, 1e-9);
PLOT_CHECK_NEAR(env[3], 1.0, 1e-9);
s.gather(1, 0, 256, 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_NEAR(mean[0], 64.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(near[0], 64.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 plot::Range bounds = s.y_bounds();
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);
// 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(frozen.sum(0, n), s.sum(0, n), 1e-3);
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
// 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);
}
// --- The gather origin: f32 precision against the view, not the window -------
PLOT_TEST(a_gather_origin_at_the_view_keeps_a_deep_zoom_on_the_sample_it_quotes) {
// 20 s of retained data at 1 kS/s examined through a 10 us window -- the shape of
// a scope zoomed in on a long ring. gather() stages x as f32, so what the origin
// decides is the span that f32 has to resolve: the whole retained window (which
// is what `x0()`, the old implicit origin, names) or the part of it on screen. A
// readout quotes `x_at()` in exact f64, so the difference between the two is the
// distance between where a sample is *drawn* and the x reported for it.
constexpr u64 kN = 20001;
std::vector<f32> y(static_cast<plot::usize>(kN));
for (u64 i = 0; i < kN; ++i) y[static_cast<plot::usize>(i)] = f32(i % 71);
const plot::data::ArraySeries s(std::move(y), /*x0=*/0.0, /*dx=*/1e-3);
PLOT_CHECK_NEAR(s.x_bounds().max, 20.0, 1e-9);
const f64 centre = s.x_at(kN - 2u); // 19.999 s, near the far end
const plot::Range view{centre - 5e-6, centre + 5e-6}; // 10 us across
const f64 px_per_x = 700.0 / (view.max - view.min); // a typical axis width
const auto worst_error_px = [&](f64 origin) {
std::vector<f32> out;
s.gather(0, kN - 3u, kN, origin, out);
PLOT_CHECK(out.size() == 6u);
f64 worst = 0.0;
for (u64 k = 0; k < 3u; ++k) {
// Exactly the renderer's reconstruction: the origin added back in f64.
const f64 drawn = origin + f64(out[static_cast<plot::usize>(2u * k)]);
worst = std::max(worst, std::abs(drawn - s.x_at(kN - 3u + k)) * px_per_x);
}
return worst;
};
// The visible range's minimum: the f32 resolves 2^-24 of what is on screen, so
// the error is a fixed fraction of a pixel at every zoom.
PLOT_CHECK(worst_error_px(view.min) < 0.05);
// The start of the retained window -- what every caller used to get -- resolves
// 2^-24 of 20 s instead, which at this zoom is most of the axis.
PLOT_CHECK(worst_error_px(s.x0()) > 10.0);
}
// --- Incremental append: ArraySeries::push() ----------------------------------
PLOT_TEST(array_series_push_matches_commit) {
+12
View File
@@ -263,6 +263,18 @@ PLOT_TEST(format_time_never_lets_a_field_reach_its_radix) {
plot::format_time(86399.5, b, sizeof b); PLOT_CHECK(std::string(b) == "1d");
plot::format_time(86400.0, b, sizeof b); PLOT_CHECK(std::string(b) == "1d");
// A mantissa may not reach its own radix either: three significant figures of a value a
// hair under a rung's top round up to 1000, and `%.3g` writes that `1e+03`. A one-second
// tick computed as 0.9999999 s read `1e+03ms` on a live axis, which is where this was seen.
plot::format_time(0.9999999, b, sizeof b); PLOT_CHECK(std::string(b) == "1s");
plot::format_time(0.9995, b, sizeof b); PLOT_CHECK(std::string(b) == "1s");
plot::format_time(0.9994, b, sizeof b); PLOT_CHECK(std::string(b) == "999ms");
plot::format_time(0.9999999e-3, b, sizeof b); PLOT_CHECK(std::string(b) == "1ms");
plot::format_time(0.9994e-3, b, sizeof b); PLOT_CHECK(std::string(b) == "999\u00b5s");
plot::format_time(0.9999999e-6, b, sizeof b); PLOT_CHECK(std::string(b) == "1\u00b5s");
plot::format_time(0.9999999e-9, b, sizeof b); PLOT_CHECK(std::string(b) == "1ns");
plot::format_time(-0.9999999, b, sizeof b); PLOT_CHECK(std::string(b) == "-1s");
// Sweep the boundary neighbourhoods and check every emitted field.
const double values[] = {0.4, 0.9995, 1.0, 59.4, 59.4999, 59.5, 59.9, 60.0, 90.0, 119.5, 3599.4,
3599.5, 3599.9, 86399.4, 86399.5, 86399.9, 86400.0, 172800.0, -119.5,
+4 -3
View File
@@ -52,7 +52,8 @@ PLOT_TEST(xy_series_reports_its_own_grid) {
PLOT_CHECK_NEAR(s.y_bounds().max, 48.0, 1e-6);
// 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);
const std::vector<f64> es = epoch; // un-moved copy for the expectations
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().max, 1.7e9 + (xs.back() - xs.front()), 1e-3);
std::vector<f32> out;
e.gather(0, 0, 500, out);
e.gather(0, 0, 500, e.x0(), out);
PLOT_CHECK(out.size() == 1000);
// x is relative to x0(): x_at(i) - x0(), the array's own offsets -- small
// values even though the absolute epoch is 1.7e9. The expectations come from
@@ -186,7 +187,7 @@ PLOT_TEST(xy_series_gather_envelope_matches_exact_reduces) {
const u64 span = u64{kBucket} << level;
const u64 b = 500 + 33, e = 3500 - 57; // unaligned on the bucket grid
std::vector<f32> out;
s.gather(level, b, e, out, plot::data::GatherMode::Envelope);
s.gather(level, b, e, s.x0(), out, plot::data::GatherMode::Envelope);
PLOT_CHECK(out.size() >= 8u);
// Emitted x stays inside [x_at(begin), x_at(end)) and the geometry reaches
// both the range's extremes (the lead/trail raw spans carry them).