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
+34 -4
View File
@@ -106,6 +106,7 @@ one per plot.
- `set_crosshair_readout(bool)` switches the panel off — or back on — without moving the
crosshair. `set_crosshair_style(const CrosshairStyle&)` overrides this plot's stroke and
dash.
- `set_crosshair_value_mode(CrosshairValue)` chooses what a readout *is* — see below.
In a `Figure`, the crosshair is applied to **every row** (one shared `x`): each row's readout
lists its own traces at that instant. `Figure::set_crosshair(x, active)` is vertical-only
@@ -113,10 +114,39 @@ across the figure, and `Figure::set_crosshair(x, hovered_row, y_in_row, active)`
horizontal line **only in the row the pointer is in** — a figure's rows share x and not y, so
a figure-wide horizontal line would be a value that means something different in each row.
Readout values are the **nearest sample's**, neither interpolated between two samples nor an
interval statistic (see [data-and-performance.md](data-and-performance.md)); a row whose trace
has no sample at the crosshair's x is listed with the em dash `"—"` and no marker, rather than a
fabricated value.
### What a readout is: the two value modes
`Crosshair::value_mode` (`set_crosshair_value_mode()`) answers two different questions, and
which one a caller is asking decides the mode:
| mode | the value | the marker | answers |
|---|---|---|---|
| `CrosshairValue::NearestSample` (default) | the nearer bracketing sample's y (a tie reads the earlier) | **snapped to that sample's own x** | *which datum is under the pointer?* |
| `CrosshairValue::Interpolated` | a linear blend of the two bracketing samples | at the crosshair's own x | *what is the signal at this instant?* |
The default is the sample, because a readout that quotes a number the data does not contain is
a second estimate laid over the first — and once the view is zoomed past the sample spacing the
polyline between two samples is a line the *renderer* drew, so reading a value off it reports
the drawing rather than the signal. A host whose x is a continuous quantity it is willing to
interpolate (a smooth analytic curve, a resampled series) states `Interpolated` and gets the
blend, exactly at the pointer. Either way the value is a **stored** sample or a blend of two of
them, never an LOD bucket's aggregate (see
[data-and-performance.md](data-and-performance.md)).
`CrosshairRow` carries both halves: `x` and `y` are the data coordinate the value belongs to —
the quoted sample's own in `NearestSample`, the crosshair's x in `Interpolated` — and `marker`
is that point in screen space. `Plot::crosshair_point(trace, x, out_x, out_y)` is the same
lookup for a host that draws instruments of its own; `Plot::crosshair_value()` is it with the x
discarded.
The mark is drawn `Style::crosshair_marker_px` across (9 logical px) at
`crosshair_marker_width`, both deliberately **larger than a trace's own sample dots**
(`marker_px`, 3): in `NearestSample` the mark lands *among* those dots, and at the same size it
would read as one more sample instead of as the one the panel is quoting. `0` across draws no
mark.
A row whose trace has no sample at the crosshair's x is listed with the em dash `"—"` and no
marker, rather than a fabricated value.
## The gesture table (exactly as implemented)