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

+14
View File
@@ -410,4 +410,18 @@ const FigureLayout& Figure::layout() {
return layout_;
}
// ----------------------------------------------------------------- crosshair
void Figure::set_crosshair(f64 x, bool active) {
// One x, every row: the readouts are synced because they are told the same coordinate, not
// because a renderer copies one row's to the others. `Plot::set_crosshair()` refuses a
// coordinate that is not a number, so a garbage value leaves every row's crosshair as it
// was rather than clearing some rows and not others.
for (Plot& row : plots_) row.set_crosshair(x, active);
}
void Figure::clear_crosshair() {
for (Plot& row : plots_) row.clear_crosshair();
}
} // namespace plot
+340 -21
View File
@@ -3,6 +3,7 @@
#include <algorithm>
#include <cmath>
#include <cstdio>
namespace plot {
namespace {
@@ -133,6 +134,77 @@ bool fit_axis_range(Axis& axis, Range bounds) {
return true;
}
/// How far the crosshair's readout panel is held off the crosshair, in **logical** pixels:
/// the hand's own distance, so a panel is the same distance from the pointer on a 2x display
/// as on a 1x one. It is also the offset the edge flip mirrors, so the panel appears on the
/// other side of the crosshair by the same amount rather than jumping across it.
constexpr f32 kCrosshairPanelOffsetLogical = 12.0f;
/// The chrome a **readout panel** is built from, in device pixels: what the padding inside
/// the panel is, what a row's swatch is, the gap between two rows, and the two heights a row
/// is measured in.
///
/// One struct rather than a set of locals per panel, because the legend and the crosshair's
/// readout are the same object: a padding, a swatch, a line of text and a gap between rows.
/// Two panels sized by two copies of that arithmetic is how features that are meant to look
/// like siblings drift apart, and the readout would then need its own fit rule, its own
/// placement rule and its own row arithmetic as well.
struct ReadoutChrome {
f32 pad = 0.0f; ///< panel edge to content, and the gutter between a row's runs.
f32 swatch_w = 0.0f; ///< the colour swatch's box.
f32 swatch_h = 0.0f;
f32 row_gap = 0.0f; ///< blank space between two rows.
f32 line_h = 0.0f; ///< one text line, as the backend measures it.
f32 row_h = 0.0f; ///< `max(swatch_h, line_h)`: a small swatch is centred, not stretched.
};
/// The chrome of a panel styled by `cfg` -- the plot's `Legend`, whose knobs are the
/// *panel's*: both panels spend them -- measured against `text` at the plot's own scale.
/// Every size in `cfg` is logical, so each is converted exactly once, here.
ReadoutChrome readout_chrome(const Legend& cfg, const render::Style& style,
const render::TextBackend& text) {
ReadoutChrome chrome;
chrome.pad = style.device(cfg.pad);
chrome.swatch_w = style.device(cfg.swatch_w);
chrome.swatch_h = style.device(cfg.swatch_h);
chrome.row_gap = style.device(cfg.row_gap);
chrome.line_h = text.height();
chrome.row_h = std::max(chrome.swatch_h, chrome.line_h);
return chrome;
}
/// The height `rows` rows occupy: one padding top and bottom, the rows, and a gap between
/// every pair of them.
f32 readout_panel_height(const ReadoutChrome& chrome, u32 rows) {
const f32 count = static_cast<f32>(rows);
return 2.0f * chrome.pad + count * chrome.row_h + (count - 1.0f) * chrome.row_gap;
}
/// The width a panel needs for a content column `content_px` wide: a padding, the swatch, the
/// gutter after it, the content, and the closing padding.
f32 readout_panel_width(const ReadoutChrome& chrome, f32 content_px) {
return 2.0f * chrome.pad + chrome.swatch_w + chrome.pad + content_px;
}
/// The top of row `index`: one padding in, and one row plus one gap per row before it.
f32 readout_row_top(const ReadoutChrome& chrome, f32 panel_y, u32 index) {
return panel_y + chrome.pad + static_cast<f32>(index) * (chrome.row_h + chrome.row_gap);
}
/// A row's swatch box, centred in the row's height.
Rect readout_swatch(const ReadoutChrome& chrome, const Rect& panel, u32 index) {
return Rect{panel.x + chrome.pad,
readout_row_top(chrome, panel.y, index) + 0.5f * (chrome.row_h - chrome.swatch_h),
chrome.swatch_w, chrome.swatch_h};
}
/// The pen a row's first text run is staged at: one swatch and one gutter in, one text line
/// centred in the row's height.
Vec2 readout_text_pen(const ReadoutChrome& chrome, const Rect& panel, u32 index) {
return Vec2{panel.x + chrome.pad + chrome.swatch_w + chrome.pad,
readout_row_top(chrome, panel.y, index) + 0.5f * (chrome.row_h - chrome.line_h)};
}
} // namespace
u32 Plot::add_x_axis(const AxisDesc& desc) {
@@ -806,6 +878,10 @@ const PlotLayout& Plot::layout() {
// interval map to the pixel columns the frame will actually use.
build_regions();
build_legend();
// The crosshair last: its panel is fitted into `axes` and its lines are held inside it, and
// its rows are the interpolated values at a data x -- so it wants the transforms the
// settled layout pins, exactly as the legend and the regions do.
build_crosshair();
return layout_;
}
@@ -927,17 +1003,11 @@ void Plot::build_legend() {
// plot that lays out exactly as it did before legends existed.
if (text_ == nullptr || !legend_.visible) return;
// Every one of these is a logical size -- the legend is chrome a user reads --
// while `text_->height()` and the name measures are device: converting each one
// once, into a local, is what keeps the panel's arithmetic in device pixels
// throughout, like the strips it is fitted into.
const f32 pad = style_.device(legend_.pad);
// The panel's own chrome -- padding, swatch, row gap, line height -- measured once, in
// device pixels, by the one helper the crosshair's readout uses too: the two panels are
// the same object, so they cannot be sized by two copies of this arithmetic.
const ReadoutChrome chrome = readout_chrome(legend_, style_, *text_);
const f32 margin = style_.device(legend_.margin);
const f32 swatch_w = style_.device(legend_.swatch_w);
const f32 swatch_h = style_.device(legend_.swatch_h);
const f32 row_gap = style_.device(legend_.row_gap);
const f32 line_h = text_->height();
const f32 row_h = std::max(swatch_h, line_h);
// Entry widths, prefix-maxed: dropping from the end then costs no re-measure, and
// the panel's width after a drop is the width of an entry that is still listed.
legend_name_px_.clear();
@@ -970,9 +1040,9 @@ void Plot::build_legend() {
const bool truncated = keep < entries;
const f32 widest = truncated ? std::max(legend_name_px_[keep - 1u], ellipsis_w)
: legend_name_px_[keep - 1u];
const f32 rows = static_cast<f32>(keep + (truncated ? 1u : 0u));
panel_w = 2.0f * pad + swatch_w + pad + widest;
panel_h = 2.0f * pad + rows * row_h + (rows - 1.0f) * row_gap;
const u32 rows = keep + (truncated ? 1u : 0u);
panel_w = readout_panel_width(chrome, widest);
panel_h = readout_panel_height(chrome, rows);
if (panel_w <= room_w && panel_h <= room_h) break;
if (--keep == 0u) return; // not even one entry and its marker fit
}
@@ -995,18 +1065,17 @@ void Plot::build_legend() {
// The name box starts one swatch and one gutter in -- exactly what the panel's
// width allowed for -- and both boxes are centred in the row's height, so a swatch
// shorter than the text line is centred on it instead of sitting on its baseline.
const f32 name_x = x + pad + swatch_w + pad;
f32 row_y = y + pad;
for (u32 i = 0; i < traces_.size() && legend.rows.size() < keep; ++i) {
u32 placed = 0;
for (u32 i = 0; i < traces_.size() && placed < keep; ++i) {
const Trace& trace = traces_[i];
if (!trace.visible() || trace.name.empty()) continue;
LegendRow row;
row.trace = i;
row.swatch = Rect{x + pad, row_y + 0.5f * (row_h - swatch_h), swatch_w, swatch_h};
row.name = Rect{name_x, row_y + 0.5f * (row_h - line_h),
text_->measure(trace.name.c_str()), line_h};
row.swatch = readout_swatch(chrome, legend.panel, placed);
const Vec2 pen = readout_text_pen(chrome, legend.panel, placed);
row.name = Rect{pen.x, pen.y, text_->measure(trace.name.c_str()), chrome.line_h};
legend.rows.push_back(row);
row_y += row_h + row_gap;
++placed;
}
if (keep < entries) {
// Entries were dropped; the marker takes the row after the last kept one, with no
@@ -1014,7 +1083,8 @@ void Plot::build_legend() {
// lines up with the names above it.
LegendRow marker;
marker.ellipsis = true;
marker.name = Rect{name_x, row_y + 0.5f * (row_h - line_h), ellipsis_w, line_h};
const Vec2 pen = readout_text_pen(chrome, legend.panel, placed);
marker.name = Rect{pen.x, pen.y, ellipsis_w, chrome.line_h};
legend.rows.push_back(marker);
}
}
@@ -1072,4 +1142,253 @@ void Plot::build_regions() {
}
}
// --- crosshair (spec §15) --------------------------------------------------
CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept {
CrosshairSpan span;
const u64 n = series.size();
const f64 x0 = series.x0();
const f64 dx = series.dx();
// No samples, no grid, no finite origin: nothing to interpolate between, and `index`
// below would be a division by zero or a NaN -- whose floor is undefined -- rather than a
// sample index. `!(dx > 0.0)` is also the NaN test for `dx`.
if (n == 0u || !(dx > 0.0) || !std::isfinite(x0) || !std::isfinite(x)) return span;
// The window is closed at both ends -- the oldest and the newest sample are samples too --
// and a query outside it has no neighbours to interpolate between. Answering with the
// nearest sample's value instead is the clamped lie this refuses: the readout would report
// a number as if it were under the pointer when the series does not reach that far.
const f64 newest = x0 + static_cast<f64>(n - 1u) * dx;
if (x < x0 || x > newest) return span;
// Index arithmetic, not a search: the grid is uniform, so the sample at or before `x` is
// `floor((x - x0) / dx)` and there is nothing to look up. Two special cases keep the span
// honest at the ends and under rounding -- an index past the last sample collapses onto
// it, and a `t` that rounds up to 1.0 (a query one ulp above a sample) collapses onto the
// sample it has reached rather than reporting an interpolation of width zero.
const f64 index = (x - x0) / dx;
const u64 i = index <= 0.0 ? 0u : static_cast<u64>(std::floor(index));
if (i >= n - 1u) {
span.begin = span.end = n - 1u;
span.t = 0.0;
span.valid = true;
return span;
}
const f64 t = index - static_cast<f64>(i);
if (!(t > 0.0)) {
span.begin = span.end = i; // exactly on sample `i`
} else if (t >= 1.0) {
span.begin = span.end = i + 1u; // exactly on the sample after it
} else {
span.begin = i;
span.end = i + 1u;
span.t = t;
}
span.valid = true;
return span;
}
std::string crosshair_unnamed_label(u32 trace) {
char buf[16];
std::snprintf(buf, sizeof buf, "#%u", static_cast<unsigned>(trace));
return std::string(buf);
}
void Plot::set_crosshair(f64 x, bool active) {
// A coordinate that is not a number is not a position: it has no column to draw and no
// value to read, and its floor as a sample index is undefined. Refused, like
// `set_x_offset()` refuses one, rather than stored for the renderer to trip over -- the
// crosshair stays where it was, which is a state the pointer can move it out of.
if (active && !std::isfinite(x)) return;
crosshair_.x = x;
crosshair_.active = active;
// One coordinate, one line: the vertical column of `x`, its markers and the readout. There
// is no y to draw a row at, and the readout anchors itself at the top of the axes instead
// of at a value the caller never gave (see `build_crosshair()`).
crosshair_.vertical = true;
crosshair_.horizontal = false;
}
void Plot::set_crosshair(f64 x, f64 y, bool active) {
if (active && (!std::isfinite(x) || !std::isfinite(y))) return;
set_crosshair(x, active);
crosshair_.y = y;
crosshair_.horizontal = true;
}
bool Plot::crosshair_value(u32 trace, f64 x, f64& out_y) const {
if (trace >= traces_.size()) return false;
const data::Series* series = traces_[trace].series;
if (series == nullptr) return false;
const CrosshairSpan span = crosshair_span(*series, x);
if (!span.valid) return false;
// The samples themselves: `Series::gather()` at level 0 is the one public way to a raw
// sample, and it appends `(x, y)` pairs -- one pair when the query landed on a sample, two
// when it fell between them. The scratch is cleared and refilled rather than reallocated,
// and sized for two pairs from construction, so the pointer path allocates nothing.
crosshair_scratch_.clear();
series->gather(0u, span.begin, span.end + 1u, crosshair_scratch_);
const usize pairs = static_cast<usize>(span.end - span.begin + 1u);
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]);
out_y = crosshair_interpolate(y_begin, y_end, span.t);
return true;
}
void Plot::build_crosshair() {
CrosshairLayout& crosshair = layout_.crosshair;
crosshair.active = crosshair_.active;
crosshair.vertical = Rect{};
crosshair.horizontal = Rect{};
crosshair.panel = Rect{};
if (!crosshair_.active) {
crosshair.rows.clear();
return;
}
const Rect& axes = layout_.axes;
const Transform tx = x_transform(0u);
const Transform ty = y_transform(0u);
// A coordinate held inside a pair of bounds that may be inverted or closer together than
// the stroke: `axes` is whatever the host gave the plot, and `std::clamp` is undefined for
// an unordered pair.
const auto inside = [](f32 v, f32 lo, f32 hi) {
return std::clamp(v, std::min(lo, hi), std::max(lo, hi));
};
// The two lines, each a stroke box held *inside* the axes by half its own width. The
// renderer stages the stroke across the box's centre line, so this is the whole placement
// rule: the box is where the line may be drawn, and Task 1's clip never has to trim one
// that already fits. The lines are placed through the primary axis pair, like the grid and
// a region's fill -- one crosshair is one column and one row of the data area.
const f32 stroke = style_.device(style_.line_width_px);
const f32 half = 0.5f * stroke;
if (crosshair_.vertical) {
const f32 at =
inside(static_cast<f32>(tx.to_screen_x(crosshair_.x)), axes.x + half, axes.right() - half);
crosshair.vertical = Rect{at - half, axes.y, stroke, axes.h};
}
if (crosshair_.horizontal) {
const f32 at =
inside(static_cast<f32>(ty.to_screen_y(crosshair_.y)), axes.y + half, axes.bottom() - half);
crosshair.horizontal = Rect{axes.x, at - half, axes.w, stroke};
}
// The rows: one per *visible* trace -- a trace with no series or no samples is not under
// the pointer, so it has nothing to say -- named or not, because the readout is about what
// the pointer is over and not about identity. The vector is filled in place and its entries
// reused, strings and all, so a steady-state frame's readout allocates nothing.
u32 count = 0;
for (u32 i = 0; i < traces_.size(); ++i) {
const Trace& trace = traces_[i];
if (!trace.visible()) continue;
if (count == crosshair.rows.size()) crosshair.rows.emplace_back();
CrosshairRow& row = crosshair.rows[count++];
row.trace = i;
row.valid = false;
row.listed = false;
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);
row.value.clear();
// The value, and with it the marker: a trace with no sample at this x writes no number
// and gets no marker, rather than a value clamped to the end of its series.
if (crosshair_value(i, crosshair_.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
// tick labels beside it -- the rule a horizontal ruler's label already follows.
y_axis(trace.axis_y).format_value(row.y, value, sizeof value);
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)),
static_cast<f32>(y_transform(trace.axis_y).to_screen_y(row.y))};
}
}
crosshair.rows.resize(count);
// The panel needs text to measure and a row to list; the lines and the markers above do
// not, which is why this is where they part company.
if (count == 0u || !crosshair_.show_readout || text_ == nullptr) return;
const ReadoutChrome chrome = readout_chrome(legend_, style_, *text_);
// Each row's content: its label, the gutter, and its value when it has one -- an invalid
// row is narrower, and the panel is sized by the widest row that will be listed, which the
// prefix-max reads in O(1) after a row was dropped from the end.
crosshair_content_px_.clear();
for (const CrosshairRow& row : crosshair.rows) {
f32 content = text_->measure(row.label.c_str());
if (row.valid) content += chrome.pad + text_->measure(row.value.c_str());
crosshair_content_px_.push_back(crosshair_content_px_.empty()
? content
: std::max(crosshair_content_px_.back(), content));
}
// The room inside `axes`, held off its edges by the panel's own margin -- the inset the
// legend is fitted into and the budget this panel has, one number for both panels. No room
// at all is no panel at all: a plot too small for a panel is not a place to draw one.
const f32 margin = style_.device(legend_.margin);
const f32 room_w = axes.w - 2.0f * margin;
const f32 room_h = axes.h - 2.0f * margin;
if (!(room_w > 0.0f) || !(room_h > 0.0f)) return;
// Rows are dropped from the end until the panel fits, as a legend's entries are: a panel
// that has to overhang to list the last trace would be drawn over a label strip, which is
// the defect Task 1 fixed. The dropped rows keep their markers -- only the list is cut.
u32 keep = count;
f32 panel_w = 0.0f;
f32 panel_h = 0.0f;
for (;;) {
panel_w = readout_panel_width(chrome, crosshair_content_px_[keep - 1u]);
panel_h = readout_panel_height(chrome, keep);
if (panel_w <= room_w && panel_h <= room_h) break;
if (--keep == 0u) return; // not even one row fits
}
// Beside the pointer: one offset down and to the right of it, flipped on an axis where
// that would leave the room, and finally clamped into it. With the fitted size the two
// clamp bounds are ordered, so "the panel is inside `axes`" is arithmetic rather than
// hope -- and the flip is what keeps it *near* the pointer rather than pinned to a corner
// whenever the crosshair is in the right or the bottom half of the plot.
const f32 offset = style_.device(kCrosshairPanelOffsetLogical);
const f32 anchor_x = inside(static_cast<f32>(tx.to_screen_x(crosshair_.x)), axes.x, axes.right());
// A vertical-only crosshair has no pointer row, so the panel hangs from the top of the
// axes: there is no `y` a caller gave it and inventing one would place the panel at a value
// nothing points at.
const f32 anchor_y = crosshair_.horizontal
? inside(static_cast<f32>(ty.to_screen_y(crosshair_.y)), axes.y,
axes.bottom())
: axes.y + margin;
f32 px = anchor_x + offset;
f32 py = anchor_y + offset;
if (px + panel_w > axes.right() - margin) px = anchor_x - offset - panel_w;
if (py + panel_h > axes.bottom() - margin) py = anchor_y - offset - panel_h;
px = inside(px, axes.x + margin, axes.right() - margin - panel_w);
py = inside(py, axes.y + margin, axes.bottom() - margin - panel_h);
crosshair.panel = Rect{px, py, panel_w, panel_h};
// The listed rows' boxes, placed here rather than left to a renderer to re-derive (one
// placement rule, one place to read it). Values are right-aligned on the panel's inner
// edge, which is what makes a column of numbers readable; the panel's width was measured
// from label plus gutter plus value, so the two runs cannot meet even on the widest row.
for (u32 i = 0; i < keep; ++i) {
CrosshairRow& row = crosshair.rows[i];
row.listed = true;
row.swatch = readout_swatch(chrome, crosshair.panel, i);
const Vec2 pen = readout_text_pen(chrome, crosshair.panel, i);
row.label_box = Rect{pen.x, pen.y, text_->measure(row.label.c_str()), chrome.line_h};
if (row.valid) {
const f32 width = text_->measure(row.value.c_str());
row.value_box =
Rect{crosshair.panel.right() - chrome.pad - width, pen.y, width, chrome.line_h};
}
}
}
} // namespace plot