The readout's y was a linear interpolation between the two samples adjacent to the pointer, which answers "what is the signal at this instant" -- a number the series does not contain. On a source whose samples are already averaged or decimated at ingest that is a value nothing measured, and at a zoom past the sample spacing it is visibly not the datum under the pointer. `crosshair_span()`'s `t` becomes a selector rather than a blend factor: the nearer end wins, a tie reading the earlier sample (the bias the span already takes for a degenerate pair). `t` is measured on the samples' own x through `neighbours()`/`x_at()`, so "nearer" is exact on a non-uniform clock too, and the lookup still reads level 0, so the value is a stored sample and never an LOD bucket's aggregate. `crosshair_interpolate()` had no other caller and is gone. `CrosshairRow::marker` is placed from the same y, so the dot follows the reported sample: past the sample spacing it sits beside the polyline rather than on it, deliberately -- one y feeding both keeps the mark and the number from ever disagreeing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1639 lines
74 KiB
C++
1639 lines
74 KiB
C++
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#include <plot/plot/plot.hpp>
|
|
#include <plot/plot/detail/measure_count.hpp>
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
|
|
namespace plot {
|
|
namespace {
|
|
|
|
/// Layout passes. The left strip's width depends on the tick labels, which depend
|
|
/// on the plot width the strip leaves; the loop stops as soon as the strip stops
|
|
/// changing, so a normal plot converges on the second pass and never reaches this.
|
|
constexpr u32 kMaxLayoutPasses = 4;
|
|
|
|
/// Share of the viewport the axes rect keeps however much chrome the label and
|
|
/// title strips ask for. A long SI label or a title wider than a narrow panel can
|
|
/// ask for the whole viewport, and an axes rect with no width or no height draws
|
|
/// nothing at all -- no grid, no tick, no trace -- without reporting anything, so
|
|
/// the strips give way instead. The label strip is what makes the tick values
|
|
/// readable, so it is served first and the title strip takes what is left; a title
|
|
/// longer than its share of the strip is clipped to it rather than allowed to grow
|
|
/// it (see `Renderer::draw_titles()`).
|
|
constexpr f32 kMinAxesShare = 0.5f;
|
|
|
|
const Axis& default_axis() noexcept {
|
|
static const Axis axis{};
|
|
return axis;
|
|
}
|
|
|
|
/// An axis coordinate in the space the live follow arithmetic runs in: log10 when
|
|
/// the axis is logarithmic, the value itself otherwise -- the same space the
|
|
/// controller's `to_space`/`from_space` gestures run in, and the space `Axis::offset`
|
|
/// and a `Live` window's span are stated in. A log axis therefore *scrolls* decades
|
|
/// and holds decades under follow: a linear span on a log axis would rescale the
|
|
/// view on every frame (the defect this fixes).
|
|
f64 to_axis_space(f64 v, bool log) noexcept {
|
|
return log ? Transform::log10_positive(v) : v;
|
|
}
|
|
f64 from_axis_space(f64 v, bool log) noexcept {
|
|
return log ? std::pow(10.0, v) : v;
|
|
}
|
|
|
|
/// The sample range a data interval covers: `[begin, end)` over the samples whose
|
|
/// x lies inside `[lo, hi]`, both ends included -- exactly the samples a region's
|
|
/// statistics are over. The brackets come from `Series::neighbours()`, which names
|
|
/// the samples at or either side of a bound *by the series' own x coordinates*
|
|
/// (`x_at()`), so a series whose samples are not on a uniform grid -- an XY series
|
|
/// -- is covered exactly where its samples actually are, not where a `dx()` grid
|
|
/// would have put them. Still O(1): two neighbour lookups.
|
|
///
|
|
/// False when the interval holds no sample at all, which is a real case: an
|
|
/// interval between two adjacent samples on a sparse series, one entirely before or
|
|
/// after the window, and a non-finite bound.
|
|
bool covered_samples(const data::Series& series, f64 lo, f64 hi, u64& begin, u64& end) {
|
|
const u64 n = series.size();
|
|
if (n == 0u || !std::isfinite(lo) || !std::isfinite(hi) || !(lo <= hi)) return false;
|
|
|
|
// The first sample at or after `lo`: the right neighbour of the bound, unless the
|
|
// bound lands on a sample -- which is then itself the first one inside. A bound
|
|
// outside the window covers everything on its side: before the oldest sample the
|
|
// coverage starts at 0, after the newest there is nothing inside.
|
|
u64 i0 = 0;
|
|
u64 i1 = 0;
|
|
if (!series.neighbours(lo, i0, i1)) {
|
|
if (series.x_at(0u) > lo) {
|
|
i1 = 0u;
|
|
} else {
|
|
return false;
|
|
}
|
|
} else if (series.x_at(i0) >= lo) {
|
|
i1 = i0;
|
|
}
|
|
const u64 b = i1;
|
|
|
|
// One past the last sample at or before `hi`, from the same lookup: the left
|
|
// neighbour of the bound, or the sample the bound landed on.
|
|
u64 j0 = 0;
|
|
u64 j1 = 0;
|
|
if (!series.neighbours(hi, j0, j1)) {
|
|
if (series.x_at(n - 1u) < hi) {
|
|
j0 = n - 1u;
|
|
} else {
|
|
return false;
|
|
}
|
|
} else if (series.x_at(j1) <= hi) {
|
|
j0 = j1;
|
|
}
|
|
const u64 last = j0;
|
|
|
|
if (last < b) return false; // the interval falls between two adjacent samples
|
|
begin = b;
|
|
end = last + 1u;
|
|
return true;
|
|
}
|
|
|
|
/// True when a pair of bounds can be a region's bounds: both of them finite. The one place
|
|
/// this is decided, so `add_region()` and `set_region()` cannot disagree about what they
|
|
/// will hold -- and so no NaN ever reaches the `std::min`/`std::max` below, which propagate
|
|
/// it into *both* stored bounds and would leave a region no drag could recover from.
|
|
bool finite_bounds(f64 x0, f64 x1) noexcept {
|
|
return std::isfinite(x0) && std::isfinite(x1);
|
|
}
|
|
|
|
const Measurement& invalid_measurement() noexcept {
|
|
static const Measurement measurement{};
|
|
return measurement;
|
|
}
|
|
|
|
Axis axis_from(const AxisDesc& desc) {
|
|
Axis axis;
|
|
axis.label = desc.label;
|
|
axis.range = desc.range;
|
|
axis.auto_scale = desc.auto_scale;
|
|
axis.place = desc.place;
|
|
axis.log = desc.log;
|
|
axis.format = desc.format;
|
|
return axis;
|
|
}
|
|
|
|
/// Fits `axis`' range to the union of its traces' bounds, returning false when the
|
|
/// union cannot be fitted and the range is then left untouched.
|
|
///
|
|
/// A linear axis takes 2% of the span on both ends; a zero-span union (a
|
|
/// single-sample series) is widened instead of padded, because a degenerate range
|
|
/// has no span to take 2% of and would leave the transform's inverse ambiguous.
|
|
///
|
|
/// A log axis needs strictly positive data, and this is where a log axis could
|
|
/// otherwise be left broken: an all-negative union has no positive value to show,
|
|
/// and padding it would produce `min > max` once the lower edge was clamped to a
|
|
/// positive bound. Instead the positive part of the union is used -- `[hi*1e-3,
|
|
/// hi]` when the union straddles or starts below zero, since zero has no place on
|
|
/// a log axis -- the pad is multiplicative (2% of the span in log space), and a
|
|
/// union with no positive value at all is refused outright. The caller keeps its
|
|
/// range and is free to retry, which is what lets a stream that starts at zero
|
|
/// begin fitting by itself once it goes positive.
|
|
bool fit_axis_range(Axis& axis, Range bounds) {
|
|
const f64 lo = std::min(bounds.min, bounds.max);
|
|
const f64 hi = std::max(bounds.min, bounds.max);
|
|
if (!std::isfinite(lo) || !std::isfinite(hi)) return false;
|
|
|
|
if (axis.log) {
|
|
if (!(hi > 0.0)) return false; // no positive sample: not auto-fittable
|
|
const f64 lower = lo > 0.0 ? lo : hi * 1e-3;
|
|
if (!(lower > 0.0) || lower > hi) return false;
|
|
if (lower == hi) {
|
|
axis.range = Range{lower / 10.0, hi * 10.0}; // one decade either side
|
|
return true;
|
|
}
|
|
const f64 factor = std::pow(hi / lower, 0.02); // 2% of the span, in log space
|
|
axis.range = Range{lower / factor, hi * factor};
|
|
return true;
|
|
}
|
|
|
|
if (hi > lo) {
|
|
const f64 pad = (hi - lo) * 0.02;
|
|
axis.range = Range{lo - pad, hi + pad};
|
|
return true;
|
|
}
|
|
const f64 pad = std::fabs(lo) * 1e-3 + 0.5;
|
|
axis.range = Range{lo - pad, hi + pad};
|
|
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) {
|
|
x_axes_.push_back(axis_from(desc));
|
|
return static_cast<u32>(x_axes_.size() - 1);
|
|
}
|
|
|
|
u32 Plot::add_y_axis(const AxisDesc& desc) {
|
|
y_axes_.push_back(axis_from(desc));
|
|
return static_cast<u32>(y_axes_.size() - 1);
|
|
}
|
|
|
|
u32 Plot::add_trace(const TraceDesc& desc) {
|
|
Trace trace;
|
|
trace.series = desc.series;
|
|
trace.color = desc.color;
|
|
trace.width = desc.width;
|
|
trace.axis_x = desc.axis_x;
|
|
trace.axis_y = desc.axis_y;
|
|
trace.name = desc.name;
|
|
trace.mode = desc.mode;
|
|
trace.dash_on = desc.dash_on;
|
|
trace.dash_off = desc.dash_off;
|
|
trace.marker = desc.marker;
|
|
trace.marker_size_px = desc.marker_size_px;
|
|
trace.marker_every = desc.marker_every;
|
|
traces_.push_back(trace);
|
|
mark_measures_dirty();
|
|
return static_cast<u32>(traces_.size() - 1);
|
|
}
|
|
|
|
u32 Plot::add_cursor(f64 x, Color color) {
|
|
Cursor cursor;
|
|
// The default orientation: a marker at a data x. `set_cursor_x()` writes `x` for this
|
|
// orientation and refuses it for the other (see CursorOrientation).
|
|
cursor.x = x;
|
|
cursor.color = color;
|
|
cursor.x = snapped_x(cursor);
|
|
cursors_.push_back(cursor);
|
|
mark_measures_dirty();
|
|
return static_cast<u32>(cursors_.size() - 1);
|
|
}
|
|
|
|
u32 Plot::add_h_ruler(f64 y, Color color) {
|
|
Cursor ruler;
|
|
ruler.orientation = CursorOrientation::Horizontal;
|
|
ruler.y = y;
|
|
ruler.color = color;
|
|
ruler.y = snapped_y(ruler);
|
|
cursors_.push_back(ruler);
|
|
// No mark_measures_dirty(): a measurement is read from two *x* coordinates (see
|
|
// MeasureSlot, and `measurement()`'s own sample fetch), and a ruler holds neither, so
|
|
// no cached result can have gone stale by adding one.
|
|
return static_cast<u32>(cursors_.size() - 1);
|
|
}
|
|
|
|
u32 Plot::add_measure(u32 cursor_a, u32 cursor_b, u32 trace) {
|
|
MeasureSlot slot;
|
|
slot.cursor_a = cursor_a;
|
|
slot.cursor_b = cursor_b;
|
|
slot.trace = trace;
|
|
measures_.push_back(slot);
|
|
return static_cast<u32>(measures_.size() - 1);
|
|
}
|
|
|
|
u32 Plot::add_annotation(const Annotation& annotation) {
|
|
annotations_.push_back(annotation);
|
|
return static_cast<u32>(annotations_.size() - 1);
|
|
}
|
|
|
|
void Plot::set_cursor_color(u32 id, Color color) {
|
|
if (id >= cursors_.size()) return;
|
|
cursors_[id].color = color;
|
|
}
|
|
|
|
void Plot::set_cursor_stroke(u32 id, f32 width, Vec2 dash) {
|
|
if (id >= cursors_.size()) return;
|
|
cursors_[id].width = width;
|
|
cursors_[id].dash = dash;
|
|
}
|
|
|
|
void Plot::set_cursor_selectable(u32 id, bool selectable) {
|
|
if (id >= cursors_.size()) return;
|
|
cursors_[id].selectable = selectable;
|
|
}
|
|
|
|
void Plot::set_x_axis_origin(u32 axis, f64 origin) {
|
|
if (axis >= x_axes_.size()) return;
|
|
x_axes_[axis].origin = origin;
|
|
}
|
|
|
|
void Plot::set_y_axis_origin(u32 axis, f64 origin) {
|
|
if (axis >= y_axes_.size()) return;
|
|
y_axes_[axis].origin = origin;
|
|
}
|
|
|
|
void Plot::set_x_axis_origin_from_range(u32 axis, bool on) {
|
|
if (axis >= x_axes_.size()) return;
|
|
x_axes_[axis].origin_follows_range = on;
|
|
}
|
|
|
|
void Plot::set_y_axis_origin_from_range(u32 axis, bool on) {
|
|
if (axis >= y_axes_.size()) return;
|
|
y_axes_[axis].origin_follows_range = on;
|
|
}
|
|
|
|
f64 Plot::x_axis_origin(u32 axis) const {
|
|
return axis < x_axes_.size() ? x_axes_[axis].origin : 0.0;
|
|
}
|
|
|
|
f64 Plot::y_axis_origin(u32 axis) const {
|
|
return axis < y_axes_.size() ? y_axes_[axis].origin : 0.0;
|
|
}
|
|
|
|
void Plot::set_annotation(u32 id, const Annotation& annotation) {
|
|
if (id >= annotations_.size()) return;
|
|
annotations_[id] = annotation;
|
|
}
|
|
|
|
u32 Plot::add_region(const Region& region) {
|
|
// A region whose bounds are not numbers is not an interval this plot can hold, and a
|
|
// `Free` one stored that way could never be dragged back (every motion re-derives both
|
|
// bounds from the stored ones), so it is refused rather than stored: nothing is added,
|
|
// and the id handed back names no region -- `region()` reads a default for it and
|
|
// `region_count()` does not count it.
|
|
if (!finite_bounds(region.x0, region.x1)) return static_cast<u32>(kInvalid);
|
|
// Ordered on the way in, so no caller -- and no drag -- can leave `x0 > x1`
|
|
// behind: the store is the one place this invariant is enforced.
|
|
Region stored = region;
|
|
stored.x0 = std::min(region.x0, region.x1);
|
|
stored.x1 = std::max(region.x0, region.x1);
|
|
regions_.push_back(stored);
|
|
const u32 id = static_cast<u32>(regions_.size() - 1);
|
|
// A region that asks to snap lands on the sample grid as it is added, exactly as
|
|
// `add_cursor()` places a snapping cursor: what a caller hands over is then on the
|
|
// samples of the traces the plot already holds.
|
|
snap_region(id);
|
|
return id;
|
|
}
|
|
|
|
const Axis& Plot::x_axis(u32 axis) const {
|
|
return axis < x_axes_.size() ? x_axes_[axis] : default_axis();
|
|
}
|
|
|
|
const Axis& Plot::y_axis(u32 axis) const {
|
|
return axis < y_axes_.size() ? y_axes_[axis] : default_axis();
|
|
}
|
|
|
|
const Trace& Plot::trace(u32 id) const {
|
|
static const Trace fallback{};
|
|
return id < traces_.size() ? traces_[id] : fallback;
|
|
}
|
|
|
|
const Cursor& Plot::cursor(u32 id) const {
|
|
static const Cursor fallback{};
|
|
return id < cursors_.size() ? cursors_[id] : fallback;
|
|
}
|
|
|
|
f32 Plot::cursor_screen_coordinate(u32 id) const {
|
|
if (id >= cursors_.size()) return 0.0f;
|
|
const Cursor& cursor = cursors_[id];
|
|
// Which coordinate is the marker's, decided once, in `Cursor`: the axis reached for is the
|
|
// one the cursor's own orientation names, and the other orientation's coordinate is not
|
|
// consulted at all -- a ruler is never placed by a dead x.
|
|
if (cursor.is_horizontal()) {
|
|
return static_cast<f32>(
|
|
y_transform(cursor.coordinate_axis()).to_screen_y(cursor.coordinate()));
|
|
}
|
|
return static_cast<f32>(x_transform(cursor.coordinate_axis()).to_screen_x(cursor.coordinate()));
|
|
}
|
|
|
|
const Annotation& Plot::annotation(u32 id) const {
|
|
static const Annotation fallback{};
|
|
return id < annotations_.size() ? annotations_[id] : fallback;
|
|
}
|
|
|
|
const Region& Plot::region(u32 id) const {
|
|
static const Region fallback{};
|
|
return id < regions_.size() ? regions_[id] : fallback;
|
|
}
|
|
|
|
Range Plot::x_range(u32 axis) const { return x_axis(axis).range; }
|
|
|
|
Range Plot::y_range(u32 axis) const { return y_axis(axis).range; }
|
|
|
|
void Plot::set_x_range(u32 axis, Range r) {
|
|
if (axis >= x_axes_.size()) return;
|
|
Axis& a = x_axes_[axis];
|
|
Range live{};
|
|
if (a.follow == XFollow::Live && live_x_bounds(axis, live)) {
|
|
// Under Live the caller is stating a *span* and where the window sits relative
|
|
// to the live edge, not a pair of absolute positions: the next layout()
|
|
// recomputes the window from the series' newest x, so storing `r` verbatim
|
|
// would be a position that survives exactly until the next frame. The offset is
|
|
// what carries the position -- derived here from the live edge as it is *now*,
|
|
// which is what makes a zoom keep its anchor still and a pan shift by the
|
|
// drag's data delta -- and place_live_x_window() clamps it and stores the
|
|
// window that follows from it, so the state is consistent before the next
|
|
// layout rather than only after it.
|
|
//
|
|
// This branch is taken unconditionally on a live axis, and what it refuses to
|
|
// store is what would strand it: `place_live_x_window()` needs a positive span,
|
|
// and every gesture keeps a non-positive one out of its arithmetic, so a
|
|
// degenerate range stored here would leave the axis following nothing, movable
|
|
// by nothing, while `x_follow()` still said Live. An inverted range is a
|
|
// caller's ordering slip, not a request to break the axis' `min <= max`
|
|
// invariant -- the rest of the library reads a range as a range -- so it is
|
|
// normalised; a span that is not a span is ignored outright, leaving the span
|
|
// and offset the axis holds. `auto_fit_x()` is how a window is framed either
|
|
// way.
|
|
//
|
|
// The offset is taken in **axis space** (log10 when the axis is logarithmic):
|
|
// "how far behind the live edge" is a distance the axis' own transform measures,
|
|
// so a log axis states it in decades and the window that follows from it holds
|
|
// decades, not a decaying linear width. The span itself is read back from the
|
|
// stored range by place_live_x_window(), in the same space.
|
|
const f64 lo = std::min(r.min, r.max);
|
|
const f64 hi = std::max(r.min, r.max);
|
|
if (!std::isfinite(lo) || !std::isfinite(hi) || !(hi > lo)) return;
|
|
a.range = Range{lo, hi};
|
|
a.offset = to_axis_space(live.max, a.log) - to_axis_space(hi, a.log);
|
|
place_live_x_window(axis);
|
|
a.auto_scale = false; // as ever: a range a caller chose is not an auto-fit
|
|
return;
|
|
}
|
|
// A bound that is not a number is not a range: storing one would hand the
|
|
// transforms a NaN (every mapping of it is NaN) and clear `auto_scale`, the one
|
|
// way back -- so the call is refused outright and the axis keeps the range *and*
|
|
// the auto-scale state it had. The same rule `set_region()` applies to a region's
|
|
// bounds, and for the same reason.
|
|
if (!std::isfinite(r.min) || !std::isfinite(r.max)) return;
|
|
// Off, or Live with nothing streaming to follow: the range is stored verbatim,
|
|
// which is what every caller of this setter got before follow existed.
|
|
a.range = r;
|
|
a.auto_scale = false;
|
|
}
|
|
|
|
void Plot::set_y_range(u32 axis, Range r) {
|
|
if (axis >= y_axes_.size()) return;
|
|
// The x setter's refusal, on the y side: a non-finite bound would leave the axis
|
|
// with no range to draw and no auto-scale to recover with.
|
|
if (!std::isfinite(r.min) || !std::isfinite(r.max)) return;
|
|
y_axes_[axis].range = r;
|
|
y_axes_[axis].auto_scale = false;
|
|
}
|
|
|
|
void Plot::set_x_auto_scale(u32 axis, bool on) {
|
|
if (axis < x_axes_.size()) x_axes_[axis].auto_scale = on;
|
|
}
|
|
|
|
void Plot::set_y_auto_scale(u32 axis, bool on) {
|
|
if (axis < y_axes_.size()) y_axes_[axis].auto_scale = on;
|
|
}
|
|
|
|
void Plot::set_x_follow(u32 axis, XFollow f) {
|
|
if (axis >= x_axes_.size()) return;
|
|
// A figure link group owns this axis' shared window *and* its follow state:
|
|
// `Figure::link_x()` refuses a `Live` member joiner, and arming `Live` here *after*
|
|
// linking would be the same state reached by the back door -- `push_link()` would
|
|
// write the group's range into an axis that reinterprets it against its own live
|
|
// edge, `adopt_member_changes()` would read it straight back as an external change,
|
|
// and the group would track one member instead of the union of them. Refused.
|
|
if (f == XFollow::Live && x_axes_[axis].link_member) return;
|
|
x_axes_[axis].follow = f;
|
|
// Arming a live axis places its window at once, so x_range() reports the window
|
|
// the next layout produces rather than the one the axis held while it was Off.
|
|
// Nothing else follows from it: the follow state is only ever changed here, and
|
|
// the range the axis already held is the span `Live` starts from (see the header).
|
|
place_live_x_window(axis);
|
|
}
|
|
|
|
void Plot::set_x_link_member(u32 axis, bool linked) noexcept {
|
|
if (axis >= x_axes_.size()) return;
|
|
x_axes_[axis].link_member = linked;
|
|
}
|
|
|
|
XFollow Plot::x_follow(u32 axis) const {
|
|
return axis < x_axes_.size() ? x_axes_[axis].follow : XFollow::Off;
|
|
}
|
|
|
|
void Plot::set_x_offset(u32 axis, f64 units_behind_live) {
|
|
if (axis >= x_axes_.size()) return;
|
|
// No such offset: a NaN would place a window that is not a window, and the last
|
|
// usable one is a better answer than that for a value no gesture can undo. The
|
|
// units are **axis space** ones -- data units on a linear axis, decades on a log
|
|
// one -- matching how `set_x_range()` derives the offset and how
|
|
// `place_live_x_window()` spends it.
|
|
if (!std::isfinite(units_behind_live)) return;
|
|
x_axes_[axis].offset = std::max(0.0, units_behind_live);
|
|
// The upper end of the clamp is what the traces still retain, which only they
|
|
// know: place_live_x_window() applies it, and with it brings the visible window
|
|
// along so x_range() and x_offset() agree before the next layout. A no-op on an
|
|
// axis that is not Live -- the value waits there for when following is armed.
|
|
place_live_x_window(axis);
|
|
}
|
|
|
|
f64 Plot::x_offset(u32 axis) const {
|
|
return axis < x_axes_.size() ? x_axes_[axis].offset : 0.0;
|
|
}
|
|
|
|
bool Plot::x_live_edge(u32 axis, f64& newest) const {
|
|
Range live{};
|
|
if (axis >= x_axes_.size() || !live_x_bounds(axis, live)) return false;
|
|
newest = live.max;
|
|
return true;
|
|
}
|
|
|
|
void Plot::set_x_live_owner(u32 axis, bool live, f64 newest) noexcept {
|
|
if (axis >= x_axes_.size()) return;
|
|
x_axes_[axis].live_owner = live;
|
|
x_axes_[axis].live_owner_newest = live ? newest : 0.0;
|
|
}
|
|
|
|
bool Plot::x_live_owner(u32 axis, f64& newest) const noexcept {
|
|
if (axis >= x_axes_.size() || !x_axes_[axis].live_owner) return false;
|
|
newest = x_axes_[axis].live_owner_newest;
|
|
return true;
|
|
}
|
|
|
|
bool Plot::live_x_bounds(u32 axis, Range& out) const {
|
|
bool any = false;
|
|
for (const Trace& t : traces_) {
|
|
// Only a streaming series has a live edge. An array's bounds cover everything
|
|
// it holds and change only when the caller replaces the data, so `Live` on such
|
|
// a trace would be an anchor that never moves -- the axis is left to its stored
|
|
// range instead (see place_live_x_window).
|
|
if (t.axis_x != axis || !t.visible() || !t.series->streaming()) continue;
|
|
const Range b = t.series->x_bounds();
|
|
if (!std::isfinite(b.min) || !std::isfinite(b.max)) continue;
|
|
if (!any) {
|
|
out = b;
|
|
any = true;
|
|
} else {
|
|
// The union of what the traces retain: the oldest x any of them still holds
|
|
// and the newest any of them holds, so the window is clamped against the ring
|
|
// that reaches furthest back and anchored to the sample that is newest.
|
|
out.min = std::min(out.min, b.min);
|
|
out.max = std::max(out.max, b.max);
|
|
}
|
|
}
|
|
return any;
|
|
}
|
|
|
|
bool Plot::follows_live(u32 axis) const {
|
|
if (x_axes_[axis].follow != XFollow::Live) return false;
|
|
Range live{};
|
|
return live_x_bounds(axis, live);
|
|
}
|
|
|
|
bool Plot::place_live_x_window(u32 axis) {
|
|
Axis& a = x_axes_[axis];
|
|
if (a.follow != XFollow::Live) return false;
|
|
Range live{};
|
|
if (!live_x_bounds(axis, live)) return false;
|
|
// The span and the offset are distances in **axis space** -- log10 when the axis
|
|
// is logarithmic -- the space the controller's gestures are computed in. The span
|
|
// is the axis' own -- what the user zoomed to, or what the last fit produced --
|
|
// and the follow window only moves it: the span is what makes this a scroll
|
|
// rather than a rescale. On a log axis a *linear* span would rescale the view
|
|
// once the newest sample moved: ten units of span at 1e0 is ten decades, at 1e9
|
|
// it is nothing -- while the log-space span holds the visible decades constant
|
|
// whatever the edge does. A span that is not a span is nothing to place a window
|
|
// from, so the range is left exactly as it is.
|
|
const f64 hi_space = to_axis_space(a.range.max, a.log);
|
|
const f64 lo_space = to_axis_space(a.range.min, a.log);
|
|
const f64 span = hi_space - lo_space;
|
|
if (!std::isfinite(span) || !(span > 0.0)) return false;
|
|
// The offset can outlive the window it names: a ring that dropped samples moves
|
|
// the oldest x forward under it, so it is clamped against what the traces hold
|
|
// *now* -- an offset past that would put the window's right edge behind the oldest
|
|
// sample left, i.e. over data that is gone. In axis space, like the span: the
|
|
// retained window's length is measured by the same ruler that measures the
|
|
// offset, or a log axis could hold an offset larger than everything it retains.
|
|
const f64 newest_space = to_axis_space(live.max, a.log);
|
|
const f64 retained = std::max(0.0, newest_space - to_axis_space(live.min, a.log));
|
|
a.offset = std::clamp(a.offset, 0.0, retained);
|
|
const f64 right = newest_space - a.offset;
|
|
a.range = Range{from_axis_space(right - span, a.log), from_axis_space(right, a.log)};
|
|
return true;
|
|
}
|
|
|
|
Transform Plot::x_transform(u32 axis) const {
|
|
const Axis& a = x_axis(axis);
|
|
const Rect r = layout_.axes;
|
|
// Only the x half of the transform is meaningful; the dummy y range keeps the
|
|
// returned Transform's y mapping an identity rather than a second, conflicting
|
|
// axis mapping. A collapsed rect or range is widened inside from_range.
|
|
return Transform::from_range(a.range, Range{}, static_cast<f64>(r.x),
|
|
static_cast<f64>(r.right()), 0.0, 1.0, a.log, false);
|
|
}
|
|
|
|
Transform Plot::y_transform(u32 axis) const {
|
|
const Axis& a = y_axis(axis);
|
|
const Rect r = layout_.axes;
|
|
// Screen y grows downwards, so the range's minimum maps to the rect's bottom
|
|
// and its maximum to the top: py0/py1 are passed bottom-first on purpose.
|
|
return Transform::from_range(Range{}, a.range, 0.0, 1.0, static_cast<f64>(r.bottom()),
|
|
static_cast<f64>(r.y), false, a.log);
|
|
}
|
|
|
|
bool Plot::trace_bounds_union(bool x_side, u32 axis, Range& out) const {
|
|
bool any = false;
|
|
for (const Trace& t : traces_) {
|
|
if ((x_side ? t.axis_x : t.axis_y) != axis || !t.visible()) continue; // null series: skip
|
|
const Range bounds = x_side ? t.series->x_bounds() : t.series->y_bounds();
|
|
if (!std::isfinite(bounds.min) || !std::isfinite(bounds.max)) continue;
|
|
if (!any) {
|
|
out = bounds;
|
|
any = true;
|
|
} else {
|
|
out.min = std::min(out.min, bounds.min);
|
|
out.max = std::max(out.max, bounds.max);
|
|
}
|
|
}
|
|
return any;
|
|
}
|
|
|
|
bool Plot::fit_x_axis(u32 axis) {
|
|
Range union_range{};
|
|
if (!trace_bounds_union(/*x_side=*/true, axis, union_range)) return false;
|
|
return fit_axis_range(x_axes_[axis], union_range);
|
|
}
|
|
|
|
bool Plot::fit_y_axis(u32 axis) {
|
|
Range union_range{};
|
|
if (!trace_bounds_union(/*x_side=*/false, axis, union_range)) return false;
|
|
return fit_axis_range(y_axes_[axis], union_range);
|
|
}
|
|
|
|
void Plot::auto_fit_x() {
|
|
for (u32 i = 0; i < x_axes_.size(); ++i) {
|
|
// Fitting re-arms auto scaling, so the next layout keeps the axis framed. An
|
|
// axis with nothing bound to it has nothing to fit and nothing to re-arm.
|
|
if (!fit_x_axis(i)) continue;
|
|
x_axes_[i].auto_scale = true;
|
|
// Under Live a fit is also the way *back* to the live edge: the span becomes the
|
|
// one that was just fitted, and following means the window ends at the newest
|
|
// sample, so the offset returns to zero. Following stays armed -- a fit is not
|
|
// how a plot stops following, set_x_follow() is.
|
|
if (follows_live(i)) x_axes_[i].offset = 0.0;
|
|
place_live_x_window(i);
|
|
}
|
|
}
|
|
|
|
void Plot::auto_fit_y(u32 axis) {
|
|
if (axis >= y_axes_.size()) return;
|
|
if (fit_y_axis(axis)) y_axes_[axis].auto_scale = true;
|
|
}
|
|
|
|
void Plot::autoscale_axes() {
|
|
for (u32 i = 0; i < x_axes_.size(); ++i) {
|
|
// A Live axis with a live edge is owned by the follow path, and auto scale is
|
|
// inert on it: a fit here would overwrite the span the follow window is built
|
|
// from -- the user's zoom -- on every frame, which is exactly the fight spec §12
|
|
// removes. auto_fit_x() is how a live axis is framed.
|
|
if (x_axes_[i].auto_scale && !follows_live(i)) fit_x_axis(i);
|
|
}
|
|
for (u32 i = 0; i < y_axes_.size(); ++i) {
|
|
if (y_axes_[i].auto_scale) fit_y_axis(i);
|
|
}
|
|
}
|
|
|
|
void Plot::set_cursor_x(u32 id, f64 x, CursorPlacement placement) {
|
|
if (id >= cursors_.size()) return;
|
|
Cursor& cursor = cursors_[id];
|
|
// `x` is the vertical cursor's coordinate and nobody else's: a ruler's `x` is dead, and
|
|
// writing it would leave the struct holding two coordinates that disagree about where the
|
|
// cursor is (see CursorOrientation).
|
|
if (cursor.is_horizontal()) return;
|
|
cursor.x = x;
|
|
// `Free` stores the value and stops: a drag moves the marker under the pointer and the
|
|
// snap is a separate act (`snap_cursor()`), which is what keeps the two orientations
|
|
// agreeing about *when* they land on their grid -- see CursorPlacement.
|
|
if (placement == CursorPlacement::Snapped) cursor.x = snapped_x(cursor);
|
|
mark_measures_dirty();
|
|
}
|
|
|
|
void Plot::snap_cursor(u32 id) {
|
|
if (id >= cursors_.size()) return;
|
|
const Cursor& cursor = cursors_[id];
|
|
// One act, the orientation's own rule: the ruler's half of the same shape a region's
|
|
// `snap_region()` has. Both setters default to `Snapped`, so re-storing the coordinate
|
|
// the cursor already holds is what applies the rule.
|
|
if (cursor.is_horizontal()) {
|
|
set_cursor_y(id, cursor.y);
|
|
} else {
|
|
set_cursor_x(id, cursor.x);
|
|
}
|
|
}
|
|
|
|
void Plot::set_cursor_snap(u32 id, SnapMode mode) {
|
|
if (id >= cursors_.size()) return;
|
|
Cursor& cursor = cursors_[id];
|
|
// The vertical snap, on a vertical cursor: a ruler's snap is `HRulerSnap`, and the two
|
|
// must not drift into meaning each other's job -- `NearestSample` is a sample of an x
|
|
// grid, which a ruler has no notion of.
|
|
if (cursor.is_horizontal()) return;
|
|
cursor.snap = mode;
|
|
cursor.x = snapped_x(cursor);
|
|
mark_measures_dirty();
|
|
}
|
|
|
|
void Plot::set_cursor_y(u32 id, f64 y, CursorPlacement placement) {
|
|
if (id >= cursors_.size()) return;
|
|
Cursor& ruler = cursors_[id];
|
|
if (!ruler.is_horizontal()) return;
|
|
ruler.y = y;
|
|
// As `set_cursor_x()`: `Free` is the drag's half of the gesture, and `snap_cursor()`
|
|
// commits the ruler's own rule on release.
|
|
if (placement == CursorPlacement::Snapped) ruler.y = snapped_y(ruler);
|
|
// No mark_measures_dirty(), for `add_h_ruler()`'s reason: a measurement is read from two
|
|
// x coordinates, and a ruler's y is not one of them.
|
|
}
|
|
|
|
void Plot::set_cursor_h_snap(u32 id, HRulerSnap mode) {
|
|
if (id >= cursors_.size()) return;
|
|
Cursor& ruler = cursors_[id];
|
|
if (!ruler.is_horizontal()) return;
|
|
ruler.h_snap = mode;
|
|
ruler.y = snapped_y(ruler);
|
|
}
|
|
|
|
const Measurement& Plot::measurement(u32 id) const {
|
|
if (id >= measures_.size()) return invalid_measurement();
|
|
const MeasureSlot& slot = measures_[id];
|
|
const data::Series* series = nullptr;
|
|
if (slot.trace < traces_.size()) series = traces_[slot.trace].series;
|
|
const u64 samples = series != nullptr ? series->size() : 0;
|
|
// A saturated ring keeps `samples` constant while its window slides: x0() is
|
|
// what moves, and with it the sample each cursor snaps to and the interval they
|
|
// measure. Caching on (pointer, count) alone would freeze the readout at the
|
|
// first saturated frame until a cursor moved.
|
|
const f64 x0 = series != nullptr ? series->x0() : 0.0;
|
|
|
|
// Recompute only when someone moved a cursor, replaced the trace's series, grew
|
|
// it, or slid the window under it (x0 above). Everything else is a cache hit,
|
|
// which is what keeps a steady frame from re-evaluating every measure.
|
|
if (slot.dirty || series != slot.last_series || samples != slot.last_size ||
|
|
x0 != slot.last_x0) {
|
|
#ifdef PLOT_COUNT_MEASURE_RECOMPUTES
|
|
detail::measure_recomputes().fetch_add(1, std::memory_order_relaxed);
|
|
#endif
|
|
slot.result = Measurement{};
|
|
if (series != nullptr && samples > 0 && slot.cursor_a < cursors_.size() &&
|
|
slot.cursor_b < cursors_.size()) {
|
|
const Cursor& ca = cursors_[slot.cursor_a];
|
|
const Cursor& cb = cursors_[slot.cursor_b];
|
|
// R8: a measurement is read from two *x* coordinates, and a horizontal ruler
|
|
// has no x -- it is the dead field of its orientation (see set_cursor_x()).
|
|
// Evaluating the pair anyway would silently measure from x=0 and report
|
|
// numbers about nothing, so the result stays invalid instead; the id the
|
|
// caller holds is real, and `valid == false` is its answer.
|
|
if (!ca.is_horizontal() && !cb.is_horizontal()) {
|
|
// The cursors land on samples through the series' own lookups
|
|
// (`snap_index` clamps like the old pyramid snap did, and `x_at()` is
|
|
// where the sample really is), so an XY series measures between the
|
|
// samples its own x array names rather than between indices a uniform
|
|
// `dx()` guesses.
|
|
const u64 a = series->snap_index(ca.x);
|
|
const u64 b = series->snap_index(cb.x);
|
|
// snap_index() clamps to [0, size-1], so [a, b+1) is inside the series
|
|
// whenever the cursor pair is not inverted. An inverted pair is not an
|
|
// interval.
|
|
if (a <= b) slot.result = Measure::evaluate(*series, a, b + 1);
|
|
}
|
|
}
|
|
slot.last_series = series;
|
|
slot.last_size = samples;
|
|
slot.last_x0 = x0;
|
|
slot.dirty = false;
|
|
}
|
|
return slot.result;
|
|
}
|
|
|
|
void Plot::invalidate_measurements() noexcept {
|
|
mark_measures_dirty();
|
|
// The region statistics key on the same series identity, so a mutation those keys
|
|
// cannot see has to invalidate them too (see the contract).
|
|
for (RegionStatsSlot& slot : region_stats_) slot.dirty = true;
|
|
}
|
|
|
|
void Plot::set_region(u32 id, f64 x0, f64 x1) {
|
|
if (id >= regions_.size()) return;
|
|
// A bound that is not a number is not a bound, and `std::min`/`std::max` would carry it
|
|
// through the whole pair: accepting one would replace the finite partner as well, so the
|
|
// region could never be dragged back. Refused outright -- as the other bound setters in
|
|
// this file refuse one -- which leaves the interval it already held, and with it the
|
|
// statistics cached for that interval.
|
|
if (!finite_bounds(x0, x1)) return;
|
|
// min/max, so an inverted pair is stored ordered rather than clamped: a drag that
|
|
// crosses one bound over the other *swaps* the interval, which is what keeps the
|
|
// bound the pointer holds movable instead of pinned at the other one.
|
|
regions_[id].x0 = std::min(x0, x1);
|
|
regions_[id].x1 = std::max(x0, x1);
|
|
mark_region_stats_dirty(id);
|
|
}
|
|
|
|
void Plot::snap_region(u32 id) {
|
|
if (id >= regions_.size()) return;
|
|
const Region& region = regions_[id];
|
|
if (region.snap != RegionSnap::NearestSample) return;
|
|
// A region names no axis, so its coordinates are on the plot's primary x axis --
|
|
// the axis its fill and the grid are drawn against (see Region).
|
|
set_region(id, snapped_x(region.x0, 0u), snapped_x(region.x1, 0u));
|
|
}
|
|
|
|
void Plot::remove_region(u32 id) {
|
|
if (id >= regions_.size()) return;
|
|
regions_.erase(regions_.begin() + static_cast<std::ptrdiff_t>(id));
|
|
// The published hot handles name a region by index and are re-indexed with everything
|
|
// else: the removed region's own handle is cleared (nothing is hot there any more, and a
|
|
// frame drawn before the next event would otherwise highlight the region that slid into
|
|
// its slot), and a handle naming a region above the hole follows it down. The `zone` is
|
|
// the grab that went with that index, so it is cleared with it -- a stale zone describing
|
|
// a region that is no longer there would colour the next region's bound.
|
|
//
|
|
// A host must not remove a region *mid-drag*: the controller holds the id it grabbed, and
|
|
// this setter cannot re-index a controller's private `drag_region_`. The gesture's release
|
|
// would then edit whichever region now carries that id. Remove regions between gestures.
|
|
if (hot_.region != kInvalid) {
|
|
if (hot_.region == id) {
|
|
hot_.region = kInvalid;
|
|
hot_.zone = RegionGrab::None;
|
|
} else if (hot_.region > id) {
|
|
hot_.region -= 1u;
|
|
}
|
|
}
|
|
// The cache is keyed by region index, so the removed region's entries name nothing
|
|
// and every entry above the hole names a region one slot further down. The
|
|
// surviving regions' own intervals are untouched -- erase keeps their order -- so
|
|
// their cached statistics stay true and are only re-indexed, not dropped.
|
|
std::erase_if(region_stats_,
|
|
[id](const RegionStatsSlot& slot) { return slot.region == id; });
|
|
for (RegionStatsSlot& slot : region_stats_) {
|
|
if (slot.region > id) slot.region -= 1u;
|
|
}
|
|
}
|
|
|
|
const RegionStats& Plot::region_stats(u32 region, u32 trace) const {
|
|
static const RegionStats none{};
|
|
if (region >= regions_.size() || trace >= traces_.size()) return none;
|
|
|
|
RegionStatsSlot* slot = nullptr;
|
|
for (RegionStatsSlot& candidate : region_stats_) {
|
|
if (candidate.region == region && candidate.trace == trace) {
|
|
slot = &candidate;
|
|
break;
|
|
}
|
|
}
|
|
if (slot == nullptr) {
|
|
// Filled on demand, one entry per pair a caller asks about: a plot with a dozen
|
|
// regions and a dozen traces never pays for the cross product it never queried.
|
|
region_stats_.push_back(RegionStatsSlot{});
|
|
slot = ®ion_stats_.back();
|
|
slot->region = region;
|
|
slot->trace = trace;
|
|
}
|
|
|
|
const data::Series* series = traces_[trace].series;
|
|
const u64 samples = series != nullptr ? series->size() : 0;
|
|
// A saturated ring keeps `samples` constant while its window slides: `x0()` is
|
|
// what moves, and with it which samples the region's data interval covers. Caching
|
|
// on (pointer, count) alone would freeze the readout at the first saturated frame
|
|
// -- the same defect the measurement cache avoids with this same third key.
|
|
const f64 x0 = series != nullptr ? series->x0() : 0.0;
|
|
|
|
if (slot->dirty || series != slot->last_series || samples != slot->last_size ||
|
|
x0 != slot->last_x0) {
|
|
slot->result = series != nullptr ? evaluate_region_stats(regions_[region], *series)
|
|
: RegionStats{};
|
|
slot->last_series = series;
|
|
slot->last_size = samples;
|
|
slot->last_x0 = x0;
|
|
slot->dirty = false;
|
|
}
|
|
return slot->result;
|
|
}
|
|
|
|
RegionStats Plot::evaluate_region_stats(const Region& region, const data::Series& series) {
|
|
RegionStats stats;
|
|
u64 begin = 0;
|
|
u64 end = 0;
|
|
if (!covered_samples(series, region.x0, region.x1, begin, end)) return stats;
|
|
|
|
// Exact interval queries on Series, never the pyramid's *_buckets forms: this
|
|
// interval is not bucket-aligned in general (that is the whole point of a free
|
|
// region), and a bucket-grained answer would silently include samples outside it.
|
|
const Range extremes = series.reduce(begin, end);
|
|
const f64 total = series.sum(begin, end);
|
|
const f64 squared = series.sumsq(begin, end);
|
|
const f64 n = static_cast<f64>(end - begin);
|
|
|
|
stats.x0 = region.x0;
|
|
stats.x1 = region.x1;
|
|
stats.width = region.x1 - region.x0;
|
|
stats.min = extremes.min;
|
|
stats.max = extremes.max;
|
|
stats.mean = total / n;
|
|
stats.rms = std::sqrt(squared / n);
|
|
stats.integral = total * series.dx(); // left-Riemann, the measurement's convention
|
|
stats.count = end - begin;
|
|
stats.valid = true;
|
|
return stats;
|
|
}
|
|
|
|
MeasureSpan Plot::measure_span(u32 id) const noexcept {
|
|
// An id that names nothing, or a measure built from cursor ids that were never
|
|
// registered, is not a span between two markers: `valid` says so rather than
|
|
// letting a caller shade a band between cursors 0 and 1 by accident.
|
|
if (id >= measures_.size()) return MeasureSpan{};
|
|
const MeasureSlot& slot = measures_[id];
|
|
if (slot.cursor_a >= cursors_.size() || slot.cursor_b >= cursors_.size()) return MeasureSpan{};
|
|
// A band is drawn between two *x* coordinates, and a horizontal ruler has none
|
|
// (the same dead field `measurement()` refuses under R8): the span says invalid
|
|
// rather than naming a band one edge of which sits at x=0.
|
|
if (cursors_[slot.cursor_a].is_horizontal() || cursors_[slot.cursor_b].is_horizontal()) {
|
|
return MeasureSpan{};
|
|
}
|
|
return MeasureSpan{slot.cursor_a, slot.cursor_b, true};
|
|
}
|
|
|
|
const PlotLayout& Plot::layout() {
|
|
// Belt and braces for R1: `set_viewport()` normalises a hostile rect, but every
|
|
// `std::clamp` below needs `lo <= hi` to be defined, so the dimensions the chrome
|
|
// is clamped against are read non-negative here too -- layout() is on the path of
|
|
// every host, every frame, and must be undefined nowhere.
|
|
const f32 vw = viewport_.w > 0.0f ? viewport_.w : 0.0f;
|
|
const f32 vh = viewport_.h > 0.0f ? viewport_.h : 0.0f;
|
|
// Live x windows are placed first, so everything below that derives from a range
|
|
// -- the ticks, the grid, the transforms the controller hit-tests through, and the
|
|
// range a caller reads back -- describes the live edge of *this* frame rather than
|
|
// the position the window had when the axis was last touched, and the renderer,
|
|
// the controller and a caller all see one window per frame. O(1) per axis and
|
|
// allocation-free, so a frame pays nothing for it.
|
|
for (u32 i = 0; i < x_axes_.size(); ++i) place_live_x_window(i);
|
|
|
|
// Axes still on auto scale refit here, once per layout, so a growing stream
|
|
// stays framed. A caller gesture clears auto_scale (set_x_range/set_y_range),
|
|
// which is precisely what stops this from fighting the range the user chose --
|
|
// and a live axis is not fitted at all, because the follow path above owns it.
|
|
autoscale_axes();
|
|
|
|
// The label padding is a logical size, so it is converted once, here: every strip
|
|
// and inset below reads this value in device pixels, like the backend metrics it
|
|
// is added to.
|
|
const f32 pad = style_.device(style_.grid_label_pad);
|
|
// Seeds: PlotLayout's defaults when nobody can measure text, otherwise the
|
|
// metrics the text backend does know (its line height, and just the padding
|
|
// until the first tick list says how wide the labels are).
|
|
f32 y_width = PlotLayout{}.y_axis_width;
|
|
f32 x_height = PlotLayout{}.x_axis_height;
|
|
if (text_ != nullptr) {
|
|
y_width = y_axes_.empty() ? 0.0f : 2.0f * pad;
|
|
x_height = text_->height() + 2.0f * pad;
|
|
}
|
|
// A row of a `Figure` gives its x strip to the figure's one shared axis -- the
|
|
// bottom row's own strip, where that axis lives -- so it reserves none: the seed and
|
|
// the fixed point below both start from zero, and `axes` then reaches the viewport's
|
|
// bottom edge. Set here rather than in the loop, because the loop's `need_x` is what
|
|
// the strip wants and this overrides the want, not the measurement.
|
|
const bool x_labels = x_strip_has_labels(x_strip_);
|
|
if (!x_labels) x_height = 0.0f;
|
|
// The title strips, though, are needed before the loop and never change, unlike
|
|
// the label strips: a strip holds exactly one text line per titled axis and no
|
|
// tick label can change that. Nothing at all is reserved without a text backend
|
|
// -- there is then no title to measure, which is what keeps the two defaults
|
|
// above meaningful for the no-text-backend case. The x strip is one text line
|
|
// per titled x axis tall (they are stacked in it); the y strip is one text line
|
|
// per titled y axis *wide*, because the y title is drawn rotated and so spends
|
|
// its length along the axes' height and takes a column, not a row, per titled
|
|
// axis (see y_title_strip_width).
|
|
// The title strip is its own part of the mode: a mode that reserves the labels and
|
|
// no title reserves no row for a title either, and one that reserves the title and no
|
|
// labels still gets its row -- below where the labels would have been.
|
|
const u32 x_titles = x_strip_has_title(x_strip_) ? titled_x_axis_count() : 0u;
|
|
const f32 y_title_w = text_ != nullptr ? y_title_strip_width() : 0.0f;
|
|
const f32 x_title_h = text_ != nullptr && x_titles != 0u
|
|
? static_cast<f32>(x_titles) * text_->height() + 2.0f * pad
|
|
: 0.0f;
|
|
|
|
for (u32 pass = 0;; ++pass) {
|
|
// Chrome is measured against the viewport, then capped so the axes keeps
|
|
// kMinAxesShare of it -- the label strip first, the title strip out of what is
|
|
// left of the allowance -- which is what keeps axes.w and axes.h positive
|
|
// however long the labels and titles are. `viewport_` is what a huge label set
|
|
// is clamped to first, so neither strip can push the rect outside it.
|
|
y_width = std::clamp(y_width, 0.0f, vw);
|
|
x_height = std::clamp(x_height, 0.0f, vh);
|
|
const f32 y_budget = (1.0f - kMinAxesShare) * vw;
|
|
const f32 x_budget = (1.0f - kMinAxesShare) * vh;
|
|
// The figure's common gutter, when one was imposed: a floor on `y_width + y_title_w`
|
|
// so every row's axes starts at the same x. It is served by the *label* strip -- the
|
|
// title column is one text line per titled axis and cannot be widened to fill a gutter
|
|
// -- and it is applied before the title column below, so the pair still fits the cap
|
|
// (the floor is clamped to the same budget) and the invariant `axes.w >= share` holds.
|
|
// `min_y_gutter_` is 0.0 unless a figure set it, so this is a no-op for a lone plot.
|
|
const f32 y_floor = std::min(min_y_gutter_, y_budget);
|
|
y_width = std::min(std::max(y_width, y_floor - y_title_w), y_budget);
|
|
x_height = std::min(x_height, x_budget);
|
|
const f32 y_title_width = std::min(y_title_w, y_budget - y_width);
|
|
const f32 x_title_height = std::min(x_title_h, x_budget - x_height);
|
|
layout_.y_axis_width = y_width;
|
|
layout_.x_axis_height = x_height;
|
|
layout_.y_title_width = y_title_width;
|
|
layout_.x_title_height = x_title_height;
|
|
layout_.axes = Rect{viewport_.x + y_width + y_title_width, viewport_.y,
|
|
vw - (y_width + y_title_width),
|
|
vh - (x_height + x_title_height)};
|
|
layout_.y_title_area = Rect{viewport_.x, layout_.axes.y, y_title_width, layout_.axes.h};
|
|
layout_.y_label_area =
|
|
Rect{layout_.y_title_area.right(), layout_.axes.y, y_width, layout_.axes.h};
|
|
layout_.x_label_area = Rect{layout_.axes.x, layout_.axes.bottom(), layout_.axes.w, x_height};
|
|
layout_.x_title_area = Rect{layout_.axes.x, layout_.x_label_area.bottom(), layout_.axes.w,
|
|
x_title_height};
|
|
|
|
refresh_ticks();
|
|
|
|
if (text_ == nullptr || pass + 1 >= kMaxLayoutPasses) break;
|
|
// The same caps as above, so this compares like with like and the loop still
|
|
// reaches its fixed point on the second pass.
|
|
const f32 need_y = std::min(std::clamp(y_label_width(), 0.0f, vw), y_budget);
|
|
// Overridden, not merely unmeasured: the strip is not reserved, so the fixed
|
|
// point has to be zero for a row that gave its strip away.
|
|
const f32 need_x =
|
|
x_labels ? std::min(std::clamp(text_->height() + 2.0f * pad, 0.0f, vh), x_budget)
|
|
: 0.0f;
|
|
// What the strip *wants* is the label's own width or the figure's floor, whichever is
|
|
// greater -- the floor is a maximum over sources, settled by one `max` and not by
|
|
// asking the loop to grow the strip towards it pass by pass. Comparing the measured
|
|
// need alone would never agree once a floor sits above it, and the loop would run its
|
|
// cap out; comparing the want is what keeps it a fixed point here too.
|
|
const f32 want_y = std::min(std::max(need_y, y_floor - y_title_w), y_budget);
|
|
if (want_y == y_width && need_x == x_height) break; // strips are a fixed point
|
|
y_width = want_y;
|
|
x_height = need_x;
|
|
}
|
|
|
|
build_grid();
|
|
// The regions and the legend are measured last, from the finished `axes`: both
|
|
// live inside the data area and reserve nothing, so neither can move the strips
|
|
// above nor depend on anything but the rect they settled on. The regions are
|
|
// measured from the *placed* live windows, which is what makes a region's data
|
|
// 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 sample values nearest a data x -- so it wants the transforms the
|
|
// settled layout pins, exactly as the legend and the regions do.
|
|
build_crosshair();
|
|
return layout_;
|
|
}
|
|
|
|
void Plot::refresh_ticks() {
|
|
// Ticks are generated for the final axes size: an x axis' density depends on
|
|
// the plot's width and a y axis' on its height. The minimum spacing is a logical
|
|
// size, so it is converted here: a 2x display therefore gets the same *count* of
|
|
// major ticks over the same logical width rather than twice as many.
|
|
for (Axis& a : x_axes_) a.refresh(layout_.axes.w, style_.device(style_.tick_min_px), true);
|
|
for (Axis& a : y_axes_) a.refresh(layout_.axes.h, style_.device(style_.tick_min_px), true);
|
|
}
|
|
|
|
void Plot::build_grid() {
|
|
if (x_axes_.empty() || y_axes_.empty()) {
|
|
grid_.clear();
|
|
return;
|
|
}
|
|
grid_.build(x_axes_[0].ticks, x_axes_[0].range, y_axes_[0].ticks, y_axes_[0].range);
|
|
}
|
|
|
|
void Plot::mark_measures_dirty() noexcept {
|
|
for (MeasureSlot& slot : measures_) slot.dirty = true;
|
|
}
|
|
|
|
void Plot::mark_region_stats_dirty(u32 region) noexcept {
|
|
for (RegionStatsSlot& slot : region_stats_) {
|
|
if (slot.region == region) slot.dirty = true;
|
|
}
|
|
}
|
|
|
|
const data::Series* Plot::snap_series(u32 axis_x) const {
|
|
for (const Trace& t : traces_) {
|
|
if (t.axis_x != axis_x || !t.visible()) continue; // null series: skip
|
|
return t.series;
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
f64 Plot::snapped_x(f64 x, u32 axis_x) const {
|
|
const data::Series* s = snap_series(axis_x);
|
|
if (s == nullptr) return x;
|
|
// Through the series' own index arithmetic (`snap_index`/`x_at`), so a series
|
|
// whose samples are not on a uniform grid snaps to the sample that is really
|
|
// nearest, not to where a `dx()` grid would have put one.
|
|
return s->x_at(s->snap_index(x));
|
|
}
|
|
|
|
f64 Plot::snapped_x(const Cursor& c) const {
|
|
return c.snap == SnapMode::NearestSample ? snapped_x(c.x, c.axis_x) : c.x;
|
|
}
|
|
|
|
f64 Plot::snapped_y(const Cursor& c) const {
|
|
if (c.h_snap != HRulerSnap::NearestTick) return c.y;
|
|
if (!std::isfinite(c.y)) return c.y; // no tick is nearer than a value that is not one
|
|
if (c.axis_y >= y_axes_.size()) return c.y;
|
|
// The tick set of the *last* layout(), never a second tick computation: `refresh_ticks()`
|
|
// is the one place ticks are generated, and the controller lays the plot out before every
|
|
// pointer event, so on the drag path this is the frame's own set (see the header's note on
|
|
// the one-frame staleness a direct setter can see).
|
|
const std::vector<Tick>& ticks = y_axes_[c.axis_y].ticks;
|
|
if (ticks.empty()) return c.y;
|
|
const Tick* nearest = &ticks.front();
|
|
f64 best = std::fabs(nearest->value - c.y);
|
|
for (const Tick& tick : ticks) {
|
|
const f64 d = std::fabs(tick.value - c.y);
|
|
// Strictly nearer, so a value exactly between two ticks lands on the lower one rather
|
|
// than on whichever the iteration happened to visit last.
|
|
if (d < best) {
|
|
best = d;
|
|
nearest = &tick;
|
|
}
|
|
}
|
|
return nearest->value;
|
|
}
|
|
|
|
u32 Plot::titled_x_axis_count() const noexcept {
|
|
u32 n = 0;
|
|
for (const Axis& a : x_axes_) {
|
|
if (!a.label.empty()) ++n;
|
|
}
|
|
return n;
|
|
}
|
|
|
|
u32 Plot::titled_y_axis_count() const noexcept {
|
|
u32 n = 0;
|
|
for (const Axis& a : y_axes_) {
|
|
if (!a.label.empty()) ++n;
|
|
}
|
|
return n;
|
|
}
|
|
|
|
f32 Plot::y_title_strip_width() const {
|
|
// One text line per titled y axis, not the widest title's own width: the title
|
|
// is drawn rotated to read bottom-to-top, so it spends the axes' height and
|
|
// each titled axis needs a column of its own -- which is what keeps a Left/Right
|
|
// pair of titles from being drawn over each other now that they no longer stack
|
|
// down a taller strip.
|
|
if (text_ == nullptr) return 0.0f;
|
|
const u32 titles = titled_y_axis_count();
|
|
if (titles == 0u) return 0.0f;
|
|
return static_cast<f32>(titles) * text_->height() + 2.0f * style_.device(style_.grid_label_pad);
|
|
}
|
|
|
|
f32 Plot::y_label_width() const {
|
|
if (text_ == nullptr || y_axes_.empty()) return 0.0f;
|
|
f32 widest = 0.0f;
|
|
for (const Axis& a : y_axes_) {
|
|
for (const Tick& tick : a.ticks) {
|
|
if (!tick.major || tick.label.empty()) continue;
|
|
widest = std::max(widest, text_->measure(tick.label.c_str()));
|
|
}
|
|
}
|
|
return widest + 2.0f * style_.device(style_.grid_label_pad);
|
|
}
|
|
|
|
void Plot::build_legend() {
|
|
LegendLayout& legend = layout_.legend;
|
|
legend.rows.clear();
|
|
legend.panel = Rect{};
|
|
// Nothing to measure with, or nothing asked for: an empty layout, and with it a
|
|
// plot that lays out exactly as it did before legends existed.
|
|
if (text_ == nullptr || !legend_.visible) return;
|
|
|
|
// 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);
|
|
// 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();
|
|
for (const Trace& trace : traces_) {
|
|
// A trace with no series, no samples, or no name is not an entry -- the same
|
|
// "visible" test every other consumer of a trace makes, plus the name.
|
|
if (!trace.visible() || trace.name.empty()) continue;
|
|
const f32 width = text_->measure(trace.name.c_str());
|
|
legend_name_px_.push_back(legend_name_px_.empty() ? width
|
|
: std::max(legend_name_px_.back(), width));
|
|
}
|
|
const u32 entries = static_cast<u32>(legend_name_px_.size());
|
|
if (entries == 0u) return;
|
|
|
|
const f32 ellipsis_w = text_->measure(kLegendEllipsis);
|
|
const f32 room_w = layout_.axes.w - 2.0f * margin;
|
|
const f32 room_h = layout_.axes.h - 2.0f * margin;
|
|
|
|
// Fit, from every entry down: the panel is a row per entry with `row_gap` between,
|
|
// and, once an entry has been dropped, one more row for the marker that says so.
|
|
// The marker is part of the panel, so it is part of what has to fit -- a legend
|
|
// that dropped entries and then overhung the axes would be the defect the drop
|
|
// exists to avoid. When even one entry plus the marker has no room, there is no
|
|
// panel at all: a plot's data area is not a place to draw a legend that does not
|
|
// fit it.
|
|
u32 keep = entries;
|
|
f32 panel_w = 0.0f;
|
|
f32 panel_h = 0.0f;
|
|
for (;;) {
|
|
const bool truncated = keep < entries;
|
|
const f32 widest = truncated ? std::max(legend_name_px_[keep - 1u], ellipsis_w)
|
|
: legend_name_px_[keep - 1u];
|
|
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
|
|
}
|
|
|
|
// Anchored in the corner `place` names and held `margin` off both edges, which is
|
|
// the inset the fit above was measured against -- so the panel is inside `axes` by
|
|
// construction and can never cross it, whatever corner it was put in.
|
|
f32 x = layout_.axes.x + margin;
|
|
f32 y = layout_.axes.y + margin;
|
|
if (legend_.place == LegendPlace::TopRight || legend_.place == LegendPlace::BottomRight) {
|
|
x = layout_.axes.right() - margin - panel_w;
|
|
}
|
|
if (legend_.place == LegendPlace::BottomLeft || legend_.place == LegendPlace::BottomRight) {
|
|
y = layout_.axes.bottom() - margin - panel_h;
|
|
}
|
|
legend.panel = Rect{x, y, panel_w, panel_h};
|
|
|
|
// The rows themselves: the swatch and the name's line box, both placed here rather
|
|
// than left to a renderer to re-derive (one placement rule, one place to read it).
|
|
// 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.
|
|
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 = 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);
|
|
++placed;
|
|
}
|
|
if (keep < entries) {
|
|
// Entries were dropped; the marker takes the row after the last kept one, with no
|
|
// swatch -- there is no trace to colour -- and the name column's own x, so it
|
|
// lines up with the names above it.
|
|
LegendRow marker;
|
|
marker.ellipsis = true;
|
|
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);
|
|
}
|
|
}
|
|
|
|
void Plot::build_regions() {
|
|
layout_.regions.clear();
|
|
if (regions_.empty()) return;
|
|
|
|
const Rect& axes = layout_.axes;
|
|
// A region's coordinates are on the primary x axis (see Region). The transform is
|
|
// taken *here*, after the live windows have been placed and the strips have
|
|
// settled, so the columns this produces are the ones the frame draws with -- and
|
|
// the controller hit-tests these very numbers rather than mapping the bounds
|
|
// through the transform a second time.
|
|
const Transform tx = x_transform(0u);
|
|
const f32 handle_w = style_.device(style_.handle_width_px);
|
|
const f32 pad = style_.device(style_.grid_label_pad);
|
|
const f32 line_h = text_ != nullptr ? text_->height() : 0.0f;
|
|
|
|
for (u32 i = 0; i < regions_.size(); ++i) {
|
|
const Region& region = regions_[i];
|
|
// An invisible region is not in the layout at all: the layout is exactly what a
|
|
// frame draws and what a pointer can hit, so the two cannot disagree about
|
|
// whether a region is there.
|
|
if (!region.visible) continue;
|
|
|
|
RegionLayout out;
|
|
out.region = i;
|
|
const f32 lo = static_cast<f32>(tx.to_screen_x(region.x0));
|
|
const f32 hi = static_cast<f32>(tx.to_screen_x(region.x1));
|
|
// One tab per bound, centred on it, so the bound's column is the tab's centre
|
|
// line (`handle_column()`) rather than a second number that could drift from it.
|
|
out.left_handle = Rect{lo - 0.5f * handle_w, axes.y, handle_w, axes.h};
|
|
out.right_handle = Rect{hi - 0.5f * handle_w, axes.y, handle_w, axes.h};
|
|
// The fill is the interval clipped to the axes' columns: a region that has
|
|
// scrolled out of the window has nothing to shade, and one wider than the window
|
|
// is not allowed to paint over the label strips.
|
|
const f32 first = std::clamp(lo, axes.x, axes.right());
|
|
const f32 last = std::clamp(hi, axes.x, axes.right());
|
|
out.fill = Rect{first, axes.y, last - first, axes.h};
|
|
|
|
// The label goes on the first text line inside the fill, clamped to `axes` -- the
|
|
// measurement readout's rule, and for its reason: a caption starting at a fill
|
|
// edge near the right of the plot would otherwise run over the axis and out of
|
|
// the panel. A label with no room across the axes is dropped rather than
|
|
// overhanging them, and a zero-width fill (an off-window region) draws none.
|
|
if (!region.label.empty() && line_h > 0.0f && out.fill.w > 0.0f) {
|
|
const f32 width = text_->measure(region.label.c_str());
|
|
if (width + 2.0f * pad <= axes.w) {
|
|
out.label = Rect{std::clamp(out.fill.x + pad, axes.x + pad, axes.right() - width - pad),
|
|
axes.y + pad, width, line_h};
|
|
}
|
|
}
|
|
layout_.regions.push_back(out);
|
|
}
|
|
}
|
|
|
|
// --- crosshair (spec §15) --------------------------------------------------
|
|
|
|
CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept {
|
|
CrosshairSpan span;
|
|
const u64 n = series.size();
|
|
// No samples and no finite query: nothing to bracket.
|
|
if (n == 0u || !std::isfinite(x)) return span;
|
|
|
|
// The bracketing samples, straight from the series (`neighbours()`/`x_at()`):
|
|
// the window is closed at both ends -- the oldest and the newest sample are
|
|
// samples too -- and a query outside it has no neighbours at all. 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. The series owns where its
|
|
// samples are, so an XY series is bracketed by its own x array exactly, where
|
|
// the old uniform-grid index arithmetic would have read samples that do not
|
|
// exist at the positions it assumed.
|
|
u64 i0 = 0;
|
|
u64 i1 = 0;
|
|
if (!series.neighbours(x, i0, i1)) return span;
|
|
|
|
// A query that landed **on** a sample is a span of one, whichever side the
|
|
// series' bracket reported: a sample at the query is the answer, and the two
|
|
// ends of the window are samples too -- a bracket of (0, 1) at a query sitting
|
|
// on sample 0 would read as a span with a width it does not have. Decided on
|
|
// the samples' own coordinates, so it holds for any `neighbours()` impl.
|
|
if (series.x_at(i0) == x) {
|
|
span.begin = span.end = i0;
|
|
span.t = 0.0;
|
|
span.valid = true;
|
|
return span;
|
|
}
|
|
if (series.x_at(i1) == x) {
|
|
span.begin = span.end = i1;
|
|
span.t = 0.0;
|
|
span.valid = true;
|
|
return span;
|
|
}
|
|
|
|
if (i0 == i1) {
|
|
// The series bracketed a single sample for a query that is not on it: there
|
|
// is no second sample to choose between, and this one is the honest answer.
|
|
span.begin = span.end = i0;
|
|
span.t = 0.0;
|
|
} else {
|
|
span.begin = i0;
|
|
span.end = i1;
|
|
// `t` from the samples' own x coordinates, so "which is nearer" is exact on
|
|
// any grid, uniform or not. A degenerate pair (two samples sharing an x)
|
|
// reads the earlier sample.
|
|
const f64 xa = series.x_at(i0);
|
|
const f64 xb = series.x_at(i1);
|
|
span.t = xb > xa ? (x - xa) / (xb - xa) : 0.0;
|
|
if (!(span.t > 0.0)) {
|
|
span.t = 0.0; // a rounding step below the earlier sample is the sample
|
|
} else if (span.t > 1.0) {
|
|
span.t = 1.0;
|
|
}
|
|
}
|
|
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]);
|
|
// **The nearer sample, never a value between two of them.** A readout quotes the data: a
|
|
// number the series does not contain is a claim about a signal between its samples, which
|
|
// is the caller's to make and not a plot's to put in a panel. `span.t` is measured on the
|
|
// samples' own x, so "nearer" is exact on any grid; a tie reads the earlier sample, which
|
|
// is the bias `crosshair_span()` already takes for a degenerate pair.
|
|
out_y = span.t <= 0.5 ? y_begin : y_end;
|
|
return true;
|
|
}
|
|
|
|
void Plot::build_crosshair() {
|
|
CrosshairLayout& crosshair = layout_.crosshair;
|
|
crosshair.active = crosshair_.active;
|
|
crosshair.vertical = Rect{};
|
|
crosshair.horizontal = Rect{};
|
|
crosshair.stroke = 0.0f;
|
|
crosshair.dash = Vec2{};
|
|
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.
|
|
//
|
|
// The stroke and the dash are resolved *here*, once, from the crosshair's own override where
|
|
// it has one and the global style otherwise, and converted to device pixels through
|
|
// `device()`. The boxes below and the renderer's strokes are then one set of numbers rather
|
|
// than two that must be kept in step (spec §17): `stroke` is the device width, `dash` the
|
|
// device (on, off) pair with `off <= 0` meaning solid.
|
|
const CrosshairStyle& cs = crosshair_.style;
|
|
const f32 stroke = style_.device(cs.width >= 0.0f ? cs.width : style_.crosshair_width);
|
|
crosshair.stroke = stroke;
|
|
crosshair.dash =
|
|
Vec2{style_.device(cs.dash_on >= 0.0f ? cs.dash_on : style_.crosshair_dash_on),
|
|
style_.device(cs.dash_off >= 0.0f ? cs.dash_off : style_.crosshair_dash_off)};
|
|
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);
|
|
// The value: the nearest sample's number where the trace has one at this x, and the em dash
|
|
// where it does not. The row is listed either way, so the absence has to be *said* -- a blank
|
|
// value cell reads as a value that has not arrived -- and the dash is measured into the panel's
|
|
// width like any other value, so the row's boxes are placed by one rule.
|
|
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))};
|
|
} else {
|
|
row.value.assign(kCrosshairNoValue);
|
|
}
|
|
}
|
|
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 -- which is a number or the em dash,
|
|
// so every row is the same two runs and the panel is sized by the widest *listed* one, read in
|
|
// O(1) by the prefix-max after a row was dropped from the end.
|
|
crosshair_content_px_.clear();
|
|
for (const CrosshairRow& row : crosshair.rows) {
|
|
const f32 content =
|
|
text_->measure(row.label.c_str()) + 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};
|
|
// Every listed row stages a value run: the number, or `kCrosshairNoValue` where the trace has
|
|
// no sample here. A blank cell would read as a value still to come, which is a different thing
|
|
// from "nothing at this x".
|
|
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
|