feat(plot): the readout marker snaps to the sample it quotes, and the value mode is a choice

The mark was placed at the *pointer's* x with the quoted sample's y, so with the
nearest-sample readout it sat off the datum horizontally -- and further off the deeper
the zoom. `CrosshairRow` now carries the data `x` the value belongs to and the marker is
placed from it, so in `NearestSample` the mark lands on the sample the panel is quoting.
`Style::crosshair_marker_px` (9) and `crosshair_marker_width` (1.5) replace the
renderer's hard-coded 3 px arm and 1 px stroke: a snapped mark lands among a trace's own
sample dots (`marker_px`, 3) and at that size would read as one more sample rather than
as the answer to the question the hover asks.

`CrosshairValue` makes the previous behaviour reachable rather than deleted. The two
modes answer different questions -- `NearestSample` "which datum is under the pointer",
`Interpolated` "what is the signal at this instant" -- and only the caller knows which it
is asking, so the plot offers both with the sample as the default.
`crosshair_interpolate()` is restored for it.

`Plot::crosshair_point()` is the lookup with both coordinates, public because a host that
draws instruments of its own needs the same snap and a second rule for it would drift
from this one; `crosshair_value()` delegates to it.
This commit is contained in:
Martino Ferrari committed 2026-10-05 14:36:47 +02:00
1 parent c028f48fa0
commit c960decaea
9 files changed
+372 -78

No files matched your search

+17 -12
View File
@@ -179,18 +179,23 @@ these two is *how many* samples answer the question:
and no query allocates. `Measure::evaluate` and `Plot::region_stats` are built on these,
never on the `*_buckets` forms, which would silently report wrong extremes for an
unaligned interval.
- **The crosshair readout is a single sample — the nearest one.** `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.
- **The crosshair readout is a single sample — the nearest one — by default.**
`crosshair_span(series, x)` returns the two samples `x` lies between (`begin`, `end`, `t`),
and `CrosshairValue::NearestSample` takes whichever of the two is nearer: `t <= 0.5` the
earlier, otherwise the later. It answers "which datum is under the pointer", not "what is
the signal *at* this instant" — a linearly interpolated y is a number the series does not
contain, and on a source whose samples are already averaged or decimated at ingest it is a
claim about a signal nobody measured. That is why it is the default; a host whose x is a
quantity it is willing to interpolate states `CrosshairValue::Interpolated` and gets
`crosshair_interpolate(y_begin, y_end, t)` instead, at the crosshair's own x. The lookup is
O(1) and allocation-free either way, and it reads level 0, so the value is a stored sample
(or a blend of two) and never an LOD bucket's aggregate. `Plot::crosshair_point(trace, x,
out_x, out_y)` exposes **both** coordinates to a host and `crosshair_value()` the y alone;
`CrosshairRow::x`/`y` and its `marker` are placed from that same pair, so in
`NearestSample` the mark snaps onto the quoted sample and the dot and the number cannot
disagree. A row whose trace has no sample at the crosshair's x shows `kCrosshairNoValue`
(an em dash, `"—"`), and an x outside the series window yields no value rather than
clamping to the first or last sample.
`Measurement` (from `Plot::measurement()`) documents `integral` as a **left-Riemann sum**,
`sum(begin, end) * dx` — the samples taken as the value at the left edge of each step. That