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
@@ -2068,6 +2068,134 @@ PLOT_TEST(crosshair_value_is_the_nearest_sample) {
|
||||
PLOT_CHECK(crosshair.rows[0].value == "10");
|
||||
}
|
||||
|
||||
/// The mark is **snapped to the sample the row quotes**, which is what makes it say *which*
|
||||
/// value is being read. Placing it at the pointer's x with the sample's y put it on neither the
|
||||
/// data nor the polyline, and the further a reader zoomed in the further off it sat -- a mark on
|
||||
/// nothing.
|
||||
///
|
||||
/// The queries here are all **off** a sample on purpose, so "snapped" has something to be wrong
|
||||
/// about: the assertion is that `row.x` is a `tens` grid position (0, 1, 2, 3) and not the query,
|
||||
/// and that `row.marker` is that position mapped through the trace's own axes. The pixel column
|
||||
/// is computed from the transform the plot already exposes, so this checks the two agree rather
|
||||
/// than re-deriving the mapping.
|
||||
PLOT_TEST(the_crosshair_marker_snaps_to_the_sample_it_reports) {
|
||||
CrosshairScene scene;
|
||||
const u32 trace = scene.plot.add_trace(
|
||||
{&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay, "sine"});
|
||||
|
||||
// A query between samples 1 and 2, nearer 2: the point is sample 2 entire -- x = 2 as well as
|
||||
// y = 20 -- so the mark lands on the datum and not a fifth of a division to its left.
|
||||
f64 px = 0.0;
|
||||
f64 py = 0.0;
|
||||
PLOT_CHECK(scene.plot.crosshair_point(trace, 1.8, px, py));
|
||||
PLOT_CHECK_NEAR(px, 2.0, 1e-12);
|
||||
PLOT_CHECK_NEAR(py, 20.0, 1e-9);
|
||||
// The tie takes the earlier sample, and takes *its* x with it: the same bias in both
|
||||
// coordinates, so the mark and the number can never name different samples.
|
||||
PLOT_CHECK(scene.plot.crosshair_point(trace, 1.5, px, py));
|
||||
PLOT_CHECK_NEAR(px, 1.0, 1e-12);
|
||||
PLOT_CHECK_NEAR(py, 10.0, 1e-9);
|
||||
// `crosshair_value()` is the same call with the x dropped, so the two cannot disagree.
|
||||
f64 y_only = 0.0;
|
||||
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.5, y_only));
|
||||
PLOT_CHECK_NEAR(y_only, py, 1e-12);
|
||||
// A failing lookup leaves both outputs alone, as the value form does.
|
||||
PLOT_CHECK(!scene.plot.crosshair_point(trace, 9.0, px, py));
|
||||
PLOT_CHECK_NEAR(px, 1.0, 1e-12);
|
||||
|
||||
// And through the layout: the row carries the snapped x, and the marker is that x through the
|
||||
// trace's own transform -- not the crosshair's column, which is where it used to be.
|
||||
scene.plot.set_crosshair(1.8, true);
|
||||
scene.plot.layout();
|
||||
const plot::CrosshairRow& row = scene.plot.layout().crosshair.rows[0];
|
||||
PLOT_CHECK(row.valid);
|
||||
PLOT_CHECK_NEAR(row.x, 2.0, 1e-12);
|
||||
PLOT_CHECK_NEAR(row.y, 20.0, 1e-9);
|
||||
const f32 sample_col = static_cast<f32>(scene.plot.x_transform(scene.ax).to_screen_x(2.0));
|
||||
const f32 pointer_col = static_cast<f32>(scene.plot.x_transform(scene.ax).to_screen_x(1.8));
|
||||
PLOT_CHECK_NEAR(row.marker.x, sample_col, 1e-3);
|
||||
PLOT_CHECK_NEAR(row.marker.y,
|
||||
static_cast<f32>(scene.plot.y_transform(scene.ay).to_screen_y(20.0)), 1e-3);
|
||||
// The two columns are far apart at this scale, so the check above is not satisfied by both
|
||||
// being nearly the same number.
|
||||
PLOT_CHECK(std::abs(row.marker.x - pointer_col) > 10.0f);
|
||||
// The vertical line stays on the *pointer*: it is where the hover is, and only the mark moved.
|
||||
PLOT_CHECK_NEAR(scene.plot.layout().crosshair.vertical.x +
|
||||
0.5f * scene.plot.layout().crosshair.vertical.w,
|
||||
pointer_col, 1e-3);
|
||||
}
|
||||
|
||||
/// The other mode, and the reason it exists: `CrosshairValue::Interpolated` answers "what is the
|
||||
/// signal *at* this instant" by blending the two bracketing samples, for a caller whose data is a
|
||||
/// continuous quantity sampled finely enough that the line between two samples is a model it
|
||||
/// believes. `NearestSample` is the default because that line is an artefact of plotting for most
|
||||
/// sources -- see `CrosshairValue` -- but the question is about the data, so the library offers
|
||||
/// both rather than picking for the caller.
|
||||
///
|
||||
/// Every number here is one the *other* mode refuses, so the two are pinned apart rather than
|
||||
/// merely exercised: 15 at the tie, 11 and 19 either side of it. The marker moves with the value,
|
||||
/// which is the mode's second half -- an interpolated y belongs at the pointer's x, on the
|
||||
/// polyline, and nowhere else.
|
||||
PLOT_TEST(crosshair_interpolated_mode_blends_the_two_samples) {
|
||||
CrosshairScene scene;
|
||||
const u32 trace = scene.plot.add_trace(
|
||||
{&scene.series, plot::Color::hex(0x40C0FF), 1.5f, scene.ax, scene.ay, "sine"});
|
||||
PLOT_CHECK(scene.plot.crosshair_value_mode() == plot::CrosshairValue::NearestSample);
|
||||
scene.plot.set_crosshair_value_mode(plot::CrosshairValue::Interpolated);
|
||||
PLOT_CHECK(scene.plot.crosshair_value_mode() == plot::CrosshairValue::Interpolated);
|
||||
|
||||
f64 y = 0.0;
|
||||
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.5, y));
|
||||
PLOT_CHECK_NEAR(y, 15.0, 1e-9);
|
||||
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.1, y));
|
||||
PLOT_CHECK_NEAR(y, 11.0, 1e-9);
|
||||
PLOT_CHECK(scene.plot.crosshair_value(trace, 1.9, y));
|
||||
PLOT_CHECK_NEAR(y, 19.0, 1e-9);
|
||||
// A query *on* a sample reads that sample back exactly in this mode too: the span collapses to
|
||||
// one index, `t` is 0, and `crosshair_interpolate()`'s form returns `y_begin` unrounded.
|
||||
PLOT_CHECK(scene.plot.crosshair_value(trace, 2.0, y));
|
||||
PLOT_CHECK(y == 20.0);
|
||||
PLOT_CHECK(plot::crosshair_interpolate(10.0, 20.0, 0.0) == 10.0);
|
||||
PLOT_CHECK(plot::crosshair_interpolate(10.0, 20.0, 1.0) == 20.0);
|
||||
PLOT_CHECK_NEAR(plot::crosshair_interpolate(10.0, 20.0, 0.25), 12.5, 1e-12);
|
||||
// Out of the window is still no value: interpolating needs two samples to be between.
|
||||
PLOT_CHECK(!scene.plot.crosshair_value(trace, 4.0, y));
|
||||
|
||||
// The point is the *query's* x here, not a sample's, so the mark sits on the polyline where the
|
||||
// number is -- the opposite placement from `NearestSample`, and the mode's visible half.
|
||||
f64 px = 0.0;
|
||||
f64 py = 0.0;
|
||||
PLOT_CHECK(scene.plot.crosshair_point(trace, 1.8, px, py));
|
||||
PLOT_CHECK_NEAR(px, 1.8, 1e-12);
|
||||
PLOT_CHECK_NEAR(py, 18.0, 1e-9);
|
||||
scene.plot.set_crosshair(1.8, true);
|
||||
scene.plot.layout();
|
||||
const plot::CrosshairRow& row = scene.plot.layout().crosshair.rows[0];
|
||||
PLOT_CHECK_NEAR(row.x, 1.8, 1e-12);
|
||||
PLOT_CHECK(row.value == "18");
|
||||
const f32 pointer_col = static_cast<f32>(scene.plot.x_transform(scene.ax).to_screen_x(1.8));
|
||||
PLOT_CHECK_NEAR(row.marker.x, pointer_col, 1e-3);
|
||||
|
||||
// Switching back is a plain state change, with no second placement to keep in step: the next
|
||||
// layout reports the sample again.
|
||||
scene.plot.set_crosshair_value_mode(plot::CrosshairValue::NearestSample);
|
||||
scene.plot.layout();
|
||||
PLOT_CHECK_NEAR(scene.plot.layout().crosshair.rows[0].x, 2.0, 1e-12);
|
||||
PLOT_CHECK_NEAR(scene.plot.layout().crosshair.rows[0].y, 20.0, 1e-9);
|
||||
}
|
||||
|
||||
/// The mark has to be **bigger than a trace's own sample dots**, because in the default mode it is
|
||||
/// snapped onto one of them: at the same size it reads as one more sample rather than as the one
|
||||
/// the panel is quoting. That is a property of the defaults, not of a drawing call, so it is
|
||||
/// asserted where it is decided -- a later edit that tunes `marker_px` up past the crosshair's is
|
||||
/// the regression, and nothing else in the library would notice.
|
||||
PLOT_TEST(the_crosshair_mark_is_bigger_than_a_sample_dot) {
|
||||
const plot::render::Style style;
|
||||
PLOT_CHECK(style.crosshair_marker_px > style.marker_px);
|
||||
// And heavier than the crosshair's own lines, which are chrome and recede on purpose.
|
||||
PLOT_CHECK(style.crosshair_marker_width > style.crosshair_width);
|
||||
}
|
||||
|
||||
/// Spec §15, the readout's membership: **every visible trace gets a row, whether or not it is
|
||||
/// named** -- the readout is about what is under the pointer, so the legend's rule of skipping
|
||||
/// unnamed traces does not apply -- while a trace with no series or no samples is not under the
|
||||
|
||||
Reference in new issue
Block a user