crosshair: axis badges, clustered cursor labels, and the shared-y horizontal line
- Axis badges: the y value in the y label strip and the x time in the x strip, each a full-opacity panel in the crosshair's colour, **centred on the line it names**. The tick text is flushed before the fill -- the over-text batch is flushed after every line batch, so without that flush the label behind printed through the panel (the "transparent backplate" defect). - Cursor labels: collected across the cursor loop and clustered at its end, so two marks in one column share a panel with one line each instead of overprinting, and a label stays on its own mark's row. - Figure: a split row's cells share one y (`set_share_row_y`), so the horizontal line is drawn in every cell of the hovered row -- one value, one scale, spanning the row -- and still in no other row. Tests: `figure_crosshair_horizontal_spans_a_split_rows_cells`, plus the render-smoke additions for the badge placement/flush and the clustered labels.
This commit is contained in:
1 parent
2cb31cbb91
commit
1d5b286611
9 files changed
+469
-49
No files matched your search
@@ -819,6 +819,21 @@ void Figure::set_crosshair(f64 x, u32 hovered_row, f64 y_in_row, bool active) {
|
||||
// than clearing a hover the pointer still has.
|
||||
if (!active || hovered_row >= plots_.size()) return;
|
||||
plots_[hovered_row].set_crosshair(x, y_in_row, true);
|
||||
// **The cells of a split row share one y**, so the horizontal line means the same value in
|
||||
// every one of them: it spans the row rather than stopping at the divider between two cells
|
||||
// that read the same scale. Only that row -- rows do not share y with each other, so a
|
||||
// sibling row's line would be a value on a scale it does not have -- and only when the row
|
||||
// really is split (`set_share_row_y`, which is what makes the y common in the first place).
|
||||
if (!share_row_y_) return;
|
||||
const u32 row = row_of_plot(hovered_row);
|
||||
if (row >= cells_per_row_.size() || cells_per_row_[row] <= 1u) return;
|
||||
const u32 first = row_first_[row];
|
||||
for (u32 k = 0; k < cells_per_row_[row]; ++k) {
|
||||
const u32 cell = first + k;
|
||||
if (cell != hovered_row && cell < plots_.size()) {
|
||||
plots_[cell].set_crosshair(x, y_in_row, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Figure::clear_crosshair() {
|
||||
|
||||
@@ -311,6 +311,11 @@ void Plot::set_cursor_color(u32 id, Color color) {
|
||||
cursors_[id].color = color;
|
||||
}
|
||||
|
||||
void Plot::set_cursor_label(u32 id, std::string label) {
|
||||
if (id >= cursors_.size()) return;
|
||||
cursors_[id].label = std::move(label);
|
||||
}
|
||||
|
||||
void Plot::set_cursor_stroke(u32 id, f32 width, Vec2 dash) {
|
||||
if (id >= cursors_.size()) return;
|
||||
cursors_[id].width = width;
|
||||
@@ -1888,6 +1893,14 @@ void Plot::build_crosshair() {
|
||||
const auto inside = [](f32 v, f32 lo, f32 hi) {
|
||||
return std::clamp(v, std::min(lo, hi), std::max(lo, hi));
|
||||
};
|
||||
crosshair.y_axis_badge = Rect{};
|
||||
crosshair.y_axis_text.clear();
|
||||
crosshair.x_axis_badge = Rect{};
|
||||
crosshair.x_axis_text.clear();
|
||||
if (!crosshair_.active) {
|
||||
crosshair.rows.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -1918,6 +1931,58 @@ void Plot::build_crosshair() {
|
||||
crosshair.horizontal = Rect{axes.x, at - half, axes.w, stroke};
|
||||
}
|
||||
|
||||
// The axis badges, when asked for (`Crosshair::axis_labels`): the values the two lines
|
||||
// meet the axes at, written in the strips the tick labels are written in and in each
|
||||
// axis' own format, so a badge reads exactly like the tick beside it. Placed here rather
|
||||
// than by the renderer, for the readout panel's reason -- one placement rule, one place to
|
||||
// read it -- and left empty whenever there is nothing honest to place: no text backend to
|
||||
// measure with, the line they belong to not drawn (a vertical-only crosshair has no y),
|
||||
// or their strip absent or too small (a row whose x strip went to the row below, a y
|
||||
// gutter collapsed to a title). The x badge is held to the axes' own columns as well, so
|
||||
// a crosshair in the leftmost cell of a split row cannot badge into its neighbour's.
|
||||
if (crosshair_.axis_labels && text_ != nullptr) {
|
||||
const f32 badge_pad = style_.device(4.0f);
|
||||
const f32 badge_h = text_->height() + 2.0f * badge_pad;
|
||||
if (crosshair_.horizontal && layout_.y_label_area.w > 0.0f &&
|
||||
layout_.y_label_area.h >= badge_h) {
|
||||
char text[64];
|
||||
y_axis(0u).format_value(crosshair_.y, text, sizeof text);
|
||||
crosshair.y_axis_text.assign(text);
|
||||
const f32 w = text_->measure(crosshair.y_axis_text.c_str()) + 2.0f * badge_pad;
|
||||
// Right-aligned on the axis line -- where the y ticks end -- and **centred** on the
|
||||
// horizontal line's own height, the way the x badge is centred on its column: the badge
|
||||
// names the line it sits beside, and a badge whose top edge *is* that line reads as
|
||||
// belonging to the tick below it. A badge is as wide as its own text and is capped by
|
||||
// nothing else, so it
|
||||
// is held within the **left strips' union**: the label strip first, the title strip
|
||||
// beside it when there is one, and never over the data or off the viewport's left
|
||||
// edge. A `std::clamp` on an inverted pair is undefined, which is exactly the state
|
||||
// "the strip is narrower than the badge" produces -- hence the ordered min/max chain.
|
||||
const f32 left = layout_.y_title_area.w > 0.0f ? layout_.y_title_area.x
|
||||
: layout_.y_label_area.x;
|
||||
f32 bx = layout_.y_label_area.right() - w;
|
||||
if (bx < left) bx = left;
|
||||
bx = std::min(bx, axes.x - w);
|
||||
if (bx < left) bx = left; // a badge wider than the union stays at its left edge
|
||||
const f32 at = inside(static_cast<f32>(ty.to_screen_y(crosshair_.y)) - 0.5f * badge_h,
|
||||
layout_.y_label_area.y + badge_pad,
|
||||
layout_.y_label_area.bottom() - badge_pad - badge_h);
|
||||
crosshair.y_axis_badge = Rect{bx, at, w, badge_h};
|
||||
}
|
||||
if (crosshair_.vertical && layout_.x_label_area.w > 0.0f &&
|
||||
layout_.x_label_area.h >= badge_h) {
|
||||
char text[64];
|
||||
x_axis(0u).format_value(crosshair_.x, text, sizeof text);
|
||||
crosshair.x_axis_text.assign(text);
|
||||
const f32 w = text_->measure(crosshair.x_axis_text.c_str()) + 2.0f * badge_pad;
|
||||
const f32 column =
|
||||
inside(static_cast<f32>(tx.to_screen_x(crosshair_.x)), axes.x, axes.right());
|
||||
const f32 x = inside(column - 0.5f * w, layout_.x_label_area.x,
|
||||
layout_.x_label_area.right() - w);
|
||||
crosshair.x_axis_badge = Rect{x, layout_.x_label_area.y, w, badge_h};
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
+176
-26
@@ -565,6 +565,10 @@ void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) {
|
||||
clear_clip();
|
||||
draw_tick_labels(plot, layout);
|
||||
draw_titles(plot, layout);
|
||||
// The crosshair's axis badges, last of all: they sit in the label strips among the tick
|
||||
// labels, so they are drawn after them and unclipped -- and over them, which is the point
|
||||
// (a badge the ticks print through is two numbers in one place).
|
||||
draw_crosshair_axis_badges(layout);
|
||||
}
|
||||
|
||||
void Renderer::end_frame() noexcept {
|
||||
@@ -1675,6 +1679,9 @@ void Renderer::draw_cursors(const Plot& plot, const PlotLayout& layout) {
|
||||
// does not depend on the marker's own colour differing from the accent -- a cursor
|
||||
// created in the accent colour is otherwise indistinguishable from an idle one.
|
||||
const HotHandles hot = plot.hot_handles();
|
||||
// Collected across the loop below and drawn clustered at its end -- see
|
||||
// `draw_clustered_cursor_labels()` for why one panel can carry several lines.
|
||||
std::vector<CursorLabel> labels;
|
||||
|
||||
for (u32 i = 0; i < plot.cursor_count(); ++i) {
|
||||
const Cursor& cursor = plot.cursor(i);
|
||||
@@ -1730,34 +1737,143 @@ void Renderer::draw_cursors(const Plot& plot, const PlotLayout& layout) {
|
||||
flush_lines(mapping(plot, cursor.axis_x, 0, x_drawn),
|
||||
on ? style_.cursor : cursor.color, width, cursor.dash);
|
||||
}
|
||||
draw_cursor_label(plot, layout, cursor, drawn);
|
||||
// The labels are drawn after the strokes, clustered: collecting them all first is what
|
||||
// lets two marks in the same column share one panel instead of overprinting each other.
|
||||
collect_cursor_label(plot, layout, cursor, drawn, labels);
|
||||
}
|
||||
draw_clustered_cursor_labels(layout, labels);
|
||||
}
|
||||
|
||||
void Renderer::draw_cursor_label(const Plot& plot, const PlotLayout& layout, const Cursor& cursor,
|
||||
f32 at) {
|
||||
const f32 gap = style_.device(3.0f);
|
||||
void Renderer::collect_cursor_label(const Plot& plot, const PlotLayout& layout,
|
||||
const Cursor& cursor, f32 at,
|
||||
std::vector<CursorLabel>& labels) {
|
||||
(void)layout;
|
||||
if (cursor.is_horizontal()) {
|
||||
// A ruler's text is the value it is *for*, written the way the y axis writes its own
|
||||
// tick labels, so a value read off a ruler reads exactly like the tick beside it. The
|
||||
// caller's caption, when there is one, is staged first on the same line, so neither the
|
||||
// caption nor the value has to be dropped -- and both stay one text run's worth of
|
||||
// glyphs, since the pen advances per glyph either way.
|
||||
const Vec2 pen{layout.axes.x + gap, at + style_.device(2.0f)};
|
||||
// caller's caption, when there is one, leads the same line.
|
||||
char value[32];
|
||||
plot.y_axis(cursor.axis_y).format_value(cursor.y, value, sizeof value);
|
||||
if (cursor.label.empty()) {
|
||||
stage_text(glyphs_under_, pen, value, style_.axis_text);
|
||||
return;
|
||||
}
|
||||
stage_text(glyphs_under_, pen, cursor.label.c_str(), style_.axis_text);
|
||||
stage_text(glyphs_under_, {pen.x + font_backend_.measure(cursor.label.c_str()) + gap, pen.y},
|
||||
value, style_.axis_text);
|
||||
const std::string text =
|
||||
cursor.label.empty() ? std::string(value) : cursor.label + " " + value;
|
||||
CursorLabel label;
|
||||
label.text = text;
|
||||
label.border = cursor.color;
|
||||
label.anchor = at; // the ruler's own row: the badge is centred on it
|
||||
label.w = font_backend_.measure(text.c_str());
|
||||
label.horizontal = true;
|
||||
labels.push_back(std::move(label));
|
||||
return;
|
||||
}
|
||||
if (!cursor.label.empty()) {
|
||||
stage_text(glyphs_under_, {at + gap, layout.axes.y + style_.device(2.0f)},
|
||||
cursor.label.c_str(), style_.axis_text);
|
||||
if (cursor.label.empty()) return;
|
||||
CursorLabel label;
|
||||
label.text = cursor.label;
|
||||
label.border = cursor.color;
|
||||
label.anchor = at; // the marker's own column: the badge is centred on it
|
||||
label.w = font_backend_.measure(cursor.label.c_str());
|
||||
labels.push_back(std::move(label));
|
||||
}
|
||||
|
||||
void Renderer::draw_clustered_cursor_labels(const PlotLayout& layout,
|
||||
const std::vector<CursorLabel>& labels) {
|
||||
if (labels.empty()) return;
|
||||
const f32 pad = style_.device(3.0f);
|
||||
const f32 line_h = font_backend_.height();
|
||||
const Rect& axes = layout.axes;
|
||||
|
||||
// **Placement first, clustering second.** Each badge is centred on the mark it belongs to
|
||||
// -- a ruler's badge straddles the ruler's own row, a marker's badge straddles the
|
||||
// marker's own column -- because a badge that drifts from its mark is a number without an
|
||||
// anchor. When two badges meet, the later one drops one text line (downward from its
|
||||
// centred position) until it is clear, so a column of close rulers reads as a stack of
|
||||
// numbers in their own rows rather than as texts printed through each other; the rows keep
|
||||
// each badge beside its own mark, which a sequential stack from the panel's top would not.
|
||||
std::vector<CursorLabel> placed;
|
||||
placed.reserve(labels.size());
|
||||
std::vector<CursorLabel> by_anchor = labels;
|
||||
std::stable_sort(by_anchor.begin(), by_anchor.end(),
|
||||
[](const CursorLabel& a, const CursorLabel& b) { return a.anchor < b.anchor; });
|
||||
for (CursorLabel& label : by_anchor) {
|
||||
f32 x = 0.0f;
|
||||
f32 y = 0.0f;
|
||||
if (label.horizontal) {
|
||||
x = axes.x + pad;
|
||||
y = label.anchor - 0.5f * line_h; // centred on the ruler's own row
|
||||
} else {
|
||||
// Centred on the marker's column, held inside the axes: a badge half over an edge is
|
||||
// half a badge.
|
||||
x = std::clamp(label.anchor - 0.5f * label.w, axes.x + pad,
|
||||
std::max(axes.x + pad, axes.right() - pad - label.w));
|
||||
y = axes.y + pad;
|
||||
}
|
||||
for (;;) {
|
||||
const Rect box{x - pad, y - pad, label.w + 2.0f * pad, line_h + 2.0f * pad};
|
||||
bool meets = false;
|
||||
for (const CursorLabel& p : placed) {
|
||||
meets = box.x < p.box.x + p.box.w && p.box.x < box.x + box.w &&
|
||||
box.y < p.box.y + p.box.h && p.box.y < box.y + box.h;
|
||||
if (meets) break;
|
||||
}
|
||||
label.pen = Vec2{x, y};
|
||||
label.box = box;
|
||||
if (!meets) break;
|
||||
y += line_h; // one row down: still beside the mark, no longer on its neighbour
|
||||
}
|
||||
placed.push_back(label);
|
||||
}
|
||||
|
||||
// The panels: one per cluster of meeting backplates -- the boxes' union plus padding, the
|
||||
// border the cluster's first label's own -- with every line staged at its *placed* pen, so
|
||||
// the panel is a unified plate behind badges that each still sit on their own mark.
|
||||
const f32 margin = style_.device(2.0f);
|
||||
std::vector<Rect> bounds;
|
||||
std::vector<std::vector<std::size_t>> groups;
|
||||
std::vector<char> used(placed.size(), 0);
|
||||
for (std::size_t i = 0; i < placed.size(); ++i) {
|
||||
if (used[i] != 0) continue;
|
||||
used[i] = 1;
|
||||
bounds.push_back(placed[i].box);
|
||||
groups.emplace_back(1, i);
|
||||
Rect& u = bounds.back();
|
||||
for (bool grew = true; grew;) {
|
||||
grew = false;
|
||||
for (std::size_t j = 0; j < placed.size(); ++j) {
|
||||
if (used[j] != 0) continue;
|
||||
const Rect inflated{u.x - margin, u.y - margin, u.w + 2.0f * margin,
|
||||
u.h + 2.0f * margin};
|
||||
const Rect& b = placed[j].box;
|
||||
const bool meet = inflated.x < b.x + b.w && b.x < inflated.x + inflated.w &&
|
||||
inflated.y < b.y + b.h && b.y < inflated.y + inflated.h;
|
||||
if (!meet) continue;
|
||||
used[j] = 1;
|
||||
groups.back().push_back(j);
|
||||
const f32 rx = std::min(u.x, b.x);
|
||||
const f32 ry = std::min(u.y, b.y);
|
||||
u = Rect{rx, ry, std::max(u.x + u.w, b.x + b.w) - rx,
|
||||
std::max(u.y + u.h, b.y + b.h) - ry};
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (std::size_t g = 0; g < groups.size(); ++g) {
|
||||
Rect panel = bounds[g];
|
||||
panel.x -= pad;
|
||||
panel.y -= pad;
|
||||
panel.w += 2.0f * pad;
|
||||
panel.h += 2.0f * pad;
|
||||
// Inside the axes, shifted rather than clipped: a panel that hangs over an edge is a
|
||||
// second plot; a panel nudged along it is still a readout of what it labels. Opaque: a
|
||||
// badge is read against ticks and traces, and the badge's line beneath it is covered --
|
||||
// the box interrupting its own ruler is what makes the two read as one instrument.
|
||||
panel.x = std::clamp(panel.x, axes.x,
|
||||
std::max(axes.x, axes.right() - panel.w));
|
||||
panel.y = std::clamp(panel.y, axes.y,
|
||||
std::max(axes.y, axes.bottom() - panel.h));
|
||||
draw_readout_panel(panel, placed[groups[g].front()].border, 1.0f);
|
||||
for (std::size_t k = 0; k < groups[g].size(); ++k) {
|
||||
const CursorLabel& label = placed[groups[g][k]];
|
||||
stage_text(glyphs_under_, label.pen, label.text.c_str(), style_.axis_text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2023,7 +2139,7 @@ void Renderer::draw_legend(const Plot& plot, const PlotLayout& layout) {
|
||||
// The panel first, so every entry is on top of it. A legend's border is the frame's axis colour;
|
||||
// the readout's is the crosshair's own (see draw_crosshair_readout), which is the only thing that
|
||||
// differs between the two panels -- so it is the one parameter the shared helper takes.
|
||||
if (plot.legend().frame) draw_readout_panel(legend.panel, style_.axis_line);
|
||||
if (plot.legend().frame) draw_readout_panel(legend.panel, style_.axis_line, kLegendPanelAlpha);
|
||||
|
||||
for (const LegendRow& row : legend.rows) {
|
||||
// The truncation row names no trace: it has no swatch to colour and its text is
|
||||
@@ -2053,7 +2169,7 @@ void Renderer::draw_x_note(const Plot& plot, const PlotLayout& layout) {
|
||||
// A sliver of axes gets no caption rather than one hanging over the frame: the panel is chrome
|
||||
// *inside* the axes, and a backplate crossing the axis line would read as a second plot.
|
||||
if (panel.w > axes.w || panel.h > axes.h) return;
|
||||
draw_readout_panel(panel, style_.axis_line);
|
||||
draw_readout_panel(panel, style_.axis_line, kLegendPanelAlpha);
|
||||
// Bottom-left, beside the labels it qualifies -- the reader adds the caption to a tick, so it
|
||||
// belongs at the end of the tick row and not in a corner of its own. `glyphs_under_` because
|
||||
// the panel is flushed with the frame's line geometry and the under-text batch goes out after
|
||||
@@ -2122,12 +2238,13 @@ void Renderer::draw_selection_frame(const PlotLayout& layout) {
|
||||
flush_lines(pixel_mapping(), style_.selection_edge, style_.device(1.0f));
|
||||
}
|
||||
|
||||
void Renderer::draw_readout_panel(const Rect& panel, Color border) {
|
||||
void Renderer::draw_readout_panel(const Rect& panel, Color border, float background_alpha) {
|
||||
if (!(panel.w > 0.0f) || !(panel.h > 0.0f)) return;
|
||||
// The frame's own background at a partial alpha, which dims whatever it covers instead of erasing
|
||||
// it -- the one backplate both readout panels use.
|
||||
// The frame's own background scaled by the caller's opacity, which dims whatever it covers
|
||||
// -- or, at full opacity, covers it outright: a badge read against ticks or a noisy trace
|
||||
// is read *through* nothing.
|
||||
Color background = style_.background;
|
||||
background.a *= kLegendPanelAlpha;
|
||||
background.a *= background_alpha;
|
||||
fill_pixels(panel, background);
|
||||
// The border frames the pixels the fill covers, not the measured rect's own fractional edges:
|
||||
// those are wherever the content happened to measure to, and a hairline drawn on one is lit
|
||||
@@ -2226,14 +2343,47 @@ void Renderer::draw_crosshair_lines(const Plot& plot, const PlotLayout& layout)
|
||||
}
|
||||
}
|
||||
|
||||
void Renderer::draw_crosshair_axis_badges(const PlotLayout& layout) {
|
||||
const CrosshairLayout& crosshair = layout.crosshair;
|
||||
if (!crosshair.active) return;
|
||||
const bool y_badge = crosshair.y_axis_badge.w > 0.0f && !crosshair.y_axis_text.empty();
|
||||
const bool x_badge = crosshair.x_axis_badge.w > 0.0f && !crosshair.x_axis_text.empty();
|
||||
if (!y_badge && !x_badge) return;
|
||||
// The same backplate the readout panel has, its border in the crosshair's colour too --
|
||||
// the badge is that panel's value column, moved to where the line meets the axis it
|
||||
// names. Drawn here rather than in the readout pass because it sits in a *label strip*:
|
||||
// the strips are drawn after the interior clip is cleared, so a badge that lived in the
|
||||
// clipped pass would be trimmed to nothing.
|
||||
//
|
||||
// **The tick labels are flushed first, and that is the whole point of this panel.** They
|
||||
// are staged into the over-text batch, which the frame flushes after *every* line batch --
|
||||
// so a fill drawn straight away would sit under them and the label it covers would print
|
||||
// through the badge (the "transparent" backplate). They are already placed at this point,
|
||||
// so pushing them out costs one batch: the fill then covers them, and the badge's own text
|
||||
// is staged after both fills and leaves with the rest of the over batch, over everything.
|
||||
flush_text(glyphs_over_);
|
||||
const f32 pad = style_.device(4.0f);
|
||||
if (y_badge) draw_readout_panel(crosshair.y_axis_badge, style_.crosshair, 1.0f);
|
||||
if (x_badge) draw_readout_panel(crosshair.x_axis_badge, style_.crosshair, 1.0f);
|
||||
if (y_badge) {
|
||||
stage_text(glyphs_over_,
|
||||
{crosshair.y_axis_badge.x + pad, crosshair.y_axis_badge.y + pad},
|
||||
crosshair.y_axis_text.c_str(), style_.axis_text);
|
||||
}
|
||||
if (x_badge) {
|
||||
stage_text(glyphs_over_,
|
||||
{crosshair.x_axis_badge.x + pad, crosshair.x_axis_badge.y + pad},
|
||||
crosshair.x_axis_text.c_str(), style_.axis_text);
|
||||
}
|
||||
}
|
||||
|
||||
void Renderer::draw_crosshair_readout(const Plot& plot, const PlotLayout& layout) {
|
||||
const CrosshairLayout& crosshair = layout.crosshair;
|
||||
if (crosshair.empty()) return;
|
||||
|
||||
// The readout: the same backplate a legend has (the shared `draw_readout_panel()`), 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.
|
||||
draw_readout_panel(crosshair.panel, style_.crosshair);
|
||||
draw_readout_panel(crosshair.panel, style_.crosshair, kLegendPanelAlpha);
|
||||
|
||||
for (const CrosshairRow& row : crosshair.rows) {
|
||||
if (!row.listed) continue;
|
||||
|
||||
Reference in new issue
Block a user