Files
plotter/include/plot/plot/plot.hpp
T

819 lines
48 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#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/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 };
/// 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;
}
/// 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 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;
/// The crosshair's geometry, when one is active: the two lines' stroke boxes, the
/// readout panel and one row per visible trace with the interpolated value the pointer
/// is over. Placed here rather than derived by the renderer, exactly as the legend and
/// the regions are -- and, like them, entirely inside `axes`: the panel is flipped and
/// clamped against the rect it was measured for, so nothing of the readout can be drawn
/// over a label strip or into a figure's neighbouring row.
CrosshairLayout crosshair;
};
/// What a pointer is on, as of the last pointer event: the cursor marker under it, the
/// 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.
void set_viewport(Rect px) noexcept { 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_; }
/// 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);
/// 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.
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;
/// 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);
/// 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_; }
/// 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; }
/// 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; }
/// 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; }
/// The crosshair's state: where it is, which lines it draws and whether the readout is
/// on. Read-only -- `set_crosshair()` and `clear_crosshair()` are how it moves -- so the
/// coordinates it holds and the geometry `layout()` places from them cannot disagree.
const Crosshair& crosshair() const noexcept { return crosshair_; }
/// The **interpolated** value of `trace`'s series at a data `x`: `crosshair_span()`
/// between the two adjacent samples, then `crosshair_interpolate()` -- the same lookup
/// the readout's rows are built from, exposed for a host that wants the number for
/// itself (a status line, a tooltip of its own).
///
/// False, leaving `out_y` untouched, for an id that names no trace, a trace with no
/// series, and an `x` the series has no sample at -- the cases `CrosshairSpan::valid`
/// documents, plus the trace itself. Never a clamped or nearest-sample answer: see
/// `crosshair_span()` for why an out-of-window x is not a value.
///
/// O(1) and allocation-free once the plot has looked a value up before: the span is
/// index arithmetic and the samples are read through one `Series::gather()` of at most
/// two samples into a buffer the plot owns. It is called on every layout -- and therefore
/// on every pointer move -- once per visible trace, so nothing here may scan a series.
bool crosshair_value(u32 trace, f64 x, f64& out_y) const;
private:
/// One measure plus its cache. The dirty flag is what keeps measurement() from
/// re-evaluating every frame; `last_series`, `last_size` and `last_x0` catch a
/// 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();
/// 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 interpolated value and marker, and the readout panel --
/// placed beside the crosshair, flipped where it would cross an edge and clamped, so
/// every rect it produces is inside `axes`. A plot with no visible trace still gets its
/// lines: the rows are the readout's, not the crosshair's.
void build_crosshair();
Rect viewport_{};
render::Style style_{};
const render::TextBackend* text_ = nullptr;
Legend legend_{};
/// Which parts of the bottom x chrome are reserved at all; see set_x_strip_mode().
XStrip x_strip_ = XStrip::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;
/// 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_;
/// 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 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