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

+41 -12
View File
@@ -1410,6 +1410,13 @@ CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept {
return span;
}
f64 crosshair_interpolate(f64 y_begin, f64 y_end, f64 t) noexcept {
// This form, not `(1 - t) * a + t * b`: at `t == 0` it is `y_begin` and at `t == 1` it is
// `y_end`, both exactly, so a query that landed on a sample reads that sample back whatever
// mode is in force. The symmetric form rounds at both ends.
return y_begin + t * (y_end - y_begin);
}
std::string crosshair_unnamed_label(u32 trace) {
char buf[16];
std::snprintf(buf, sizeof buf, "#%u", static_cast<unsigned>(trace));
@@ -1439,6 +1446,11 @@ void Plot::set_crosshair(f64 x, f64 y, bool active) {
}
bool Plot::crosshair_value(u32 trace, f64 x, f64& out_y) const {
f64 ignored = 0.0;
return crosshair_point(trace, x, ignored, out_y);
}
bool Plot::crosshair_point(u32 trace, f64 x, f64& out_x, f64& out_y) const {
if (trace >= traces_.size()) return false;
const data::Series* series = traces_[trace].series;
if (series == nullptr) return false;
@@ -1455,12 +1467,23 @@ bool Plot::crosshair_value(u32 trace, f64 x, f64& out_y) const {
if (crosshair_scratch_.size() != 2u * pairs) return false; // a series that produced nothing
const f64 y_begin = static_cast<f64>(crosshair_scratch_[1u]);
const f64 y_end = static_cast<f64>(crosshair_scratch_[2u * pairs - 1u]);
// **The nearer sample, never a value between two of them.** A readout quotes the data: a
// number the series does not contain is a claim about a signal between its samples, which
// is the caller's to make and not a plot's to put in a panel. `span.t` is measured on the
// samples' own x, 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.
out_y = span.t <= 0.5 ? y_begin : y_end;
if (crosshair_.value_mode == CrosshairValue::Interpolated) {
// The signal the samples imply *at* this x, so the point is the query's own x: that is
// where an interpolated number lives, and the marker on the polyline there is honest
// about it.
out_x = x;
out_y = crosshair_interpolate(y_begin, y_end, span.t);
return true;
}
// `NearestSample`: the nearer of the two, and **its own x with it**. `span.t` is measured on
// the samples' own coordinates, so "nearer" is exact on any grid; a tie reads the earlier
// sample, which is the bias `crosshair_span()` already takes for a degenerate pair. The x
// comes back through `x_at()` rather than from the gathered pairs, which are relative to
// `x0()` by the `Series::gather()` contract -- adding it back here would be a second
// reconstruction of a coordinate the series will hand over directly.
const bool take_begin = span.t <= 0.5;
out_x = series->x_at(take_begin ? span.begin : span.end);
out_y = take_begin ? y_begin : y_end;
return true;
}
@@ -1529,17 +1552,23 @@ void Plot::build_crosshair() {
row.trace = i;
row.valid = false;
row.listed = false;
row.x = 0.0;
row.y = 0.0;
row.marker = Vec2{};
row.swatch = Rect{};
row.label_box = Rect{};
row.value_box = Rect{};
row.label.assign(trace.name.empty() ? crosshair_unnamed_label(i) : trace.name);
// The value: the nearest sample's number where the trace has one at this x, and the em dash
// where it does not. The row is listed either way, so the absence has to be *said* -- a blank
// value cell reads as a value that has not arrived -- and the dash is measured into the panel's
// width like any other value, so the row's boxes are placed by one rule.
if (crosshair_value(i, crosshair_.x, row.y)) {
// The value where the trace has one at this x, and the em dash where it does not. The row is
// listed either way, so the absence has to be *said* -- a blank value cell reads as a value
// that has not arrived -- and the dash is measured into the panel's width like any other
// value, so the row's boxes are placed by one rule.
//
// The **point**, not just the number: `crosshair_point()` reports the x the value belongs to
// as well, which in the default `NearestSample` mode is the quoted sample's own and not the
// pointer's. Both coordinates come from that one call, so the mark and the text cannot
// disagree about which datum is being read (see `CrosshairRow::marker`).
if (crosshair_point(i, crosshair_.x, row.x, row.y)) {
row.valid = true;
char value[32];
// The trace's *own* y axis formats the number, so a readout reads exactly like the
@@ -1548,7 +1577,7 @@ void Plot::build_crosshair() {
row.value.assign(value);
// Placed through the trace's own axis pair, so the marker sits where that trace is
// actually drawn however its axes are scaled.
row.marker = Vec2{static_cast<f32>(x_transform(trace.axis_x).to_screen_x(crosshair_.x)),
row.marker = Vec2{static_cast<f32>(x_transform(trace.axis_x).to_screen_x(row.x)),
static_cast<f32>(y_transform(trace.axis_y).to_screen_y(row.y))};
} else {
row.value.assign(kCrosshairNoValue);
+22 -13
View File
@@ -1522,19 +1522,28 @@ void Renderer::draw_crosshair_lines(const Plot& plot, const PlotLayout& layout)
flush_lines(data, style_.crosshair, stroke, crosshair.dash);
}
// A small cross at each trace's intersection, in the trace's own colour -- opaque, like a
// legend's swatch, so a trace drawn at 15% still has a marker that can be seen. The arm
// length is a logical size like every other marker's, and the position is the layout's: a
// marker for a sample far outside the visible y range is staged where the trace is and the
// axes clip keeps it inside, rather than being skipped by a range test of this layer's own.
const f32 arm = style_.device(3.0f);
for (const CrosshairRow& row : crosshair.rows) {
if (!row.valid) continue;
Color color = plot.trace(row.trace).color;
color.a = 1.0f;
lines_.add_segment({row.marker.x - arm, row.marker.y}, {row.marker.x + arm, row.marker.y});
lines_.add_segment({row.marker.x, row.marker.y - arm}, {row.marker.x, row.marker.y + arm});
flush_lines(pixel_mapping(), color, style_.device(1.0f));
// A cross at each trace's readout point, in the trace's own colour -- opaque, like a legend's
// swatch, so a trace drawn at 15% still has a mark that can be seen. The position is the
// layout's, and in the default `CrosshairValue::NearestSample` that is the *quoted sample*
// rather than the pointer's column: a mark for a sample outside the visible range is staged
// where that sample is and the axes clip keeps it inside, rather than being skipped by a range
// test of this layer's own.
//
// `crosshair_marker_px` is the cross' full extent and is larger than `marker_px`, which dots a
// trace's own samples at a deep zoom -- a snapped mark lands among those dots and has to read
// as the one the panel is quoting. `0` across draws nothing, so a caller can have the panel
// and the lines without the marks.
const f32 arm = 0.5f * style_.device(style_.crosshair_marker_px);
const f32 mark_stroke = style_.device(style_.crosshair_marker_width);
if (arm > 0.0f) {
for (const CrosshairRow& row : crosshair.rows) {
if (!row.valid) continue;
Color color = plot.trace(row.trace).color;
color.a = 1.0f;
lines_.add_segment({row.marker.x - arm, row.marker.y}, {row.marker.x + arm, row.marker.y});
lines_.add_segment({row.marker.x, row.marker.y - arm}, {row.marker.x, row.marker.y + arm});
flush_lines(pixel_mapping(), color, mark_stroke);
}
}
}