Files
plotter/include/plot/plot/plot.hpp
T
Martino Ferrari 1d5b286611
CI / debug (tests + all adapters) (push) Canceled after 0s
CI / coverage (floor + measured table) (push) Canceled after 0s
CI / asan (AddressSanitizer) (push) Canceled after 0s
crosshair: axis badges, clustered cursor labels, and the shared-y horizontal line
- Axis badges: the y value in the y label strip and the x time in the x strip, each a
  full-opacity panel in the crosshair's colour, **centred on the line it names**. The tick
  text is flushed before the fill -- the over-text batch is flushed after every line batch,
  so without that flush the label behind printed through the panel (the "transparent
  backplate" defect).
- Cursor labels: collected across the cursor loop and clustered at its end, so two marks in
  one column share a panel with one line each instead of overprinting, and a label stays on
  its own mark's row.
- Figure: a split row's cells share one y (`set_share_row_y`), so the horizontal line is
  drawn in every cell of the hovered row -- one value, one scale, spanning the row -- and
  still in no other row.

Tests: `figure_crosshair_horizontal_spans_a_split_rows_cells`, plus the render-smoke
additions for the badge placement/flush and the clustered labels.
2026-11-05 17:58:49 +01:00

1182 lines
73 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <span>
#include <vector>
#include <plot/core/rect.hpp>
#include <plot/core/types.hpp>
#include <plot/math/transform.hpp>
#include <plot/plot/annotation.hpp>
#include <plot/plot/axis.hpp>
#include <plot/plot/crosshair.hpp>
#include <plot/plot/cursor.hpp>
#include <plot/plot/density.hpp>
#include <plot/plot/grid.hpp>
#include <plot/plot/legend.hpp>
#include <plot/plot/measure.hpp>
#include <plot/plot/region.hpp>
#include <plot/plot/trace.hpp>
#include <plot/render/style.hpp>
#include <plot/render/text.hpp>
namespace plot {
/// Which parts of a plot's bottom x chrome `layout()` reserves.
///
/// The bottom chrome is two strips -- the *label* strip the tick labels live in and
/// the *title* strip the axis titles live in -- and they are one enum rather than two
/// flags because a strip means little on its own: a title with no labels needs a row
/// of its own below where the labels would have been, and a figure's interior rows want
/// neither. Naming the combinations keeps a caller from switching one off and silently
/// taking the other with it, which is what a boolean would do.
///
/// The one caller that selects a mode is `Figure`: an interior row of a figure that
/// draws its x labels on the bottom only reserves no x chrome at all (`None`), so its
/// `axes` reaches its rect's bottom edge and the figure's one x axis -- the bottom row's
/// own, whose strip the shared axis occupies -- is the figure's only one. A plot drawn
/// on its own is `Both`, which is what it has always been; `LabelsOnly` and `TitleOnly`
/// are for a host that wants one without the other.
enum class XStrip { Both, LabelsOnly, TitleOnly, None };
/// Which parts of the left y chrome `Plot::layout()` reserves. `Both` is a plot drawn on
/// its own. `None` reserves no label width, no title column and ignores the figure's common
/// gutter: it is the mode of every cell of a split row but the first when the figure shares
/// the row's y axis (`Figure::set_share_row_y`), where the first cell draws the one axis.
/// The cell's ticks still refresh and its grid still builds.
enum class YStrip { Both, None };
/// Whether a strip mode reserves the label strip. One rule, read by `Plot::layout()`
/// when it sizes the strip and by the renderer when it decides whether to stage the
/// labels -- a row that reserves none would otherwise stage them over its own data.
constexpr bool x_strip_has_labels(XStrip mode) noexcept {
return mode == XStrip::Both || mode == XStrip::LabelsOnly;
}
/// Whether a strip mode reserves the title strip, the same one rule for the title.
constexpr bool x_strip_has_title(XStrip mode) noexcept {
return mode == XStrip::Both || mode == XStrip::TitleOnly;
}
/// One span of the primary x axis the host has declared invalid, in **data** coordinates --
/// `Region`'s convention. In udpscope's case a reserved hole: samples the stream's counter says
/// exist and that have not been delivered.
///
/// `repaired` says the host has since filled the span's samples in. Such a span is **still
/// reported**, because the shading is a record of what the window was missing, but it is drawn in
/// the fainter of the two tints -- so a reader can distinguish a gap that is still empty from one
/// that has since been repaired, and from a span that has left the list entirely.
struct InvalidSpan {
f64 x0 = 0.0;
f64 x1 = 0.0;
bool repaired = false;
};
/// One invalid span's measured shading: the rect, clipped to the axes' columns, and the state that
/// chooses its tint. A struct and not a bare `Rect` for `RegionLayout`'s reason -- the renderer
/// draws exactly what the layout measured and derives nothing of its own.
struct InvalidSpanLayout {
Rect fill;
bool repaired = false;
};
/// Pixel geometry of a plot's chrome, all of it in the same pixel space as
/// `Rect` (origin top-left) and therefore in **device** pixels: everything here is
/// what the renderer draws with, already converted from the logical metrics in
/// `Style` (see the unit rule on `Style`). `axes` is the area data is drawn into;
/// the label areas are the strips the tick labels live in and the title areas the
/// strips the axis titles live in, each outside `axes` and so never over the data.
///
/// `y_*` strips are left of `axes` and `x_*` ones below it, whatever an axis'
/// `place` says: an axis placed on the opposite edge still has its labels and
/// its title on the side the layout reserves, exactly as it always has.
struct PlotLayout {
Rect axes;
Rect y_label_area;
Rect x_label_area;
/// The y title's strip is a *column*: one text line wide, the axes' height
/// tall, just outside `y_label_area`. The y title is drawn rotated to read
/// bottom-to-top, so what it needs is a line's height rather than the run's own
/// width -- a horizontal run's width is what this strip had to reserve before,
/// and a long title then ate the plot. The x title stays horizontal and gets a
/// row of the same height below `x_label_area`. Both are zero-sized (and
/// unoccupied) when no axis of that direction has a title.
Rect y_title_area;
Rect x_title_area;
/// Left strip width. The default is used when no text backend has been set.
f32 y_axis_width = 56.0f;
/// Bottom strip height. The default is used when no text backend has been set, and
/// it is 0.0f when the label strip is not reserved at all -- see `XStrip`, which is
/// how a row of a `Figure` hands the band to the figure's one shared x axis.
f32 x_axis_height = 22.0f;
/// Width of `y_title_area` and height of `x_title_area`, both 0.0f when nothing
/// is reserved. Every titled axis of a direction gets its own text line inside
/// its strip -- which is what keeps a Left/Right (or Bottom/Top) pair from
/// drawing two titles over each other -- so `x_title_height` is one line per
/// titled x axis and `y_title_width` is one line per titled y axis too: a
/// rotated title occupies length along the axes' height, so a second titled y
/// axis takes a *column* beside the first rather than another row of width. With
/// no text backend there is no title to lay out at all and both stay 0.0f.
f32 y_title_width = 0.0f;
f32 x_title_height = 0.0f;
/// The empty band kept between `axes`' right edge and the viewport's, 0.0f unless a
/// caller asked for one (see `Plot::set_right_gutter()`). Reported because it is
/// clamped by the same cap as the strips above, so what was asked for and what was
/// reserved are not always the same number.
f32 right_gutter = 0.0f;
/// The measured legend: inside `axes`, never reserving any part of it, and empty
/// when there is nothing to draw. It is measured with the same text backend the
/// strips above are, and against the same `axes` rect the caller reads back, so
/// the panel a test asserts and the panel the renderer paints are one set of
/// numbers rather than two derivations of one rule (see LegendLayout).
LegendLayout legend;
/// Every visible region's geometry, in `Plot::region()` order: the shaded
/// interval, the grab tab at each bound and the label's line box. Measured here
/// rather than derived by the renderer, for the legend's reason and one more: the
/// controller's hit test reads the very same rects, so where a region is drawn and
/// where a pointer can grab it cannot drift apart.
std::vector<RegionLayout> regions;
/// Every invalid span's shading, in `Plot::invalid_spans()` order and clipped to the axes'
/// columns. Measured here for the regions' first reason -- the frame draws exactly these
/// numbers -- and reserving nothing, so a hole cannot move the strips the labels were
/// measured into. A span entirely outside the window contributes no rect at all, so this is
/// not index-aligned with `invalid_spans()` and nothing needs it to be: there is no hit test
/// on a band and no label beside it. Each entry carries the span's `repaired` state alongside
/// its rect, so the tint is chosen where the geometry is measured rather than looked up again
/// at draw time.
std::vector<InvalidSpanLayout> invalid_spans;
/// The crosshair's geometry, when one is active: the two lines' stroke boxes, the
/// readout panel and one row per visible trace with the value of the sample nearest the
/// pointer. 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;
/// The box-zoom frame, when a modified drag has one open: the area the gesture is framing, in
/// viewport pixels, already widened along whichever axis the modifier will *not* zoom (see
/// `input::Controller::selection_preview()`, which is where that is decided). Here rather than
/// derived by the renderer, for the regions' reason -- the frame draws exactly these numbers -- and
/// left as the zero rect whenever no gesture is open, which is what both the fill and the four
/// border segments read as "draw nothing".
Rect selection;
};
/// What a pointer is on, as of the last pointer event: the cursor marker under it, the
/// cursor a drag has hold of, and the region zone that is hot. Published to the plot by
/// `input::Controller` -- the layer that owns the only hit test -- and read back by the
/// renderer, which draws the handles being manipulated while knowing nothing about a
/// pointer, a button or a modifier.
///
/// It lives on `Plot` rather than on the renderer, or in `input` where it is produced,
/// because the renderer must not name the input layer -- that is the direction of every
/// dependency here -- and because it is per-plot state: two plots drawn into one frame
/// each draw their own hot handle, and the controller that drives one of them knows
/// nothing of the other.
///
/// `cursor` is the marker the pointer is over and `grabbed_cursor` the one a drag is
/// moving. They are two fields rather than one because a drag can leave its marker
/// behind -- the pointer moves on while the cursor follows it -- and a marker is drawn
/// highlighted while it is either. `region` and `zone` are the region and the zone the
/// pointer is on, or the zone an open drag resolved: a grab therefore keeps its own zone
/// emphasized for the whole gesture rather than the emphasis following the pointer. Every
/// id field is `kInvalid`, and `zone` `RegionGrab::None`, when nothing is hot.
struct HotHandles {
u64 cursor = kInvalid;
u64 grabbed_cursor = kInvalid;
u64 region = kInvalid;
RegionGrab zone = RegionGrab::None;
};
/// The public façade: axes, traces, cursors, annotations and their layout.
///
/// It is pure CPU state -- no GL context, no font of its own. The renderer hands
/// it the font to measure labels with (set_text_backend) and reads layout(),
/// the transforms and the measurement results back out. Everything the renderer
/// draws (tick geometry, grid segments, cursor x in data space) is described in
/// data coordinates and pixels here; nothing is uploaded anywhere.
///
/// Owned collections are indexed by the `u32` id the matching `add_*` returned.
/// A stale or unknown id is not fatal: accessors return a default-constructed
/// object and measurement() returns an invalid result.
class Plot {
public:
Plot() = default;
// --- viewport, style, text metrics ------------------------------------
/// The rectangle this plot occupies on screen, in framebuffer pixels. A
/// negative width or height is normalised to 0: the layout clamps against
/// the viewport's dimensions and a negative would make those clamps
/// ill-formed (`std::clamp` with `lo > hi`), so the invariant is repaired
/// at the one place a hostile rect can enter.
void set_viewport(Rect px) noexcept {
if (px.w < 0.0f) px.w = 0.0f;
if (px.h < 0.0f) px.h = 0.0f;
viewport_ = px;
}
const Rect& viewport() const noexcept { return viewport_; }
/// Set from the renderer, which owns presentation: `Renderer::draw()` copies
/// its own `style()` in here every frame, so a style set on the plot is what a
/// plot drawn *without* a renderer lays out with -- a frame drawn by one always
/// uses the renderer's values.
void set_style(const render::Style& s) noexcept { style_ = s; }
const render::Style& style() const noexcept { return style_; }
/// Device pixels per logical unit: what every metric in `style()` means, and what
/// the controller's pointer tolerances are scaled by. Set from the renderer, which
/// owns presentation -- `Renderer::draw()` writes `style().ui_scale` here before it
/// lays the chrome out, so the strips and the strokes cannot disagree about the
/// scale -- and set directly by a host that lays a plot out itself, or by a
/// controller whose plot no renderer draws.
void set_ui_scale(f32 scale) noexcept { style_.ui_scale = scale; }
/// The scale the last layout was computed at. 1.0 until a host says otherwise.
f32 ui_scale() const noexcept { return style_.ui_scale; }
/// Which parts of the bottom x chrome `layout()` reserves; `XStrip::Both` by
/// default, which is what a plot drawn on its own has always been.
///
/// A part that is not reserved costs nothing: the strip has no height (or the title
/// no row), `axes` grows into the band, and the renderer stages no run for it. A
/// figure's interior rows are the case that asked for it -- they sit under one shared
/// x axis, so they reserve no x chrome at all -- and `Figure::set_x_labels_only_on_bottom()`
/// is what selects their mode. Nothing else about such a row changes: its ticks still
/// refresh and its grid still builds, because the tick list is what a grid and a
/// shared axis are drawn from, not the strip.
void set_x_strip_mode(XStrip mode) noexcept { x_strip_ = mode; }
XStrip x_strip_mode() const noexcept { return x_strip_; }
/// Which parts of the left y chrome `layout()` reserves; `YStrip::Both` by default. With
/// `YStrip::None` the y label width, the y title column and the figure's common gutter are
/// all zero, and the renderer stages no y labels or title (`y_label_area.w == 0`).
void set_y_strip_mode(YStrip mode) noexcept { y_strip_ = mode; }
YStrip y_strip_mode() const noexcept { return y_strip_; }
/// The font layout() measures tick labels with. Borrowed, not owned, and may be
/// null -- then the label areas fall back to PlotLayout's defaults and no string
/// is measured: a plot with no renderer lays out with whatever the host sets
/// here, which is for consumers that draw the plot themselves.
///
/// A plot drawn by a `Renderer` is the resolution the other way round: the
/// renderer draws the labels with its own bitmap font, so it installs that font
/// here on every `draw()` and the strips are measured with the metrics the
/// labels are actually drawn with. A backend installed before the first draw is
/// therefore replaced, with a debug-build diagnostic naming the mismatch.
void set_text_backend(const render::TextBackend* text) noexcept { text_ = text; }
/// The backend layout() measures labels with, or null when none is set. A
/// renderer replaces whatever it finds here with its own font (see
/// set_text_backend()); a plot nobody draws keeps it.
const render::TextBackend* text_backend() const noexcept { return text_; }
// --- construction -----------------------------------------------------
u32 add_x_axis(const AxisDesc& desc);
u32 add_y_axis(const AxisDesc& desc);
u32 add_trace(const TraceDesc& desc);
u32 add_cursor(f64 x, Color color);
/// Adds a horizontal ruler at a data y (spec §14): the same instrument as `add_cursor()`
/// turned a quarter turn. It spans the axes' width at `y`, is grabbable and draggable
/// along y with the very same hit radius, and reports `CursorOrientation::Horizontal`.
///
/// `y` is on y axis 0, as `add_cursor()`'s `x` is on x axis 0. Its snap is `HRulerSnap`,
/// not `SnapMode`: a series is sampled in x, so the nearest *tick* of the y axis is the
/// only grid a y ruler can land on (`set_cursor_h_snap()`). It takes the next free id in
/// the one `cursors_` sequence both orientations share, so a vertical cursor added after
/// it is that id plus one.
u32 add_h_ruler(f64 y, Color color);
/// Measures the interval between two cursors over the series of `trace`.
u32 add_measure(u32 cursor_a, u32 cursor_b, u32 trace = 0);
u32 add_annotation(const Annotation& annotation);
/// Moves an annotation that is already in the plot, keeping its id -- what a host whose
/// annotation is a *readout* needs (the point where a cursor crosses a trace moves with
/// the cursor). `Plot` has no `remove_annotation`, so the same parking discipline as the
/// cursors applies: set `visible = false` rather than dropping it, and re-place where it
/// belongs. An id that names no annotation is ignored, not an error, exactly as every
/// other setter here treats a stale id.
void set_annotation(u32 id, const Annotation& annotation);
/// Adds a region. Its bounds are ordered and, when the region asks to snap, pulled
/// onto the sample grid as it is added -- exactly as `add_cursor()` places a
/// snapping cursor -- so a region handed to a plot that already holds samples is on
/// the grid from its first frame.
///
/// A region whose bounds are not both finite is *refused*: nothing is added, and the
/// returned id names no region (`region()` reads a default for it and `region_count()`
/// does not count it). Storing one would be worse than useless, because a `Free` region
/// holding a bound that is not a number could never be dragged back -- every motion
/// re-derives both bounds from the stored ones.
u32 add_region(const Region& region);
// --- accessors --------------------------------------------------------
// An id that does not name an object yields a default-constructed one, so a
// stale id can never read out of bounds.
u32 x_axis_count() const noexcept { return static_cast<u32>(x_axes_.size()); }
u32 y_axis_count() const noexcept { return static_cast<u32>(y_axes_.size()); }
/// Axes of that direction that carry a title, i.e. that `layout()` reserves a
/// text line for and `Renderer::draw_titles()` draws one for. Zero when no
/// backend can measure a title, whatever the labels say.
u32 titled_x_axis_count() const noexcept;
u32 titled_y_axis_count() const noexcept;
u32 trace_count() const noexcept { return static_cast<u32>(traces_.size()); }
u32 cursor_count() const noexcept { return static_cast<u32>(cursors_.size()); }
u32 measure_count() const noexcept { return static_cast<u32>(measures_.size()); }
u32 annotation_count() const noexcept { return static_cast<u32>(annotations_.size()); }
u32 region_count() const noexcept { return static_cast<u32>(regions_.size()); }
const Axis& x_axis(u32 axis) const;
const Axis& y_axis(u32 axis) const;
const Trace& trace(u32 id) const;
const Cursor& cursor(u32 id) const;
/// The device coordinate a cursor's marker sits at: the column of its `x` for a vertical
/// cursor, the row of its `y` for a horizontal ruler -- the one coordinate its own
/// orientation owns, mapped through the axis that coordinate is in. The one place that
/// choice is made, so the hit test the controller runs and the stroke the renderer stages
/// cannot disagree about where a cursor is (see CursorOrientation), and a host can place
/// its own tooltip or status line on the same number. 0.0f for an id that names no cursor,
/// like every other accessor here.
f32 cursor_screen_coordinate(u32 id) const;
const Annotation& annotation(u32 id) const;
/// The region with that id, or a default-constructed one: as everywhere else on
/// this class, a stale id reads a default rather than out of bounds.
const Region& region(u32 id) const;
// --- ranges and transforms --------------------------------------------
Range x_range(u32 axis) const;
Range y_range(u32 axis) const;
/// Sets an explicit range. Choosing a range is a user decision, so it also
/// clears that axis' auto_scale: a later layout() must not snap the range back.
/// auto_fit_x()/auto_fit_y() re-arm it.
///
/// **Under `XFollow::Live` on an x axis that has a live edge, the argument is not
/// an absolute window.** It is read as the *span* the window should have and the
/// *offset* it implies -- `offset = newest - r.max`, clamped to what the traces
/// still retain -- and the visible window follows from those at the live edge of
/// the moment. A caller who expects `r` to stay where it was put is in the wrong
/// mode: `XFollow::Off` stores the range verbatim, which is what every existing
/// caller gets. The point of the reinterpretation is that every gesture reaches
/// the x range through this one setter, so pan, wheel, box and axis zoom all keep
/// following for free instead of each needing its own special case -- and none of
/// them clears the follow state.
void set_x_range(u32 axis, Range r);
void set_y_range(u32 axis, Range r);
/// Re-arms (or disarms) the per-layout auto scaling of one axis.
///
/// On an x axis that is armed `XFollow::Live` and has a live edge, this switch is
/// inert: `layout()` leaves that axis to the follow path and never fits it (see
/// `set_x_follow()`), so an axis that reads `auto_scale == true` -- including the
/// `AxisDesc` default -- can still be one whose window is not being fitted. The
/// follow state wins because following is the more specific statement about that
/// axis, and `auto_fit_x()` is what frames such an axis.
void set_x_auto_scale(u32 axis, bool on);
void set_y_auto_scale(u32 axis, bool on);
// --- live follow ------------------------------------------------------
/// Arms or disarms live following on one x axis; see XFollow for what `Live`
/// makes the visible window. **Only this disarms it**: no gesture -- pan, wheel,
/// box zoom, axis zoom -- and no programmatic `set_x_range` reaches the follow
/// state, because "an interaction silently switched off following for good" is
/// the defect this state exists to remove (spec §12).
///
/// Arming an axis that has a live edge places its window at the live edge at
/// once, so `x_range()` reports the window the next layout will produce; the span
/// it starts from is the span the axis already had, so a caller that wants the
/// retained window framed calls `auto_fit_x()`. An axis whose stored span is not
/// a span -- a range a caller left degenerate while the axis was `Off`, which this
/// setter does not second-guess -- has no window to place and nothing to follow
/// with; arm it and call `auto_fit_x()`, and note that a range set *under* `Live`
/// cannot leave it in that state (see `set_x_range()`). Turning `Live` off leaves the
/// axis exactly where it is: the last live window becomes an ordinary stored
/// range, and `auto_scale` is untouched.
///
/// While an axis is `Live` *and* has a live edge, this path owns its range, and
/// that has two consequences worth knowing. `auto_scale` is inert on the axis:
/// `layout()` does not fit it, because the fit would overwrite the span the
/// follow window is built from -- the user's zoom -- on every frame, and the
/// follow path wins because following is the more specific statement about that
/// axis (`auto_fit_x()` is how such an axis is framed, and it does fit). And the
/// window is recomputed every frame, so its position is a *distance behind the
/// newest sample* rather than a pair of numbers. With no live edge -- no trace on
/// the axis, or none of them streaming -- nothing is recomputed and the axis
/// keeps its stored range, `auto_scale` included, exactly as an `Off` axis does.
///
/// On an axis that is a member of a `Figure` x link group, arming `Live` is
/// **refused** (the state is left as it was): the group owns the shared window and
/// its follow state, exactly as `Figure::link_x()` refuses a `Live` member joiner.
/// See `set_x_link_member()`.
void set_x_follow(u32 axis, XFollow f);
/// The follow state of one x axis. `Off` for an axis that does not exist.
XFollow x_follow(u32 axis) const;
/// How far behind the live edge the visible window's right edge sits, in data
/// units. Clamped to `[0, retained span]`: the upper limit is what the traces
/// still hold, so the window's right edge never goes behind the oldest sample the
/// ring has, and the empty region in front of it -- data the ring dropped -- is
/// what the view is left showing rather than a window invented over samples that
/// are gone. The lower limit is why dragging toward the live edge stops instead
/// of turning the window inside out. On an axis that is not `Live` the value is
/// simply stored: it applies when following is armed, and changes nothing before.
///
/// A value that is not a number is refused outright -- the window it would place
/// is not a window -- and leaves the last offset in place.
void set_x_offset(u32 axis, f64 units_behind_live);
/// The offset in force, as of the last `set_x_offset()`, `set_x_range()` or
/// `layout()`. 0.0 for an axis that does not exist.
f64 x_offset(u32 axis) const;
/// The newest x any visible streaming trace bound to `axis` holds: the coordinate
/// a `Live` window is anchored to, which the controller uses as the anchor of a
/// zoom made from outside `axes`. False -- and `newest` untouched -- when the axis
/// has no such trace, i.e. when there is no live edge to follow.
bool x_live_edge(u32 axis, f64& newest) const;
/// Publishes -- or clears -- the live edge of an owner that is following this axis'
/// window on the plot's behalf (a `Figure` link group while it follows; see
/// `Axis::live_owner`). A no-op for an axis that does not exist.
void set_x_live_owner(u32 axis, bool live, f64 newest) noexcept;
/// Whether an owner is following this axis' window, and the edge it is following: the
/// answer `set_x_live_owner()` last wrote. False -- and `newest` untouched -- for an
/// axis no owner has claimed, and for an axis that does not exist.
bool x_live_owner(u32 axis, f64& newest) const noexcept;
/// Publishes -- or clears -- that this axis is a member of a `Figure` x link group.
/// `Figure::link_x()` sets it when a row joins and `unlink_row()` clears it when one
/// leaves; it is what makes `set_x_follow(Live)` refused on a linked axis, because
/// the group owns the shared window and the follow state. A no-op for an axis that
/// does not exist. Not for callers outside the figure: a `Plot` that is not in a
/// figure never has a linked axis.
void set_x_link_member(u32 axis, bool linked) noexcept;
/// The y-side twin of `set_x_link_member()`: publishes that this y axis is a member
/// of its row's shared-y group (`Figure::set_share_row_y()`). A linked y axis is
/// never fitted by `layout()` -- the group owns its range and auto scale, and fits
/// the union of every member's traces -- but a gesture or `set_y_range()` still
/// writes it, which is how the group learns of a change. A no-op for an axis that
/// does not exist. Not for callers outside the figure.
void set_y_link_member(u32 axis, bool linked) noexcept;
/// Union of the bounds of the visible traces bound to y axis `axis`: what a shared-y
/// group collects from each member. False -- and `out` untouched -- when none.
bool y_data_bounds(u32 axis, Range& out) const;
/// Fits y axis `axis` to `bounds` the way `auto_fit_y()` fits it to its own traces
/// (same padding, same log handling), leaving `auto_scale` alone. False -- range
/// untouched -- when the axis does not exist or cannot be fitted to `bounds`.
bool fit_y_bounds(u32 axis, Range bounds);
/// Data -> pixels for one axis, built from the axis' range, log flag and the
/// current layout. Always invertible: a collapsed range or a collapsed axes
/// rect is widened by math::Transform, so to_data_*(to_screen_*(v)) == v.
Transform x_transform(u32 axis) const;
Transform y_transform(u32 axis) const;
/// Fits every x axis to the union of the series bounds of the traces bound to
/// it, padded by 2% of the span, and re-arms auto scale on the axes it fitted.
/// An axis with no visible trace is left exactly as it was.
///
/// A log axis is fitted in log space and needs a positive value to show. When
/// its traces hold none (every sample negative or zero) the fit is refused: the
/// axis keeps its current range, auto scale is not re-armed, and the next
/// auto-fit tries again -- so a stream that starts at zero begins fitting on its
/// own once it goes positive. A log axis is therefore never left with `min >=
/// max` or a non-positive bound by an auto-fit.
///
/// Under `XFollow::Live` the fit is also the way back to the live edge: the span
/// becomes the retained window's (with the same 2% margin) and the offset returns
/// to 0, so the window ends at the newest sample again. It does **not** turn
/// following off -- a fit is not how a plot stops following; `set_x_follow()` is.
void auto_fit_x();
/// Same for one y axis, over that axis' traces' y bounds.
void auto_fit_y(u32 axis);
// --- cursors and measurements -----------------------------------------
/// Moves a **vertical** cursor along x, and invalidates the measurements that depend
/// on cursors. With the default `CursorPlacement::Snapped`, a `SnapMode::NearestSample`
/// cursor is pulled onto the nearest sample of the first visible trace on its x axis;
/// with `CursorPlacement::Free` the value is stored verbatim, whatever the snap, and the
/// grid is applied later by `snap_cursor()`.
///
/// The two are the two things a caller does with this method: place a marker
/// programmatically (default -- snap now), or move it under a pointer for the length of
/// a gesture (`Free`). `input::Controller::move_cursor()` is the second, so **the value
/// this stores while that drag runs, and the value `cursor(id).x` reports, is the free
/// pointer position, not the snapped one** -- the snap is committed once, on release
/// (`snap_cursor()`, from `Controller::end_drag()`). A caller reading `cursor(id).x`
/// mid-drag reads what the user sees under the pointer; after the drop it reads the
/// snapped result. A host that wants the cursor to *hop* between samples as it is
/// dragged -- the old behaviour -- calls this without `Free` on every motion.
///
/// A horizontal ruler's `x` is not a coordinate of its: this is *ignored* for one, so
/// the dead field of an orientation can never be written behind its back (see
/// CursorOrientation). `set_cursor_y()` is the ruler's half of the pair.
void set_cursor_x(u32 id, f64 x, CursorPlacement placement = CursorPlacement::Snapped);
/// Sets the snap of a **vertical** cursor, re-placing it under the new rule at once.
/// Ignored for a horizontal ruler, whose snap is `HRulerSnap` -- see
/// `set_cursor_h_snap()`.
void set_cursor_snap(u32 id, SnapMode mode);
/// Applies a cursor's own snap rule to the coordinate it already holds: the nearest
/// sample for a `SnapMode::NearestSample` marker, the nearest y tick for a
/// `HRulerSnap::NearestTick` ruler, and nothing for a `Free` cursor of either
/// orientation. The one act `input::Controller::end_drag()` commits a cursor drag with,
/// so the drop lands on the grid -- the same shape as `snap_region()`, which a region
/// drag ends with, and the reason both orientations snap on drop rather than
/// continuously.
void snap_cursor(u32 id);
/// Moves a **horizontal** ruler along y, re-placing it under its own snap rule unless
/// `CursorPlacement::Free` is passed -- in which case the value is stored verbatim and
/// the snap waits for `snap_cursor()`, exactly as `set_cursor_x()`'s `Free` does for a
/// marker. The two orientations therefore snap at the *same* moment of a gesture (the
/// drop), not one continuously and the other on release.
///
/// Ignored for a vertical cursor -- `y` is not its coordinate -- and stored verbatim for
/// a ruler, exactly as `set_cursor_x()` stores a `Free` cursor's x: a y outside the axis'
/// range is a position the *renderer* skips, not one this store refuses, which is what
/// lets a caller park a ruler off range on purpose. The *drag* is what clamps: a pointer
/// that has left the axes has no data under it, so `input::Controller` keeps a dragged
/// ruler on its axis' range rather than storing a coordinate no pointer could reach
/// (see `Controller::move_cursor()`).
void set_cursor_y(u32 id, f64 y, CursorPlacement placement = CursorPlacement::Snapped);
/// Sets the snap of a **horizontal** ruler: `NearestTick` pulls its y onto the nearest
/// tick of its y axis' tick set -- majors and minors alike, the one list the axis
/// refreshed -- and `Free` leaves it where it is.
///
/// The tick set it snaps to is the *last* `layout()`'s, because ticks are generated there
/// and nowhere else: snapping does not recompute them, so the library keeps one tick
/// computation rather than two. On the drag path that is the frame's own set --
/// `input::Controller::on_pointer()` lays the plot out before every event it applies --
/// while a caller that moves a y range and then sets a ruler's y *without* laying out
/// snaps against the previous set, one frame stale. Laying the plot out refreshes it.
///
/// Ignored for a vertical cursor, whose snap is `SnapMode` -- see `set_cursor_snap()`.
void set_cursor_h_snap(u32 id, HRulerSnap mode);
/// Recolours a cursor that already exists. A *readout* cursor -- one a host keeps as a
/// marker for something it computed, rather than an instrument the user placed -- has to
/// be told apart from its neighbours, and its colour is how it says which readout it
/// belongs to. An id that names no cursor is ignored, like every other setter here.
void set_cursor_color(u32 id, Color color);
/// A cursor's caption (see `Cursor::label`): a vertical marker's badge text -- a horizontal
/// ruler writes its own value and needs no caption to be readable. Rendered on a
/// backplate, and labels whose backplates meet are merged into one panel with one line per
/// label (see the renderer), which is what keeps a marker pair half a division apart
/// readable instead of a smear of two texts over one column. An empty string removes the
/// caption; an id that names no cursor is ignored, like every other setter here.
void set_cursor_label(u32 id, std::string label);
/// A cursor's stroke: its logical width (`<= 0` for the style's default) and its dash
/// (`dash.off <= 0` for solid). The instrument's own, like its colour -- see `Cursor::width`.
void set_cursor_stroke(u32 id, f32 width, Vec2 dash);
/// Makes a cursor unselectable (see `Cursor::selectable`): the pointer cannot hover or
/// grab it, which is what a host wants for a marker it draws as a *readout*. An id that
/// names no cursor is ignored, like every other setter here.
void set_cursor_selectable(u32 id, bool selectable);
/// Sets the value an axis' labels are written relative to (see `Axis::origin`). The ticks
/// are generated on that shifted grid too, so a host that zooms into a window can label it
/// from its own left edge. An id that names no axis of that orientation is ignored, like the
/// other setters here -- x and y ids are separate sequences, so the orientation is part of
/// the call.
void set_x_axis_origin(u32 axis, f64 origin);
void set_y_axis_origin(u32 axis, f64 origin);
/// The origin the matching setter stored (0 for an axis that never had one).
f64 x_axis_origin(u32 axis) const;
f64 y_axis_origin(u32 axis) const;
/// Makes an axis' label origin follow its own range (see `Axis::origin_follows_range`): the
/// labels then count from the left edge the frame actually has, which is the only way a
/// host can label a *moving* window from its start without being a frame behind.
void set_x_axis_origin_from_range(u32 axis, bool on);
void set_y_axis_origin_from_range(u32 axis, bool on);
/// A one-line caption for the x axis, drawn as **chrome** in the bottom-left corner of the
/// axes with the legend's own backplate behind it. It exists for the thing a shifted origin
/// leaves unsaid: labels counted from a window's left edge are only readable beside the value
/// that edge has, and the reader adds the two. An `Annotation` cannot serve -- its anchor is a
/// data coordinate, so the caption crawls across the plot under a pan and drifts under a zoom,
/// which is the one thing a legend-like panel must not do. The text is the host's, because only
/// the host knows how much precision the sum needs: the axis' own label form carries three
/// significant figures, which is exactly what made the shifted origin necessary.
/// Empty (the default) draws nothing.
void set_x_note(std::string text);
const std::string& x_note() const noexcept { return x_note_; }
/// Spans of the primary x axis the host has declared invalid -- in udpscope's case a reserved
/// hole: samples the stream's counter says exist and that have not been delivered. Each span's
/// `x0`/`x1` are in **data** coordinates, `Region`'s convention, and is shaded behind
/// the traces and over the grid.
///
/// Deliberately *not* a `Region`: a region is interactive -- draggable bounds, hot zones, a
/// label, a cached statistic, a controller hit test -- and a plot can carry dozens of holes at
/// once, which would put a hundred-odd grab targets in the way of every pointer event for
/// marks nobody can usefully drag. This is passive shading and nothing else.
///
/// The span is copied, so the caller's storage need not outlive the call. Spans need not be
/// sorted and may overlap; an empty call clears the shading.
///
/// A span whose `repaired` flag is set is shaded more faintly (`Style::invalid_repaired_fill`
/// against `Style::invalid_fill`). Clearing the shading is the host's decision and nothing
/// here retires a span on its own: a repaired span is drawn until the host stops sending it.
void set_invalid_spans(std::span<const InvalidSpan> spans);
std::span<const InvalidSpan> invalid_spans() const noexcept { return invalid_spans_; }
// --- phosphor density --------------------------------------------------
/// Turns this plot's **phosphor density** on or off, and states how it fades. Off -- the
/// default -- is the plot exactly as it was: no grid is allocated, no geometry is deposited
/// and no frame uploads anything.
///
/// On, the plot keeps an intensity grid behind each of its traces, in that trace's own colour
/// (see `DensityGrid` for the resolution, the bound on it and the per-cell meaning, and
/// `DensityConfig` for the per-trace colour and the memory the grids cost), and deposits the
/// acquisition on screen into it with `accumulate_density()`. While a density is on the
/// renderer draws **no solid trace** over it: the trace ink *is* the image, so the newest
/// acquisition is deposited like every other and fades with the rest -- a trace left drawn on
/// top of its own density could never fade, which is what a report of "the last acquisition
/// never fades" was. It is deliberately *not* a set of retained traces -- the library has the
/// trace geometry in hand, so a density costs one grid per trace whatever the trigger has done,
/// where retaining the acquisitions would cost a window copy each.
///
/// Like `set_invalid_spans()` it reserves **no layout space** and nothing on it can be picked:
/// it is drawn inside the axes it was sized to, and a pointer neither finds it nor is found by
/// it. A cell is a device pixel of that axes rect while the rect is under `kDensityCellCap` and
/// a block of them past it, which the renderer scales back up with a linear filter -- the image
/// reserves no space either way, so the bound changes its resolution and nothing about the
/// layout.
///
/// **Disabling drops what was accumulated at once**, not at the next acquisition: a caller who
/// has stopped paying for the display is not left holding a stale image of it, which is the
/// rule the app's own `set_persist_depth()` follows for the very same reason. Re-enabling
/// starts from an empty grid -- the image is sized to the screen, and a screen it has not been
/// sized against yet is a screen it has nothing to say about.
void set_density(const DensityConfig& config);
/// The configuration, as set.
const DensityConfig& density() const noexcept { return density_; }
/// The accumulated image **of a trace**, for a host that wants to draw it or read it back. There
/// is one per trace, in trace order, because each is drawn in that trace's own colour (see
/// `DensityConfig`); an out-of-range trace reads an empty (0x0) image. Empty until the first
/// `accumulate_density()` on a laid-out plot, and dropped by `set_density()` off.
const DensityGrid& density_grid(u32 trace) const noexcept {
return trace < density_grids_.size() ? density_grids_[trace] : empty_density_grid();
}
/// Deposits **the acquisition on screen** into each trace's grid: every visible trace of this
/// plot, the newest acquisition included, over the x window its own axis is showing, 1.0 into
/// every cell it covers -- **once per cell per sweep**, however many of that trace's own samples
/// crossed it, so a cell's value is how many traces have covered it since the last decay and not
/// how much the beam moved (see `DensityGrid`; the dwell question is
/// `Style::intensity_grading`'s). Called once per *acquisition*, which is the cadence the grids
/// count in: a caller that deposited every frame would add one sweep sixty times a second, and
/// the per-acquisition fade would have nothing to measure itself against. The renderer draws no
/// polyline for a trace whose density is on (see `set_density()`), so this deposit is the whole
/// of what the newest acquisition looks like -- and it fades with the rest.
///
/// The geometry is the frame's own. The visible x range is mapped onto sample indices through
/// the series' own x (`Series::neighbours()`), never the `dx()` grid, and never the whole
/// series -- exactly as `Renderer::draw_traces()` maps it -- and the gathered points are taken
/// to pixels through the transforms the plot's last `layout()` pinned. So this belongs *after*
/// a layout: a plot that has never been laid out has no axes rect to size the grid to and
/// deposits nothing.
///
/// The grid is sized to that axes rect, **the rect in device pixels up to `kDensityCellCap`
/// cells** (`DensityGrid`), and a grid of the wrong size is reallocated -- which drops what it
/// held, because the image is of the axes rect and a new rect is a new image. Under
/// `DensityDecay::PerAcquisition` the fade happens here, before the deposit, so the acquisition
/// just drawn is the brightest. A NaN sample is a gap: it deposits nothing and joins nothing,
/// the library-wide policy.
void accumulate_density();
/// Fades the grid by `dt_s` of **stream seconds** -- the clock the x axis reads, so a caller
/// advancing the display by its frame's own `dt` gets a fade that does not depend on the frame
/// rate. Only `DensityDecay::TimeConstant` fades here: `PerAcquisition`'s factor is applied by
/// `accumulate_density()`, because there is no acquisition in a frame that merely passed.
/// A non-positive `dt_s` and a non-positive `time_constant_s` are both no-ops, so a caller that
/// has not measured a frame yet cannot wipe the grid by accident.
void decay_density(f64 dt_s);
/// Empties the grid (and leaves the configuration alone). What a caller does when the data
/// under the image is replaced: the screen-relative image of another stream's sweeps says
/// nothing about this one.
void clear_density() noexcept;
/// The measurement for `id`. Re-evaluated only when a cursor moved, when the
/// measure was added, or when the series underneath it changed: a different
/// series, a longer one, or a sliding window whose sample count stayed the same
/// (`x0()` is the third key -- see MeasureSlot). Never once per frame. A
/// saturated stream therefore keeps reporting live values with a stationary
/// cursor pair. An unknown id, a null-series trace, an inverted cursor pair or
/// an empty series yields `valid == false`. const because recomputing fills a
/// cache: nothing a caller can observe changes.
const Measurement& measurement(u32 id) const;
/// The cursor pair `id` was created over. The renderer needs it to place the
/// shaded band the measurement readout belongs to; the statistics themselves
/// are in measurement().
MeasureSpan measure_span(u32 id) const noexcept;
/// Drops every cached measurement **and every cached region statistic**: both
/// caches key on the same series identity, so a mutation that neither of those
/// keys can see (data::ArraySeries::commit() with the same sample count) has to
/// invalidate both, and one entry point is what keeps a caller from clearing one
/// and leaving the other frozen.
void invalidate_measurements() noexcept;
// --- regions ----------------------------------------------------------
/// Replaces both bounds, ordered: `min`/`max` are stored, so `x0 > x1` cannot be
/// held whatever the caller passes. Deliberately *verbatim* otherwise -- the snap a
/// region asks for is a separate act (`snap_region()`) -- so a drag can move the
/// bounds under the pointer and put them on the sample grid once, on release,
/// instead of both ends sticking as the pointer moves. Also drops the region's
/// cached statistics.
///
/// A pair of bounds that is not both finite is *refused*: `std::min`/`std::max` would
/// propagate a NaN through both stored bounds and a finite partner would be lost with
/// it, so a rejected call changes nothing at all -- the interval stays as it was, and
/// so do the statistics cached for it.
void set_region(u32 id, f64 x0, f64 x1);
/// Pulls a snapping region's bounds onto the sample grid: onto the nearest sample
/// of the first visible trace on the region's x axis, the rule `set_cursor_x()`
/// already applies to a snapping cursor. A `Free` region, one with no visible
/// trace to snap to, and a series with a non-positive `dx()` are left exactly as
/// they are.
void snap_region(u32 id);
/// Removes a region. The ids above it shift down by one, as an element of a
/// vector does: a caller holding ids drops the ones past the removed region, and
/// region 0 is always the first region in `region()` order. Every cached
/// statistic of every remaining region is dropped with it.
void remove_region(u32 id);
/// Exact statistics over the samples `region`'s interval covers, on the series of
/// `trace`: `reduce`/`sum`/`sumsq` over exactly those samples, as a measurement
/// is, and never the pyramid's `*_buckets` forms -- those answer for whole buckets
/// and would silently report the wrong extremes and sums for an interval that is
/// not bucket-aligned, which is what an unaligned region is. `valid` is false for
/// an id that names nothing, a trace with no series, and an interval that covers
/// no sample at all.
///
/// Re-evaluated only when the region moved, when the trace's series changed, when
/// it grew, or when its retained window slid under a constant sample count
/// (`x0()` is the third key -- see RegionStatsSlot) -- never once per frame, so a
/// saturated stream with a stationary region goes on reporting live values. const
/// because recomputing fills a cache: nothing a caller can observe changes.
const RegionStats& region_stats(u32 region, u32 trace) const;
// --- hot handles ------------------------------------------------------
/// Publishes which handles the pointer is on, for the renderer to draw
/// highlighted. Set by `input::Controller`, which owns the only hit test (and
/// clears it back to "nothing is hot" when the pointer leaves); a plot nobody
/// drives keeps the default. See HotHandles for why this lives on the plot.
void set_hot_handles(const HotHandles& hot) noexcept { hot_ = hot; }
const HotHandles& hot_handles() const noexcept { return hot_; }
// --- layout -----------------------------------------------------------
/// Recomputes the layout, every axis' ticks and the grid, then returns the
/// layout. The label strips come from the text backend's metrics when one is
/// set (`measure()` of the widest y tick label, `height()` for the x strip) and
/// from PlotLayout's defaults otherwise.
///
/// `axes` is inset by the label strip of each occupied edge and, when that
/// direction has a titled axis, by the strip that title lives in (`y_title_width`
/// and `x_title_height` say how much that is): a title can then only be drawn
/// outside the data, whatever the title says.
///
/// The chrome is capped so `axes` keeps half the viewport on each dimension
/// whatever the labels and titles ask for: a plot with a title wider than a
/// narrow panel shrinks, it does not vanish -- an axes rect with no width or no
/// height selects no level, stages no segment and draws no trace, and says
/// nothing about why. The label strip is what makes the tick values readable, so
/// it is served first and the title strip takes what is left of the allowance.
///
/// Call this once per frame -- it rebuilds the ticks and the grid -- and pass
/// the result down.
const PlotLayout& layout();
/// The layout as of the last refresh. The const overload never recomputes, so
/// a lookup on a const Plot cannot move the axes out from under the caller.
const PlotLayout& layout() const noexcept { return layout_; }
/// A floor on the left gutter `layout()` reserves, in device pixels: the label
/// strip and the title column together are never narrower than this, so `axes`
/// starts no further left than `viewport().x + min_y_gutter()` **up to the cap
/// below** -- a floor wider than half the viewport is clamped exactly as the
/// measured strip is, so the guarantee is `min(min_y_gutter(), y_budget)`. 0.0
/// -- the default -- imposes nothing and the plot sizes its strip from its own
/// widest tick label, which is what a plot standing on its own has always done.
///
/// It exists for `Figure`, which is the only thing that can know the answer: a row
/// cannot see its siblings' tick labels, so the figure lays each row out once to
/// learn the gutter it needs, takes the maximum and sets it here on every row --
/// after which all the rows' `axes.x` and `axes.w` agree and a vertical feature
/// lands in the same pixel column in each. The floor is applied inside the same cap
/// as the measured strip, so a figure's gutter can never squeeze `axes` below the
/// half-viewport floor either. Negative values are clamped to 0.
void set_min_y_gutter(f32 device_px) noexcept {
min_y_gutter_ = device_px > 0.0f ? device_px : 0.0f;
}
f32 min_y_gutter() const noexcept { return min_y_gutter_; }
/// An empty band reserved between `axes`' right edge and the viewport's, in device
/// pixels. 0.0 -- the default -- is what the layout has always done: `axes` reaches
/// the viewport's right edge, which leaves the rightmost x tick label, *centred* on
/// that edge, half of it to be drawn in and nothing to draw it in -- and puts the
/// newest sample's ink on the frame. It is the mirror of the left gutter in purpose
/// and not in kind: nothing is drawn here, so there is nothing to measure and the
/// value is the caller's to choose. Reserved inside the same cap as the label and
/// title strips, so it can no more squeeze `axes` below the half-viewport floor than
/// they can. Negative values are clamped to 0.
void set_right_gutter(f32 device_px) noexcept {
right_gutter_ = device_px > 0.0f ? device_px : 0.0f;
}
f32 right_gutter() const noexcept { return right_gutter_; }
/// Grid segments for the primary axis pair (x axis 0, y axis 0), in data space.
const Grid& grid() const noexcept { return grid_; }
// --- legend -----------------------------------------------------------
/// The legend's configuration. A legend is on unless a caller turns it off and
/// costs nothing until a trace is named: with no named visible trace -- or no text
/// backend to measure a name with -- `layout()` produces an empty layout and
/// nothing is drawn.
const Legend& legend() const noexcept { return legend_; }
/// Replaces the configuration. The next `layout()` measures the panel with it, so
/// a caller can move the legend -- or switch it off -- between frames.
void set_legend(const Legend& legend) noexcept { legend_ = legend; }
// --- crosshair --------------------------------------------------------
/// Places a **vertical-only** crosshair at a data x: the column of `x`, a marker on
/// every visible trace at that x, and the readout panel beside it (spec §15).
///
/// This is the form for a caller that has a coordinate and not a pointer -- "show me
/// every signal at this instant" -- and for `Figure::set_crosshair()`, which applies one
/// x to every row: a figure's rows share x and not y, so a horizontal line across them
/// would be a claim about y that the figure does not make.
///
/// **Refused** -- nothing changes -- when `x` is not a number: a crosshair there has no
/// column to draw and no value to read, and refusing it leaves the previous position
/// rather than a crosshair no gesture could undo. `active == false` clears it exactly as
/// `clear_crosshair()` does.
void set_crosshair(f64 x, bool active);
/// Places a crosshair at both coordinates: the vertical line and the readout of the
/// first form, plus the horizontal line at `y`. This is the form `input::Controller`
/// uses, because a pointer over the axes has both coordinates.
///
/// Refused, changing nothing, when either coordinate is not a number (see the first
/// form).
void set_crosshair(f64 x, f64 y, bool active);
/// Hides the crosshair, leaving where it was: the same state a plot has before any
/// crosshair is placed, so the next `set_crosshair()` re-anchors it. `input::Controller`
/// calls this when the pointer leaves the axes.
void clear_crosshair() noexcept { crosshair_.active = false; }
/// The box-zoom frame the host is showing, in **viewport pixels**, or the zero rect for none.
///
/// Set by the host from `input::Controller::selection_preview()` while a modified left drag is
/// choosing a range, and taken off when it ends. It is the *host's* job and not the plot's because
/// the plot has no controller and learns nothing about pointer, button or modifier (see
/// `plot::input`): this is the receiving end of that boundary, exactly as `set_hot_handles()` is.
/// Drawn as a frame -- a slightly opaque fill under a light border -- over everything else inside
/// the axes, so the area being framed stays legible against the traces it covers.
void set_selection_rect(Rect rect) noexcept { selection_ = rect; }
/// Takes the frame off, leaving the same state as before any drag: the next one starts from nothing.
void clear_selection_rect() noexcept { selection_ = Rect{}; }
/// The frame as set -- what `layout()` measures into `PlotLayout::selection`, and what a host or a
/// test reads back without paying a layout.
Rect selection_rect() const noexcept { return selection_; }
/// Switches the readout panel off -- or back on -- without moving the crosshair: the lines and
/// the per-trace markers stay, the panel is not measured and no row is listed. A host that wants
/// the pointer's column marked but not a panel over its data sets this once; it is deliberately
/// **not** part of `set_crosshair()`, so placing a hover cannot silently change it.
void set_crosshair_readout(bool on) noexcept { crosshair_.show_readout = on; }
/// Writes or clears the two **axis badges** (see `Crosshair::axis_labels`): the y value in
/// the y label strip and the x value in the x label strip, at the heights and columns the
/// crosshair's lines meet the axes at. Takes effect on the next `layout()`. **Not** part of
/// `set_crosshair()`, for the same reason the readout flag is not.
void set_crosshair_axis_labels(bool on) noexcept { crosshair_.axis_labels = on; }
/// The axis-badge flag, as set.
bool crosshair_axis_labels() const noexcept { return crosshair_.axis_labels; }
/// Overrides this plot's crosshair stroke and dash, in logical pixels, without touching the
/// global style every other plot draws with (spec §17): each field left negative in `s`
/// defers to the matching `Style::crosshair_*` metric, so `CrosshairStyle{}` (the default)
/// is exactly the global style. Set it once, like a colour -- it is not part of
/// `set_crosshair()`, so placing a hover cannot silently change how the stroke looks. A
/// `dash_off` that resolves to `0` draws a solid line.
void set_crosshair_style(const CrosshairStyle& s) noexcept { crosshair_.style = s; }
/// The crosshair's style override, as set -- read back rather than the *resolved* stroke and
/// dash, which live on `CrosshairLayout` because they depend on `ui_scale`.
const CrosshairStyle& crosshair_style() const noexcept { return crosshair_.style; }
/// Chooses what a readout's numbers *are*: one of the series' own samples, or a value
/// computed between two of them. `CrosshairValue` argues the two cases and says where each
/// puts the marker; the default is `NearestSample`.
///
/// Deliberately **not** part of `set_crosshair()`, like the readout switch and the style
/// override: it is a statement about the data, so a pointer move must not be able to change
/// it. It takes effect on the next `layout()` and reaches `crosshair_value()` immediately.
void set_crosshair_value_mode(CrosshairValue mode) noexcept { crosshair_.value_mode = mode; }
/// The readout's value mode, as set -- see `set_crosshair_value_mode()`.
CrosshairValue crosshair_value_mode() const noexcept { return crosshair_.value_mode; }
/// 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 value this plot's readout would report for `trace` at a data `x`: the nearer
/// bracketing sample's y, or the blend of the two, according to
/// `crosshair_value_mode()` -- 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 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;
/// The whole **point** the readout reports, x as well as y: `crosshair_value()`'s number
/// together with the data x it belongs to. In `CrosshairValue::NearestSample` that x is the
/// quoted sample's own, which is generally not the `x` asked for; in `Interpolated` it is
/// `x` itself.
///
/// This is what places `CrosshairRow::marker`, and it is public because a host that draws
/// instruments of its own -- a cursor pinned to the value it reads, an annotation naming the
/// sample -- needs the same snap, and recomputing it would be a second rule that can drift
/// from this one. Same failure cases, same cost, same scratch buffer as `crosshair_value()`.
bool crosshair_point(u32 trace, f64 x, f64& out_x, f64& out_y) const;
private:
/// One measure plus its cache. The dirty flag is what keeps measurement() from
/// re-evaluating every frame; `last_series`, `last_size` and `last_x0` catch a
/// trace being pointed at a different series, a series that grew, and a ring
/// whose window slid under a constant sample count -- the last of which neither
/// of the other two sees, since a saturated stream keeps its size while every
/// sample it holds changes.
struct MeasureSlot {
u32 cursor_a = 0;
u32 cursor_b = 0;
u32 trace = 0;
mutable Measurement result;
mutable bool dirty = true;
mutable const data::Series* last_series = nullptr;
mutable u64 last_size = 0;
mutable f64 last_x0 = 0.0;
};
/// One (region, trace) pair's cached statistics, keyed exactly as a MeasureSlot is
/// and for its same three reasons: `dirty` for a region that moved, the series
/// pointer for a trace pointed at a different series, the sample count for one that
/// grew, and `x0()` for a saturated ring whose window slid under a constant size.
/// Keyed by the *pair*: one region's statistics over two traces are two answers.
struct RegionStatsSlot {
u32 region = 0;
u32 trace = 0;
mutable RegionStats result;
mutable bool dirty = true;
mutable const data::Series* last_series = nullptr;
mutable u64 last_size = 0;
mutable f64 last_x0 = 0.0;
};
/// Fit one axis to its traces' bounds, leaving auto_scale alone. False when the
/// axis cannot be fitted (no visible trace bound to it, or a log axis whose
/// traces hold no positive value), in which case the range is untouched.
bool fit_x_axis(u32 axis);
bool fit_y_axis(u32 axis);
/// Union of the bounds of the visible traces bound to one axis (`x_side`
/// selects x or y). False when there is no such trace; `out` then retains
/// whatever it held.
bool trace_bounds_union(bool x_side, u32 axis, Range& out) const;
/// The retained window of the visible **streaming** traces bound to one x axis:
/// `min` the oldest x they still hold, `max` the newest -- the live edge. False
/// when the axis has no such trace, i.e. when there is nothing to follow, and
/// `out` then retains whatever it held.
bool live_x_bounds(u32 axis, Range& out) const;
/// True when the follow path owns `axis`: it is `Live` *and* has a live edge.
/// What `autoscale_axes()` asks before fitting, and the only place "a live axis"
/// is decided.
bool follows_live(u32 axis) const;
/// Places a `Live` x axis' window at its live edge -- `[newest - offset - span,
/// newest - offset]`, with `span` the span the axis holds and `offset` clamped
/// against the retained window first, so the window can never be moved over
/// samples the ring has dropped. O(1) and allocation-free: `layout()` runs it on
/// every frame. False, and a no-op, when the axis is not `Live`, has no live edge,
/// or holds no usable span.
bool place_live_x_window(u32 axis);
/// Refit every axis that is still on auto scale. layout() runs this, so a
/// growing stream stays framed until a caller gesture turns auto scale off.
void autoscale_axes();
void refresh_ticks();
void build_grid();
void mark_measures_dirty() noexcept;
/// Marks every cached statistic of one region dirty -- every trace's -- which
/// `set_region()` and `remove_region()` are the only two things that need: they
/// are the only ways a region's own interval changes.
void mark_region_stats_dirty(u32 region) noexcept;
/// The series the sample grid comes from when an x coordinate is snapped: the
/// first visible trace with a series on `axis_x`, or null when the axis has none.
/// One rule for the cursor snap and the region snap both.
const data::Series* snap_series(u32 axis_x) const;
/// `x` pulled onto the nearest sample of `snap_series(axis_x)`, or `x` when there
/// is nothing to snap to.
f64 snapped_x(f64 x, u32 axis_x) const;
/// `c.x` pulled onto the nearest sample when the cursor asks to snap: the same one
/// rule, applied to the axis the cursor names.
f64 snapped_x(const Cursor& c) const;
/// `c.y` pulled onto the nearest tick of `c.axis_y`'s tick set when the ruler asks to
/// snap, else `c.y` -- `HRulerSnap`'s one rule and the only place a ruler's snap is
/// decided. The tick set is the last `layout()`'s (see `set_cursor_h_snap()`), and the
/// nearest of it wins with a tie going to the lower tick; an axis that has not been laid
/// out yet, one holding no ticks, and a `y` that is not a number all leave the value as
/// it is rather than inventing a tick to land on.
f64 snapped_y(const Cursor& c) const;
/// Measures every visible region into `layout_.regions`: the fill's clipped
/// interval, one grab tab per bound, and the label's line box. Also the place a
/// region's pixel geometry is derived -- the controller hit-tests what this
/// measured instead of mapping the bounds through the transform again.
void build_regions();
/// Measures every invalid span into `layout_.invalid_spans`: its interval clipped to the axes'
/// columns, with a one-device-pixel floor, and the state that picks its tint. Here and not in
/// the renderer for `build_regions()`' reason -- one x mapping, the frame's own.
void build_invalid_spans();
/// Deposits one trace's visible window into its own density grid: the same index window
/// `Renderer::draw_traces()` maps -- `neighbours()` for the ends, the 64-spp gate for the LOD
/// level -- gathered into `density_points_`, mapped to pixels through the transforms of the
/// plot's last layout, and left with `deposit_density_segment()`. A trace with no series, no
/// visible window or non-positive `dx()` deposits nothing.
void deposit_density_trace(const Trace& trace, DensityGrid& grid);
/// Deposits one already-mapped segment of a trace polyline into `grid`: for every grid column
/// the segment crosses, the rows it covers in that column, +1 each. The columns it also *clips*:
/// a column whose slice of the segment lies entirely outside the axes is skipped, so a trace off
/// the top of the plot deposits nothing rather than a band along the edge, and a segment with
/// both ends past an edge keeps the part that is inside. A degenerate segment (`x0 == x1`, or a
/// non-finite coordinate) fills the single column it is in.
void deposit_density_segment(f32 x0, f32 y0, f32 x1, f32 y1, DensityGrid& grid) noexcept;
/// Grows `density_grids_` -- one grid per trace -- to the last layout's axes rect in device
/// pixels, clearing a grid when the size moved (a *different* screen is a different image), and
/// returns whether any grid is usable. The one place the grids are allocated.
bool size_density_grids();
/// The image an out-of-range trace reads (see `density_grid()`): a shared empty grid, so the
/// accessor need not hand back a reference into nothing.
static const DensityGrid& empty_density_grid() noexcept;
/// Exact statistics of `region` over `series`, or an invalid result when the
/// interval covers no sample. The one place the region arithmetic lives.
static RegionStats evaluate_region_stats(const Region& region, const data::Series& series);
/// Widest y tick label plus the label padding on both sides: the left strip the
/// text backend asks for. Zero with no backend or no y axis.
f32 y_label_width() const;
/// One text line of width per titled y axis, plus the label padding on both
/// sides: the column the rotated y title is drawn in, one column per titled
/// axis. Zero with no backend or no titled y axis.
f32 y_title_strip_width() const;
/// Measures the legend into `layout_.legend`: which traces have an entry, how wide
/// the panel has to be, where it is anchored, and one row per entry it can fit.
/// Zeroes the layout and returns when there is nothing to measure with or nothing
/// to list -- an empty legend is what keeps the feature free for a caller who
/// never names a trace.
void build_legend();
/// Measures the crosshair into `layout_.crosshair`: the two lines' stroke boxes, one row
/// per visible trace with its nearest sample's value and marker, and the readout panel --
/// placed beside the crosshair, flipped where it would cross an edge and clamped, so
/// every rect it produces is inside `axes`. A plot with no visible trace still gets its
/// lines: the rows are the readout's, not the crosshair's.
void build_crosshair();
Rect viewport_{};
render::Style style_{};
const render::TextBackend* text_ = nullptr;
Legend legend_{};
/// The x axis' corner caption; see set_x_note(). Empty draws nothing.
std::string x_note_;
/// Which parts of the bottom x chrome are reserved at all; see set_x_strip_mode().
XStrip x_strip_ = XStrip::Both;
/// Which parts of the left y chrome are reserved at all; see set_y_strip_mode().
YStrip y_strip_ = YStrip::Both;
/// The floor a figure imposes on the left gutter; see set_min_y_gutter(). 0.0 means
/// none, which is what a plot standing on its own keeps.
f32 min_y_gutter_ = 0.0f;
/// The empty band kept to the right of `axes`; see set_right_gutter(). 0.0 means none,
/// which is what the layout did before the setter existed.
f32 right_gutter_ = 0.0f;
/// Which handles a pointer is on, published by the input layer (see HotHandles).
HotHandles hot_{};
std::vector<Axis> x_axes_;
std::vector<Axis> y_axes_;
std::vector<Trace> traces_;
std::vector<Cursor> cursors_;
std::vector<MeasureSlot> measures_;
std::vector<Annotation> annotations_;
std::vector<Region> regions_;
std::vector<InvalidSpan> invalid_spans_;
/// The phosphor density: its configuration, the grids it accumulates into, and the scratch the
/// one visible window being deposited is gathered into. **One grid per trace** -- the image is of
/// the axes rect but each trace's is tinted with that trace's own colour (see `DensityConfig`), so
/// the memory is `traces * columns * rows * 4` bytes and not one plot's worth -- and each is
/// bounded by `kDensityCellCap`. The vector tracks `traces_` and outlives the frames its images
/// were drawn in, which is the whole point of accumulating into an image rather than retaining
/// traces.
DensityConfig density_{};
std::vector<DensityGrid> density_grids_;
/// Scratch for `deposit_density_trace()`: interleaved (x, y) of one gathered window, re-used
/// across traces and frames so a steady-state accumulation allocates nothing. Bounded by the
/// window, not the series, as `Renderer::gather_` is.
std::vector<f32> density_points_;
/// The per-sweep dedupe's stamps, one per grid **row** and shared by every trace's deposit: the
/// key of the last (trace, column) to deposit at that row, so one acquisition's own samples
/// cannot count a cell twice (see `deposit_density_segment()`). One `u64` a row -- a few kB at
/// any window -- and not one per cell, which would double a grid's memory for a comparison.
std::vector<u64> density_stamp_;
/// The sweep's own number, bumped once per trace deposited: the high half of every stamp key, so
/// every trace of a plot, and every acquisition, deposits exactly once per cell it covers.
u32 density_trace_ = 0;
/// The (region, trace) statistics cache, filled on demand by `region_stats()`
/// and so mutable: a lookup on a const Plot may cache, exactly as `measures_`
/// does. Entries are created for the pairs a caller actually asks about, never
/// for the whole cross product.
mutable std::vector<RegionStatsSlot> region_stats_;
/// Scratch for build_legend(): the name width of every trace that has an entry, in
/// trace order and prefix-maxed in place, so the fit loop reads the width a given
/// number of kept entries needs in O(1) instead of re-measuring the names it is
/// about to drop. A member rather than a local, so a steady-state frame's legend
/// build allocates nothing.
std::vector<f32> legend_name_px_;
/// The crosshair's state (see Crosshair): at most one per plot, placed by
/// `set_crosshair()` and read back by `crosshair()` and `layout()`.
Crosshair crosshair_{};
/// The host's box-zoom frame (`set_selection_rect()`): viewport pixels, the zero rect when no
/// modified drag is choosing a range. State rather than a `PlotLayout` field alone because a host
/// sets it on every drag motion and reads it back; `layout()` only carries it through.
Rect selection_{};
/// The samples `crosshair_value()` reads a span through: `Series::gather()` appends
/// `(x, y)` pairs and never clears, so the lookup clears this and refills it with the at
/// most two samples a span covers. Sized to hold them from construction -- four floats is
/// two pairs -- so a lookup on the pointer path reallocates nothing even the first time,
/// and `mutable` for the same reason `measures_` is: a lookup is not a mutation a caller
/// can observe.
mutable std::vector<f32> crosshair_scratch_{0.0f, 0.0f, 0.0f, 0.0f};
/// Scratch for build_crosshair(): the content width (label + gutter + value) of every row,
/// prefix-maxed in place, for `legend_name_px_`'s reason -- the panel's width after a row
/// was dropped from the end is the width of a row still listed, read in O(1).
std::vector<f32> crosshair_content_px_;
PlotLayout layout_{};
Grid grid_;
};
} // namespace plot