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:
1 parent
c028f48fa0
commit
c960decaea
9 files changed
+372
-78
No files matched your search
+41
-12
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user