feat(plot): crosshair with an interpolated per-signal hover readout

This commit is contained in:
Martino Ferrari committed 2026-09-28 18:57:33 +02:00
1 parent a153b73ead
commit fd0c471430
11 files changed
+809 -22

No files matched your search

+81
View File
@@ -494,6 +494,10 @@ void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) {
draw_measures(plot, layout);
draw_annotations(plot);
draw_legend(plot, layout);
// The crosshair last of the interior passes: it is what the pointer is on, so its panel is
// drawn over the legend and every other overlay rather than under them, and its text joins
// the frame's single flush with everything else.
draw_crosshair(plot, layout);
clear_clip();
draw_tick_labels(plot, layout);
draw_titles(plot, layout);
@@ -1170,6 +1174,83 @@ void Renderer::draw_legend(const Plot& plot, const PlotLayout& layout) {
}
}
void Renderer::draw_crosshair(const Plot& plot, const PlotLayout& layout) {
const CrosshairLayout& crosshair = layout.crosshair;
if (!crosshair.active) return;
const f32 stroke = style_.device(style_.line_width_px);
// The two lines. Each is staged as one data-space segment at the *centre* of the stroke box
// the layout placed, spanning that box's own extent, so the pixels this draws are the box
// rather than a second placement of the same rule: the layout held the box inside `axes`,
// and the clip keeps the half pixel the rasteriser puts outside the box's fractional edges.
const Mapping data = mapping(plot, 0u, 0u);
if (crosshair.vertical.w > 0.0f) {
const f64 x = plot.x_transform(0u).to_data_x(
static_cast<f64>(crosshair.vertical.x + 0.5f * crosshair.vertical.w));
const Range y_range = plot.y_range(0u);
stage_segment(x, y_range.min, x, y_range.max, plot.x_axis(0u).log, plot.y_axis(0u).log);
flush_lines(data, style_.crosshair, stroke);
}
if (crosshair.horizontal.h > 0.0f) {
const f64 y = plot.y_transform(0u).to_data_y(
static_cast<f64>(crosshair.horizontal.y + 0.5f * crosshair.horizontal.h));
const Range x_range = plot.x_range(0u);
stage_segment(x_range.min, y, x_range.max, y, plot.x_axis(0u).log, plot.y_axis(0u).log);
flush_lines(data, style_.crosshair, stroke);
}
// 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));
}
// The readout: the same backplate a legend has -- the frame's background at a partial
// alpha, so the data stays legible through it -- with its border in the crosshair's own
// colour, which is what makes the panel read as part of the instrument that placed it
// rather than as a second legend.
if (crosshair.panel.w > 0.0f && crosshair.panel.h > 0.0f) {
const Rect& panel = crosshair.panel;
Color background = style_.background;
background.a *= kLegendPanelAlpha;
fill_pixels(panel, background);
// The crisp-hairline rule of the legend's border: the outermost *lit* columns and rows of
// the fill's span, not the measured rect's fractional edges, so all four sides are one
// hairline rather than three bright ones and a dim one.
const f32 left = std::ceil(panel.x - 0.5f) + 0.5f;
const f32 right = std::ceil(panel.right() - 0.5f) - 0.5f;
const f32 top = std::ceil(panel.y - 0.5f) + 0.5f;
const f32 bottom = std::ceil(panel.bottom() - 0.5f) - 0.5f;
lines_.add_segment({left, top}, {right, top});
lines_.add_segment({left, bottom}, {right, bottom});
lines_.add_segment({left, top}, {left, bottom});
lines_.add_segment({right, top}, {right, bottom});
flush_lines(pixel_mapping(), style_.crosshair, style_.device(1.0f));
}
for (const CrosshairRow& row : crosshair.rows) {
if (!row.listed) continue;
// The swatch is the trace's colour opaque, as a legend's is: it is the one element that
// says which signal the row is about.
Color swatch = plot.trace(row.trace).color;
swatch.a = 1.0f;
fill_pixels(row.swatch, swatch);
stage_text({row.label_box.x, row.label_box.y}, row.label.c_str());
// A row with no sample at this x has no value to write -- and it is *not* written as a
// zero or a dash: the box is empty and the number is simply absent (see CrosshairRow).
if (row.valid) stage_text({row.value_box.x, row.value_box.y}, row.value.c_str());
}
}
// -------------------------------------------------------------------- dump
bool Renderer::dump_frame_ppm(const char* path, Status* st) const {