fix(plot): a crosshair readout quotes a sample, never a value between two

The readout's y was a linear interpolation between the two samples adjacent to
the pointer, which answers "what is the signal at this instant" -- a number the
series does not contain. On a source whose samples are already averaged or
decimated at ingest that is a value nothing measured, and at a zoom past the
sample spacing it is visibly not the datum under the pointer.

`crosshair_span()`'s `t` becomes a selector rather than a blend factor: the
nearer end wins, a tie reading the earlier sample (the bias the span already
takes for a degenerate pair). `t` is measured on the samples' own x through
`neighbours()`/`x_at()`, so "nearer" is exact on a non-uniform clock too, and
the lookup still reads level 0, so the value is a stored sample and never an
LOD bucket's aggregate. `crosshair_interpolate()` had no other caller and is
gone.

`CrosshairRow::marker` is placed from the same y, so the dot follows the
reported sample: past the sample spacing it sits beside the polyline rather
than on it, deliberately -- one y feeding both keeps the mark and the number
from ever disagreeing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-10-05 13:42:57 +02:00
1 parent 26a7c82a80
commit c028f48fa0
10 files changed
+150 -105

No files matched your search

+15 -12
View File
@@ -166,10 +166,10 @@ series therefore stages O(visible pixels) vertices, not O(samples), and `FrameSt
which path each trace took. Below the threshold the raw samples are drawn instead; both
sides go through the same instanced anti-aliased quad pipeline.
## Exact versus interpolated
## One sample or a statistic of many
The library deliberately holds two conventions next to each other, and they do **not**
agree:
Every number the library reports is one the samples actually hold. What differs between
these two is *how many* samples answer the question:
- **`Series::reduce` / `sum` / `sumsq` are exact over a sample range.** They answer "what
are the samples' extremes / sum / sum of squares over `[begin, end)`". Each is
@@ -179,15 +179,18 @@ agree:
and no query allocates. `Measure::evaluate` and `Plot::region_stats` are built on these,
never on the `*_buckets` forms, which would silently report wrong extremes for an
unaligned interval.
- **The crosshair readout is interpolated, and says so.** `crosshair_span(series, x)`
returns the two samples `x` lies between (`begin`, `end`, `t`), and
`crosshair_interpolate(y_begin, y_end, t)` does a linear interpolation: it answers "what
is the signal *at* this instant", which is generally between two samples — a different
question from an interval statistic. The lookup is O(1) (one division and a floor) and
allocation-free; `Plot::crosshair_value(trace, x, out_y)` exposes the same number to a
host. A row whose trace has no sample at the crosshair's x shows `kCrosshairNoValue`
(an em dash, `"—"`), and an x outside the series window yields no value rather than the
nearest sample's.
- **The crosshair readout is a single sample — the nearest one.** `crosshair_span(series,
x)` returns the two samples `x` lies between (`begin`, `end`, `t`), and the readout takes
whichever of the two is nearer: `t <= 0.5` the earlier, otherwise the later. It answers
"which datum is under the pointer", not "what is the signal *at* this instant" — a
linearly interpolated y is a number the series does not contain, and on a source whose
samples are already averaged or decimated at ingest it is a claim about a signal nobody
measured. The lookup is O(1) and allocation-free, and it reads level 0, so the value is a
stored sample and never an LOD bucket's aggregate; `Plot::crosshair_value(trace, x,
out_y)` exposes the same number to a host, and `CrosshairRow::marker` is placed from that
same y, so the dot and the number cannot disagree. A row whose trace has no sample at the
crosshair's x shows `kCrosshairNoValue` (an em dash, `"—"`), and an x outside the series
window yields no value rather than clamping to the first or last sample.
`Measurement` (from `Plot::measurement()`) documents `integral` as a **left-Riemann sum**,
`sum(begin, end) * dx` — the samples taken as the value at the left edge of each step. That