feat(plot): crosshair with an interpolated per-signal hover readout
This commit is contained in:
1 parent
a153b73ead
commit
fd0c471430
11 files changed
+809
-22
No files matched your search
+340
-21
@@ -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
|
||||
Reference in new issue
Block a user