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,
|
/// the same answer for a host that wants it directly -- a resize cursor, a tooltip,
|
||||||
/// a status line.
|
/// 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)
|
/// ## Zoom and pan on a live x axis (spec §12)
|
||||||
///
|
///
|
||||||
/// An x axis armed with `XFollow::Live` keeps following through every gesture
|
/// 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
|
/// 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.
|
/// the width -- so a changed pointer motion never accumulates error.
|
||||||
bool begin_handle_drag(const PointerEvent& e);
|
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
|
/// 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
|
/// (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
|
/// 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/annotation.hpp>
|
||||||
#include <plot/plot/axis.hpp>
|
#include <plot/plot/axis.hpp>
|
||||||
|
#include <plot/plot/crosshair.hpp>
|
||||||
#include <plot/plot/cursor.hpp>
|
#include <plot/plot/cursor.hpp>
|
||||||
#include <plot/plot/figure.hpp>
|
#include <plot/plot/figure.hpp>
|
||||||
#include <plot/plot/grid.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 FigureLayout& layout() const noexcept { return 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 -------------------------------------------------------
|
// --- the x link -------------------------------------------------------
|
||||||
|
|
||||||
/// A new link group, empty. Groups are numbered from 1 -- 0 is the unlinked
|
/// 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/math/transform.hpp>
|
||||||
#include <plot/plot/annotation.hpp>
|
#include <plot/plot/annotation.hpp>
|
||||||
#include <plot/plot/axis.hpp>
|
#include <plot/plot/axis.hpp>
|
||||||
|
#include <plot/plot/crosshair.hpp>
|
||||||
#include <plot/plot/cursor.hpp>
|
#include <plot/plot/cursor.hpp>
|
||||||
#include <plot/plot/grid.hpp>
|
#include <plot/plot/grid.hpp>
|
||||||
#include <plot/plot/legend.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
|
/// 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.
|
/// where a pointer can grab it cannot drift apart.
|
||||||
std::vector<RegionLayout> regions;
|
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
|
/// 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.
|
/// a caller can move the legend -- or switch it off -- between frames.
|
||||||
void set_legend(const Legend& legend) noexcept { legend_ = legend; }
|
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:
|
private:
|
||||||
/// One measure plus its cache. The dirty flag is what keeps measurement() from
|
/// 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
|
/// 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
|
/// to list -- an empty legend is what keeps the feature free for a caller who
|
||||||
/// never names a trace.
|
/// never names a trace.
|
||||||
void build_legend();
|
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_{};
|
Rect viewport_{};
|
||||||
render::Style style_{};
|
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
|
/// about to drop. A member rather than a local, so a steady-state frame's legend
|
||||||
/// build allocates nothing.
|
/// build allocates nothing.
|
||||||
std::vector<f32> legend_name_px_;
|
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_{};
|
PlotLayout layout_{};
|
||||||
Grid grid_;
|
Grid grid_;
|
||||||
|
|||||||
@@ -487,6 +487,18 @@ class Renderer {
|
|||||||
/// them again, so the panel a test asserts and the panel this draws are the same
|
/// them again, so the panel a test asserts and the panel this draws are the same
|
||||||
/// numbers.
|
/// numbers.
|
||||||
void draw_legend(const Plot& plot, const PlotLayout& layout);
|
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:
|
/// 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
|
/// `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 axis_text = Color::hex(0xC8D0DC);
|
||||||
Color cursor = Color::hex(0xFFB020);
|
Color cursor = Color::hex(0xFFB020);
|
||||||
Color measure_fill = Color::hex(0xFFB020, 0.15f);
|
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 ----------------------------------------------------------
|
// --- the scale ----------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,10 @@ void Controller::set_viewport(Rect px) {
|
|||||||
// transforms read.
|
// transforms read.
|
||||||
plot_->set_viewport(px);
|
plot_->set_viewport(px);
|
||||||
plot_->layout();
|
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
|
// 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.
|
// else now, and the highlight the renderer draws reads what this publishes.
|
||||||
publish_hot();
|
publish_hot();
|
||||||
@@ -132,6 +136,11 @@ void Controller::on_pointer(const PointerEvent& raw) {
|
|||||||
} else if (e.down && mode_ != DragMode::None) {
|
} else if (e.down && mode_ != DragMode::None) {
|
||||||
update_drag(e);
|
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
|
// 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
|
// 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.
|
// 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,
|
// emphasizes the handle the pointer is over *now*, rather than the one it was over before,
|
||||||
// without waiting for the mouse to move again.
|
// without waiting for the mouse to move again.
|
||||||
plot_->layout();
|
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();
|
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
|
// 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
|
// 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
|
// 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();
|
plot_->layout();
|
||||||
|
if (has_pointer_) update_crosshair(pointer_x_, pointer_y_);
|
||||||
publish_hot();
|
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 {
|
u64 Controller::hovered_cursor() const {
|
||||||
if (!has_pointer_) return kInvalid;
|
if (!has_pointer_) return kInvalid;
|
||||||
return cursor_at(pointer_x_, pointer_y_);
|
return cursor_at(pointer_x_, pointer_y_);
|
||||||
|
|||||||
@@ -410,4 +410,18 @@ const FigureLayout& Figure::layout() {
|
|||||||
return 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
|
} // namespace plot
|
||||||
+340
-21
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
namespace plot {
|
namespace plot {
|
||||||
namespace {
|
namespace {
|
||||||
@@ -133,6 +134,77 @@ bool fit_axis_range(Axis& axis, Range bounds) {
|
|||||||
return true;
|
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
|
} // namespace
|
||||||
|
|
||||||
u32 Plot::add_x_axis(const AxisDesc& desc) {
|
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.
|
// interval map to the pixel columns the frame will actually use.
|
||||||
build_regions();
|
build_regions();
|
||||||
build_legend();
|
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_;
|
return layout_;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -927,17 +1003,11 @@ void Plot::build_legend() {
|
|||||||
// plot that lays out exactly as it did before legends existed.
|
// plot that lays out exactly as it did before legends existed.
|
||||||
if (text_ == nullptr || !legend_.visible) return;
|
if (text_ == nullptr || !legend_.visible) return;
|
||||||
|
|
||||||
// Every one of these is a logical size -- the legend is chrome a user reads --
|
// The panel's own chrome -- padding, swatch, row gap, line height -- measured once, in
|
||||||
// while `text_->height()` and the name measures are device: converting each one
|
// device pixels, by the one helper the crosshair's readout uses too: the two panels are
|
||||||
// once, into a local, is what keeps the panel's arithmetic in device pixels
|
// the same object, so they cannot be sized by two copies of this arithmetic.
|
||||||
// throughout, like the strips it is fitted into.
|
const ReadoutChrome chrome = readout_chrome(legend_, style_, *text_);
|
||||||
const f32 pad = style_.device(legend_.pad);
|
|
||||||
const f32 margin = style_.device(legend_.margin);
|
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
|
// 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.
|
// the panel's width after a drop is the width of an entry that is still listed.
|
||||||
legend_name_px_.clear();
|
legend_name_px_.clear();
|
||||||
@@ -970,9 +1040,9 @@ void Plot::build_legend() {
|
|||||||
const bool truncated = keep < entries;
|
const bool truncated = keep < entries;
|
||||||
const f32 widest = truncated ? std::max(legend_name_px_[keep - 1u], ellipsis_w)
|
const f32 widest = truncated ? std::max(legend_name_px_[keep - 1u], ellipsis_w)
|
||||||
: legend_name_px_[keep - 1u];
|
: legend_name_px_[keep - 1u];
|
||||||
const f32 rows = static_cast<f32>(keep + (truncated ? 1u : 0u));
|
const u32 rows = keep + (truncated ? 1u : 0u);
|
||||||
panel_w = 2.0f * pad + swatch_w + pad + widest;
|
panel_w = readout_panel_width(chrome, widest);
|
||||||
panel_h = 2.0f * pad + rows * row_h + (rows - 1.0f) * row_gap;
|
panel_h = readout_panel_height(chrome, rows);
|
||||||
if (panel_w <= room_w && panel_h <= room_h) break;
|
if (panel_w <= room_w && panel_h <= room_h) break;
|
||||||
if (--keep == 0u) return; // not even one entry and its marker fit
|
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
|
// 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
|
// 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.
|
// shorter than the text line is centred on it instead of sitting on its baseline.
|
||||||
const f32 name_x = x + pad + swatch_w + pad;
|
u32 placed = 0;
|
||||||
f32 row_y = y + pad;
|
for (u32 i = 0; i < traces_.size() && placed < keep; ++i) {
|
||||||
for (u32 i = 0; i < traces_.size() && legend.rows.size() < keep; ++i) {
|
|
||||||
const Trace& trace = traces_[i];
|
const Trace& trace = traces_[i];
|
||||||
if (!trace.visible() || trace.name.empty()) continue;
|
if (!trace.visible() || trace.name.empty()) continue;
|
||||||
LegendRow row;
|
LegendRow row;
|
||||||
row.trace = i;
|
row.trace = i;
|
||||||
row.swatch = Rect{x + pad, row_y + 0.5f * (row_h - swatch_h), swatch_w, swatch_h};
|
row.swatch = readout_swatch(chrome, legend.panel, placed);
|
||||||
row.name = Rect{name_x, row_y + 0.5f * (row_h - line_h),
|
const Vec2 pen = readout_text_pen(chrome, legend.panel, placed);
|
||||||
text_->measure(trace.name.c_str()), line_h};
|
row.name = Rect{pen.x, pen.y, text_->measure(trace.name.c_str()), chrome.line_h};
|
||||||
legend.rows.push_back(row);
|
legend.rows.push_back(row);
|
||||||
row_y += row_h + row_gap;
|
++placed;
|
||||||
}
|
}
|
||||||
if (keep < entries) {
|
if (keep < entries) {
|
||||||
// Entries were dropped; the marker takes the row after the last kept one, with no
|
// 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.
|
// lines up with the names above it.
|
||||||
LegendRow marker;
|
LegendRow marker;
|
||||||
marker.ellipsis = true;
|
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);
|
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
|
} // namespace plot
|
||||||
@@ -494,6 +494,10 @@ void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) {
|
|||||||
draw_measures(plot, layout);
|
draw_measures(plot, layout);
|
||||||
draw_annotations(plot);
|
draw_annotations(plot);
|
||||||
draw_legend(plot, layout);
|
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();
|
clear_clip();
|
||||||
draw_tick_labels(plot, layout);
|
draw_tick_labels(plot, layout);
|
||||||
draw_titles(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
|
// -------------------------------------------------------------------- dump
|
||||||
|
|
||||||
bool Renderer::dump_frame_ppm(const char* path, Status* st) const {
|
bool Renderer::dump_frame_ppm(const char* path, Status* st) const {
|
||||||
|
|||||||
Reference in new issue
Block a user