- 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.
1182 lines
73 KiB
C++
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
|