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
@@ -124,6 +124,18 @@ using plot::RegionGrab;
|
||||
/// the same answer for a host that wants it directly -- a resize cursor, a tooltip,
|
||||
/// a status line.
|
||||
///
|
||||
/// ## The crosshair (spec §15)
|
||||
///
|
||||
/// A pointer over the data area places the plot's crosshair at the data coordinates under
|
||||
/// it (`Plot::set_crosshair()`), and a pointer that leaves `axes` clears it
|
||||
/// (`Plot::clear_crosshair()`). That is a *hover*, not a gesture: no button is involved, and
|
||||
/// a press, a wheel notch or a key stroke that moves the data under the pointer re-anchors
|
||||
/// the crosshair to the same pointer position rather than clearing it -- it follows the
|
||||
/// pointer, so anything the pointer did not move leaves it where the pointer is. The
|
||||
/// per-signal values a readout shows are the plot's (`PlotLayout::crosshair`), not this
|
||||
/// layer's: the controller says where the pointer is and nothing about what a crosshair
|
||||
/// means.
|
||||
///
|
||||
/// ## Zoom and pan on a live x axis (spec §12)
|
||||
///
|
||||
/// An x axis armed with `XFollow::Live` keeps following through every gesture
|
||||
@@ -262,6 +274,16 @@ class Controller {
|
||||
/// leave alone, the bounds and the grabbed data x a body move needs to preserve
|
||||
/// the width -- so a changed pointer motion never accumulates error.
|
||||
bool begin_handle_drag(const PointerEvent& e);
|
||||
/// Places the plot's crosshair under the pointer, or clears it when the pointer is not
|
||||
/// over the data area (spec §15). Called after every event that can move the pointer *or*
|
||||
/// the data under it -- a pointer event, a wheel notch, a key stroke, a viewport change --
|
||||
/// so the crosshair is where the pointer is and reads the data the frame will draw, and
|
||||
/// never at a coordinate a zoom has already moved out from under it.
|
||||
///
|
||||
/// A press does not clear it: the crosshair follows the pointer, and a pointer that is
|
||||
/// still over the axes is still pointing at whatever gesture just started. The pointer is
|
||||
/// the only thing that clears it, by leaving `axes`.
|
||||
void update_crosshair(f32 px, f32 py);
|
||||
/// Publishes what the pointer is on to the plot, for the renderer to highlight
|
||||
/// (see HotHandles). Called after every event that can move the geometry under the
|
||||
/// pointer -- a pointer event, a wheel notch, a key stroke -- so hover and grab are
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
|
||||
#include <plot/plot/annotation.hpp>
|
||||
#include <plot/plot/axis.hpp>
|
||||
#include <plot/plot/crosshair.hpp>
|
||||
#include <plot/plot/cursor.hpp>
|
||||
#include <plot/plot/figure.hpp>
|
||||
#include <plot/plot/grid.hpp>
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <plot/core/rect.hpp>
|
||||
#include <plot/core/types.hpp>
|
||||
#include <plot/data/series.hpp>
|
||||
#include <plot/math/vec2.hpp>
|
||||
|
||||
namespace plot {
|
||||
|
||||
/// The crosshair's state: where it is, which of its two lines it draws, and whether the
|
||||
/// per-signal readout is wanted.
|
||||
///
|
||||
/// It is a *hover* instrument, not a cursor: a cursor is put where the caller wants it and
|
||||
/// stays there, while the crosshair follows the pointer and exists only while the pointer
|
||||
/// is over the data area. So it is plain state rather than an element of a collection --
|
||||
/// there is at most one per plot, and `Plot::set_crosshair()` is what places it.
|
||||
///
|
||||
/// **Its coordinates are data coordinates of the primary axis pair** (x axis 0, y axis 0),
|
||||
/// like a `Region`'s bounds and the grid: one hover is one instant and one value, whatever
|
||||
/// the strips around the axes happen to be. `vertical`/`horizontal` say which lines are
|
||||
/// drawn -- the single-coordinate `Plot::set_crosshair()` leaves the vertical one only,
|
||||
/// and a `Figure` is always vertical-only because its rows share x and not y.
|
||||
///
|
||||
/// **The readout's values are interpolated, and deliberately so.** The y a row reports for
|
||||
/// a trace is *linear interpolation between the two samples adjacent to `x`*, not a sample
|
||||
/// and not an interval statistic -- see `crosshair_span()` for the rule stated in full, and
|
||||
/// for why it is the opposite convention from `Series::reduce()`/`sum()`/`sumsq()`.
|
||||
struct Crosshair {
|
||||
/// Whether the crosshair is drawn at all. Set by `Plot::set_crosshair()`; cleared by
|
||||
/// `Plot::clear_crosshair()`, which `input::Controller` calls when the pointer leaves the
|
||||
/// axes.
|
||||
bool active = false;
|
||||
/// Draw the vertical line (the column of `x`). On for both `set_crosshair()` overloads.
|
||||
bool vertical = true;
|
||||
/// Draw the horizontal line (the row of `y`). On for the two-coordinate overload only:
|
||||
/// a crosshair placed at one coordinate has no second value to mark a row with.
|
||||
bool horizontal = true;
|
||||
/// List the visible traces and their interpolated values in a panel beside the pointer.
|
||||
/// On by default, and free to leave on: a plot with no visible trace has no rows to
|
||||
/// measure and the panel is then not placed at all.
|
||||
bool show_readout = true;
|
||||
/// The data x of the vertical line, on x axis 0.
|
||||
f64 x = 0.0;
|
||||
/// The data y of the horizontal line, on y axis 0. Meaningless for a vertical-only
|
||||
/// crosshair, which draws no row and anchors its readout at the top of the axes.
|
||||
f64 y = 0.0;
|
||||
};
|
||||
|
||||
/// The two samples of a uniform-grid series that a data `x` lies between: what the
|
||||
/// crosshair's lookup needs, and all it needs.
|
||||
///
|
||||
/// `begin` is the index of the last sample at or before `x`, `end` the index of the first
|
||||
/// sample at or after it, and `t` where `x` sits between them (`0` at `begin`, `1` at
|
||||
/// `end`). `end - begin` is therefore 0 when `x` lands exactly on a sample -- including the
|
||||
/// two ends of the window, which are samples -- and 1 otherwise, so a caller reads **at
|
||||
/// most two samples** and never scans: the span is one division and one floor of index
|
||||
/// arithmetic, which is O(1) in the series' length. The crosshair runs on every pointer
|
||||
/// move, so that is a requirement rather than a nicety.
|
||||
///
|
||||
/// **Interpolated, not exact.** The value a caller builds from this span by
|
||||
/// `crosshair_interpolate()` between the two samples is the signal *at* `x`, which is
|
||||
/// generally between two samples -- the opposite convention from `Series::reduce()`,
|
||||
/// `sum()` and `sumsq()`, which are **exact over a sample range**. The two conventions sit
|
||||
/// next to each other in this library on purpose and they do not agree: `reduce()` answers
|
||||
/// "what are the samples' extremes over this interval", this answers "what is the signal at
|
||||
/// this instant". A readout that pretended to be exact would be quoting an extreme or a
|
||||
/// mean of a window as if it were the value under the pointer.
|
||||
///
|
||||
/// `valid` is false -- and every field left at its default -- for an **empty series**, a
|
||||
/// non-positive `dx()`, a non-finite `x`, and an `x` strictly outside the series' window
|
||||
/// `[x0, x0 + (size-1)*dx]`. An out-of-window x yields *no* value rather than the nearest
|
||||
/// sample's, which would be a clamped lie about the data under the pointer; the readout row
|
||||
/// it belongs to says so instead (`CrosshairRow::valid`).
|
||||
struct CrosshairSpan {
|
||||
/// Index of the last sample whose x is at or before the query.
|
||||
u64 begin = 0;
|
||||
/// Index of the first sample whose x is at or after the query: `begin` when the query
|
||||
/// lands on a sample, `begin + 1` between two of them.
|
||||
u64 end = 0;
|
||||
/// The query's position between the two samples, in `[0, 1]`.
|
||||
f64 t = 0.0;
|
||||
bool valid = false;
|
||||
};
|
||||
|
||||
/// The sample span `x` falls in, on `series`; see `CrosshairSpan` for the rule, for the
|
||||
/// interpolation convention it is half of, and for the cases that yield `valid == false`.
|
||||
///
|
||||
/// O(1) and allocation-free: a subtraction, a division and a floor. The series' own sample
|
||||
/// fetch is the caller's -- `Plot::crosshair_value()` reads the span through
|
||||
/// `Series::gather()` into a scratch buffer it owns, so no lookup on the pointer path
|
||||
/// allocates.
|
||||
CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept;
|
||||
|
||||
/// The interpolated y at `t` between two samples: `y_begin` at `t == 0`, `y_end` at
|
||||
/// `t == 1`. The one place the interpolation's arithmetic lives, so the lookup's rule and
|
||||
/// its documentation cannot drift apart.
|
||||
inline f64 crosshair_interpolate(f64 y_begin, f64 y_end, f64 t) noexcept {
|
||||
return y_begin + (y_end - y_begin) * t;
|
||||
}
|
||||
|
||||
/// One trace's line of the readout: which trace it is about, the value under the pointer
|
||||
/// and the boxes the renderer stages.
|
||||
///
|
||||
/// **Every visible trace gets a row, named or not** -- the readout is about what is under
|
||||
/// the pointer, so the legend's rule of skipping unnamed traces does not apply here. An
|
||||
/// unnamed trace's row carries a stable positional label instead (`label`, built by
|
||||
/// `crosshair_unnamed_label()`), so the rows stay identifiable without a name being
|
||||
/// invented for the trace.
|
||||
///
|
||||
/// A row whose trace has no sample at the crosshair's x -- its series does not reach that
|
||||
/// far, or holds nothing -- is `valid == false` and carries **no value text**: the trace is
|
||||
/// still listed, because it *is* one of the signals under the pointer, but no number is
|
||||
/// fabricated for it and no marker is drawn at a y that does not exist.
|
||||
struct CrosshairRow {
|
||||
/// Index of the trace this row is about: `Plot::trace(trace)` is what the label, the
|
||||
/// swatch colour and the marker position come from.
|
||||
u32 trace = 0;
|
||||
/// False when the trace's series has no sample at the crosshair's x (see the struct
|
||||
/// comment). `y`, `value` and `marker` are then meaningless.
|
||||
bool valid = false;
|
||||
/// True when the row's chrome was placed inside the panel. The panel is clamped to the
|
||||
/// axes and drops rows from the end when they do not fit -- exactly as a legend drops
|
||||
/// entries -- so a row that is not listed is still a listed *trace*, with a marker, just
|
||||
/// not a line in a panel that has no room for it.
|
||||
bool listed = false;
|
||||
/// The trace's interpolated y at the crosshair's x, on the trace's own y axis. Only
|
||||
/// meaningful when `valid`.
|
||||
f64 y = 0.0;
|
||||
/// The trace's name, or the positional placeholder of an unnamed one.
|
||||
std::string label;
|
||||
/// The formatted value, written in the trace's y axis' own tick format (`Axis::format_value()`,
|
||||
/// so a readout reads exactly like the labels beside it), and empty when `!valid`.
|
||||
std::string value;
|
||||
/// The trace's intersection with the crosshair, in framebuffer pixels: the pen the
|
||||
/// renderer draws the marker at. Placed through the *trace's own* axis pair, so the
|
||||
/// marker is where that trace is actually drawn. Meaningless when `!valid`.
|
||||
Vec2 marker{};
|
||||
/// The swatch's box, `Legend::swatch_w` x `Legend::swatch_h` centred in the row. Empty
|
||||
/// when `!listed`.
|
||||
Rect swatch{};
|
||||
/// The label's line box: its top-left is the pen the run is staged at and its height one
|
||||
/// text line. Empty when `!listed`.
|
||||
Rect label_box{};
|
||||
/// The value's line box, one gutter (the legend's padding) after the label's. Empty when
|
||||
/// `!listed` or `!valid`: a row with no value stages no run.
|
||||
Rect value_box{};
|
||||
};
|
||||
|
||||
/// The measured crosshair: the two lines' stroke boxes, the readout panel and one row per
|
||||
/// visible trace, in the same device-pixel space as `PlotLayout` and filled alongside it by
|
||||
/// `Plot::layout()`.
|
||||
///
|
||||
/// It is a member of `PlotLayout` rather than a second call, for the legend's reason: the
|
||||
/// numbers a test asserts and the numbers the renderer draws are one set. Every rect is
|
||||
/// **inside `PlotLayout::axes`** by construction -- a line's stroke box is held half a
|
||||
/// stroke inside the rect, and the panel is placed beside the pointer, flipped when it
|
||||
/// would cross an edge and then clamped, so no crosshair pixel can land on a label strip or
|
||||
/// a neighbouring figure row. Task 1's axes clip is the second line of defence, not the
|
||||
/// first.
|
||||
///
|
||||
/// Everything is default -- no lines, no panel, no rows -- when the crosshair is not
|
||||
/// active, and the rows alone are empty when there is no visible trace.
|
||||
struct CrosshairLayout {
|
||||
/// True when the crosshair was active for this layout.
|
||||
bool active = false;
|
||||
/// The vertical line's stroke box: `Style::line_width_px` wide in device pixels, as tall
|
||||
/// as `axes`, centred on the column of `Crosshair::x`. Zero-sized when the vertical line
|
||||
/// is not drawn.
|
||||
Rect vertical{};
|
||||
/// The horizontal line's stroke box, the same instrument turned a quarter turn. Zero-sized
|
||||
/// when the horizontal line is not drawn.
|
||||
Rect horizontal{};
|
||||
/// The readout panel, inside `axes`. Zero-sized when there is nothing to read out (no
|
||||
/// visible trace, nothing to measure text with, no room for even one row), when
|
||||
/// `Crosshair::show_readout` is off, or when the panel could not be placed inside the
|
||||
/// axes at all.
|
||||
Rect panel{};
|
||||
/// One row per visible trace, in trace order -- see `CrosshairRow`. Present whether or
|
||||
/// not the panel could list them, so the markers are drawn even in a plot too small to
|
||||
/// hold a panel.
|
||||
std::vector<CrosshairRow> rows;
|
||||
|
||||
bool empty() const noexcept { return !active; }
|
||||
};
|
||||
|
||||
/// The label an **unnamed** trace's readout row carries: `#` and the trace's position, e.g.
|
||||
/// `#2`.
|
||||
///
|
||||
/// Positional on purpose. The readout lists a trace because it is under the pointer, not
|
||||
/// because it has an identity, so an unnamed trace needs *some* stable caption or its rows
|
||||
/// would be indistinguishable -- and inventing a name for it (or borrowing a neighbour's)
|
||||
/// would be a claim about the data that nothing supports. The position is derived from the
|
||||
/// trace id alone, so the same trace keeps the same caption frame after frame.
|
||||
std::string crosshair_unnamed_label(u32 trace);
|
||||
|
||||
} // namespace plot
|
||||
@@ -139,6 +139,27 @@ class Figure {
|
||||
const FigureLayout& layout();
|
||||
const FigureLayout& layout() const noexcept { return layout_; }
|
||||
|
||||
// --- the crosshair ----------------------------------------------------
|
||||
|
||||
/// Places one crosshair x on **every** row (spec §15): the same data coordinate, so each
|
||||
/// row's readout lists its own traces at that instant and the rows read as one hover
|
||||
/// rather than N. That is what "synced if multiple plots available" means -- one pointer
|
||||
/// position, one x, N rows of values -- and it is the figure's to do rather than a host's
|
||||
/// because only the figure knows its rows.
|
||||
///
|
||||
/// Vertical-only, deliberately: the rows of a figure share *x* (`Figure`'s whole reason
|
||||
/// for stacking them and linking their x windows) and not y -- row 1 may be volts and row
|
||||
/// 2 degrees -- so a horizontal line across the figure would be a value that means
|
||||
/// different things in each row. A row whose series has no sample at `x` lists the trace
|
||||
/// with no value rather than inventing one.
|
||||
///
|
||||
/// A row is placed with `Plot::set_crosshair(x, active)`, which refuses a coordinate that
|
||||
/// is not a number; such a call therefore leaves every row's crosshair as it was.
|
||||
void set_crosshair(f64 x, bool active);
|
||||
|
||||
/// Clears the crosshair on every row. A row that never had one is unaffected.
|
||||
void clear_crosshair();
|
||||
|
||||
// --- the x link -------------------------------------------------------
|
||||
|
||||
/// A new link group, empty. Groups are numbered from 1 -- 0 is the unlinked
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <plot/math/transform.hpp>
|
||||
#include <plot/plot/annotation.hpp>
|
||||
#include <plot/plot/axis.hpp>
|
||||
#include <plot/plot/crosshair.hpp>
|
||||
#include <plot/plot/cursor.hpp>
|
||||
#include <plot/plot/grid.hpp>
|
||||
#include <plot/plot/legend.hpp>
|
||||
@@ -96,6 +97,13 @@ struct PlotLayout {
|
||||
/// controller's hit test reads the very same rects, so where a region is drawn and
|
||||
/// where a pointer can grab it cannot drift apart.
|
||||
std::vector<RegionLayout> regions;
|
||||
/// The crosshair's geometry, when one is active: the two lines' stroke boxes, the
|
||||
/// readout panel and one row per visible trace with the interpolated value the pointer
|
||||
/// is over. Placed here rather than derived by the renderer, exactly as the legend and
|
||||
/// the regions are -- and, like them, entirely inside `axes`: the panel is flipped and
|
||||
/// clamped against the rect it was measured for, so nothing of the readout can be drawn
|
||||
/// over a label strip or into a figure's neighbouring row.
|
||||
CrosshairLayout crosshair;
|
||||
};
|
||||
|
||||
/// What a pointer is on, as of the last pointer event: the cursor marker under it, the
|
||||
@@ -530,6 +538,56 @@ class Plot {
|
||||
/// a caller can move the legend -- or switch it off -- between frames.
|
||||
void set_legend(const Legend& legend) noexcept { legend_ = legend; }
|
||||
|
||||
// --- crosshair --------------------------------------------------------
|
||||
|
||||
/// Places a **vertical-only** crosshair at a data x: the column of `x`, a marker on
|
||||
/// every visible trace at that x, and the readout panel beside it (spec §15).
|
||||
///
|
||||
/// This is the form for a caller that has a coordinate and not a pointer -- "show me
|
||||
/// every signal at this instant" -- and for `Figure::set_crosshair()`, which applies one
|
||||
/// x to every row: a figure's rows share x and not y, so a horizontal line across them
|
||||
/// would be a claim about y that the figure does not make.
|
||||
///
|
||||
/// **Refused** -- nothing changes -- when `x` is not a number: a crosshair there has no
|
||||
/// column to draw and no value to read, and refusing it leaves the previous position
|
||||
/// rather than a crosshair no gesture could undo. `active == false` clears it exactly as
|
||||
/// `clear_crosshair()` does.
|
||||
void set_crosshair(f64 x, bool active);
|
||||
|
||||
/// Places a crosshair at both coordinates: the vertical line and the readout of the
|
||||
/// first form, plus the horizontal line at `y`. This is the form `input::Controller`
|
||||
/// uses, because a pointer over the axes has both coordinates.
|
||||
///
|
||||
/// Refused, changing nothing, when either coordinate is not a number (see the first
|
||||
/// form).
|
||||
void set_crosshair(f64 x, f64 y, bool active);
|
||||
|
||||
/// Hides the crosshair, leaving where it was: the same state a plot has before any
|
||||
/// crosshair is placed, so the next `set_crosshair()` re-anchors it. `input::Controller`
|
||||
/// calls this when the pointer leaves the axes.
|
||||
void clear_crosshair() noexcept { crosshair_.active = false; }
|
||||
|
||||
/// The crosshair's state: where it is, which lines it draws and whether the readout is
|
||||
/// on. Read-only -- `set_crosshair()` and `clear_crosshair()` are how it moves -- so the
|
||||
/// coordinates it holds and the geometry `layout()` places from them cannot disagree.
|
||||
const Crosshair& crosshair() const noexcept { return crosshair_; }
|
||||
|
||||
/// The **interpolated** value of `trace`'s series at a data `x`: `crosshair_span()`
|
||||
/// between the two adjacent samples, then `crosshair_interpolate()` -- the same lookup
|
||||
/// the readout's rows are built from, exposed for a host that wants the number for
|
||||
/// itself (a status line, a tooltip of its own).
|
||||
///
|
||||
/// False, leaving `out_y` untouched, for an id that names no trace, a trace with no
|
||||
/// series, and an `x` the series has no sample at -- the cases `CrosshairSpan::valid`
|
||||
/// documents, plus the trace itself. Never a clamped or nearest-sample answer: see
|
||||
/// `crosshair_span()` for why an out-of-window x is not a value.
|
||||
///
|
||||
/// O(1) and allocation-free once the plot has looked a value up before: the span is
|
||||
/// index arithmetic and the samples are read through one `Series::gather()` of at most
|
||||
/// two samples into a buffer the plot owns. It is called on every layout -- and therefore
|
||||
/// on every pointer move -- once per visible trace, so nothing here may scan a series.
|
||||
bool crosshair_value(u32 trace, f64 x, f64& out_y) const;
|
||||
|
||||
private:
|
||||
/// One measure plus its cache. The dirty flag is what keeps measurement() from
|
||||
/// re-evaluating every frame; `last_series`, `last_size` and `last_x0` catch a
|
||||
@@ -636,6 +694,12 @@ class Plot {
|
||||
/// to list -- an empty legend is what keeps the feature free for a caller who
|
||||
/// never names a trace.
|
||||
void build_legend();
|
||||
/// Measures the crosshair into `layout_.crosshair`: the two lines' stroke boxes, one row
|
||||
/// per visible trace with its interpolated value and marker, and the readout panel --
|
||||
/// placed beside the crosshair, flipped where it would cross an edge and clamped, so
|
||||
/// every rect it produces is inside `axes`. A plot with no visible trace still gets its
|
||||
/// lines: the rows are the readout's, not the crosshair's.
|
||||
void build_crosshair();
|
||||
|
||||
Rect viewport_{};
|
||||
render::Style style_{};
|
||||
@@ -664,6 +728,20 @@ class Plot {
|
||||
/// about to drop. A member rather than a local, so a steady-state frame's legend
|
||||
/// build allocates nothing.
|
||||
std::vector<f32> legend_name_px_;
|
||||
/// The crosshair's state (see Crosshair): at most one per plot, placed by
|
||||
/// `set_crosshair()` and read back by `crosshair()` and `layout()`.
|
||||
Crosshair crosshair_{};
|
||||
/// The samples `crosshair_value()` reads a span through: `Series::gather()` appends
|
||||
/// `(x, y)` pairs and never clears, so the lookup clears this and refills it with the at
|
||||
/// most two samples a span covers. Sized to hold them from construction -- four floats is
|
||||
/// two pairs -- so a lookup on the pointer path reallocates nothing even the first time,
|
||||
/// and `mutable` for the same reason `measures_` is: a lookup is not a mutation a caller
|
||||
/// can observe.
|
||||
mutable std::vector<f32> crosshair_scratch_{0.0f, 0.0f, 0.0f, 0.0f};
|
||||
/// Scratch for build_crosshair(): the content width (label + gutter + value) of every row,
|
||||
/// prefix-maxed in place, for `legend_name_px_`'s reason -- the panel's width after a row
|
||||
/// was dropped from the end is the width of a row still listed, read in O(1).
|
||||
std::vector<f32> crosshair_content_px_;
|
||||
|
||||
PlotLayout layout_{};
|
||||
Grid grid_;
|
||||
|
||||
@@ -487,6 +487,18 @@ class Renderer {
|
||||
/// them again, so the panel a test asserts and the panel this draws are the same
|
||||
/// numbers.
|
||||
void draw_legend(const Plot& plot, const PlotLayout& layout);
|
||||
/// The crosshair: the two lines, a marker per trace, and the readout panel with a swatch and
|
||||
/// a value per listed row. Every rect -- the two stroke boxes, the panel, the markers'
|
||||
/// positions and the rows' boxes -- comes from `PlotLayout::crosshair`, which placed it
|
||||
/// inside `axes`; this stages exactly that geometry, so the crosshair a test measures and the
|
||||
/// crosshair a frame paints are one set of numbers. Clipped to `axes` with the rest of the
|
||||
/// interior passes, which is what keeps a marker for an off-scale sample inside the rect.
|
||||
///
|
||||
/// The panel is a backplate like the legend's, so it is drawn after the lines and the
|
||||
/// markers and before any of the frame's text. The rows' values were formatted by the plot
|
||||
/// (in the trace's own y axis' format) and are staged here as they stand -- this layer
|
||||
/// formats nothing.
|
||||
void draw_crosshair(const Plot& plot, const PlotLayout& layout);
|
||||
|
||||
/// The built-in backend the renderer owns, and the face the frames draw with:
|
||||
/// `set_text_backend()` writes `font_`, which starts here and comes back here
|
||||
|
||||
@@ -41,6 +41,13 @@ struct Style {
|
||||
Color axis_text = Color::hex(0xC8D0DC);
|
||||
Color cursor = Color::hex(0xFFB020);
|
||||
Color measure_fill = Color::hex(0xFFB020, 0.15f);
|
||||
/// The crosshair's colour: its two lines, its markers' own colour excepted, and the
|
||||
/// readout panel's border (spec §15). Deliberately **not** `cursor`: the cursor accent is
|
||||
/// an orange that a caller's own cursors, a region's bounds and its fill all already use,
|
||||
/// while the crosshair is a passive hover instrument that has to read as *chrome* over a
|
||||
/// dense trace rather than as another handle to grab. A pale cyan does that against the
|
||||
/// dark default theme, and it is a colour no default element of a frame shares.
|
||||
Color crosshair = Color::hex(0xB8E8FF);
|
||||
|
||||
// --- the scale ----------------------------------------------------------
|
||||
|
||||
|
||||
@@ -113,6 +113,10 @@ void Controller::set_viewport(Rect px) {
|
||||
// transforms read.
|
||||
plot_->set_viewport(px);
|
||||
plot_->layout();
|
||||
// A resize moves the axes (and with them the data under a stationary pointer), so the
|
||||
// crosshair is re-placed against the new rect -- and cleared if the pointer is now outside
|
||||
// it, which is the same rule as a pointer that left.
|
||||
if (has_pointer_) update_crosshair(pointer_x_, pointer_y_);
|
||||
// The hover the layout is part of: a handle under the pointer may be somewhere
|
||||
// else now, and the highlight the renderer draws reads what this publishes.
|
||||
publish_hot();
|
||||
@@ -132,6 +136,11 @@ void Controller::on_pointer(const PointerEvent& raw) {
|
||||
} else if (e.down && mode_ != DragMode::None) {
|
||||
update_drag(e);
|
||||
}
|
||||
// The crosshair is placed (or cleared) from the pointer's own position, after the gesture
|
||||
// above: a press does not clear it -- the pointer is still over the axes, still pointing at
|
||||
// whatever it just grabbed -- and a drag keeps it re-anchored to the pointer as the data
|
||||
// moves under it.
|
||||
update_crosshair(e.x, e.y);
|
||||
// Every exit above ends here: a hover that moved, a gesture that opened, and a
|
||||
// gesture that closed all change what is under the pointer, and the publish is
|
||||
// what makes the renderer's highlight (and a host's resize cursor) follow.
|
||||
@@ -174,6 +183,11 @@ void Controller::on_wheel(const WheelEvent& raw) {
|
||||
// emphasizes the handle the pointer is over *now*, rather than the one it was over before,
|
||||
// without waiting for the mouse to move again.
|
||||
plot_->layout();
|
||||
// The data under the pointer moved with the ranges, so the crosshair -- which is a data
|
||||
// coordinate -- is re-anchored to the pointer rather than left on the value it was placed
|
||||
// at: it follows the pointer, and this is the one thing that moves the data without moving
|
||||
// the pointer.
|
||||
if (has_pointer_) update_crosshair(pointer_x_, pointer_y_);
|
||||
publish_hot();
|
||||
}
|
||||
|
||||
@@ -206,11 +220,31 @@ void Controller::on_key(const KeyEvent& e) {
|
||||
// A key stroke that moved a range moved the geometry under a stationary pointer, exactly
|
||||
// as a wheel notch does: refresh the layout and republish what is hot, so the highlight a
|
||||
// frame draws is the handle the pointer is over now (see on_wheel). A key this controller
|
||||
// has no gesture for changes nothing and returns above.
|
||||
// has no gesture for changes nothing and returns above -- and so does not touch the
|
||||
// crosshair either.
|
||||
plot_->layout();
|
||||
if (has_pointer_) update_crosshair(pointer_x_, pointer_y_);
|
||||
publish_hot();
|
||||
}
|
||||
|
||||
void Controller::update_crosshair(f32 px, f32 py) {
|
||||
// Inside the data area the crosshair is at the coordinates under the pointer; outside it
|
||||
// there is no data to read, so there is no crosshair -- the null answer, rather than a
|
||||
// value clamped to the nearest edge of the window.
|
||||
const PlotLayout& layout = std::as_const(*plot_).layout();
|
||||
if (!layout.axes.contains(px, py)) {
|
||||
plot_->clear_crosshair();
|
||||
return;
|
||||
}
|
||||
// Both coordinates, on the primary axis pair (the same pair the grid and a region's fill
|
||||
// are placed through): a pointer over the axes knows its x and its y, so a single plot's
|
||||
// crosshair is the full cross. A `Figure` is driven by `Figure::set_crosshair()` instead,
|
||||
// which applies one x to every row and draws no horizontal line -- its rows share x and
|
||||
// not y.
|
||||
plot_->set_crosshair(plot_->x_transform(0u).to_data_x(static_cast<f64>(px)),
|
||||
plot_->y_transform(0u).to_data_y(static_cast<f64>(py)), true);
|
||||
}
|
||||
|
||||
u64 Controller::hovered_cursor() const {
|
||||
if (!has_pointer_) return kInvalid;
|
||||
return cursor_at(pointer_x_, pointer_y_);
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user