wip(plot): region layout, hot handles and draggable handles (build restored)

This commit is contained in:
Martino Ferrari committed 2026-09-28 13:28:51 +02:00
1 parent 7d2349555d
commit e4d48cc6c3
9 files changed
+915 -37

No files matched your search

+126 -4
View File
@@ -3,6 +3,7 @@
#include <plot/core/rect.hpp>
#include <plot/core/types.hpp>
#include <plot/input/events.hpp>
#include <plot/plot/region.hpp>
namespace plot {
class Plot;
@@ -15,11 +16,21 @@ enum class DragMode {
None,
Pan, ///< translate every range (left or right drag)
BoxZoom, ///< a shift+left selection is open; the ranges change on release
MoveCursor, ///< ctrl+left drag is moving `hovered_cursor()`
MoveCursor, ///< a drag is moving the cursor marker it grabbed
MoveRegion, ///< a drag is translating a whole region, width preserved
ResizeRegionLeft, ///< a drag is moving the bound that was `x0` when it opened
ResizeRegionRight, ///< a drag is moving the bound that was `x1` when it opened
AxisZoomX, ///< middle drag inside the x label strip; zooms x only
AxisZoomY, ///< middle drag inside the y label strip; zooms y only
};
/// The grab zone of a region, named with the primitive it describes
/// (`plot/plot/region.hpp`, which this header imports it from): the input layer
/// reports it and the renderer colours by it -- through `Plot::hot_handles()`,
/// which carries the zone the pointer is on -- so both layers speak of one type
/// rather than of two that would have to be kept in step.
using plot::RegionGrab;
/// Turns host events into plot gestures, knowing nothing about Qt, GTK, GLFW or
/// ImGui -- Tasks 8-11 translate their native events into the value types above.
///
@@ -32,16 +43,20 @@ enum class DragMode {
/// Gesture table (grab-and-drag: the data follows the cursor):
/// | gesture | mode | effect |
/// |---|---|---|
/// | left drag | `Pan` | translate both ranges |
/// | right drag | `Pan` | same, for hosts that reserve left |
/// | shift + left drag | `BoxZoom` | both ranges from the selection rect |
/// | ctrl + left drag near a cursor | `MoveCursor` | move that cursor |
/// | left drag on a handle | `MoveCursor` / `MoveRegion` / `ResizeRegion*` | move the handle the press grabbed |
/// | left drag elsewhere | `Pan` | translate both ranges |
/// | right drag | `Pan` | same, for hosts that reserve left |
/// | middle drag on the x label strip | `AxisZoomX` | x-only zoom |
/// | middle drag on the y label strip | `AxisZoomY` | y-only zoom |
/// | wheel (shift: x only, ctrl: y only) | -- | zoom about the pointer |
/// | `+` / `-` | -- | zoom about the plot centre |
/// | `f` | -- | auto-fit every axis |
///
/// The right button pans *and* grabs: both are buttons a host drives drags with, so
/// neither one is singled out for handles. Middle keeps its own role on the label
/// strips, and a middle drag anywhere else still does nothing.
///
/// Both axis-zoom drags read the vertical `dy` in pointer pixels with the
/// wheel's sign convention (dragging down zooms in), anchored at the pointer's
/// data coordinate on the axis they zoom. The drag has its own per-pixel
@@ -51,6 +66,49 @@ enum class DragMode {
/// A drag that changes no range is harmless: a box-zoom selection under 4 px on
/// either side is a click, and leaves the ranges alone.
///
/// ## Direct manipulation (spec §5)
///
/// Cursors and regions are *grabbable*: a press on one enters a drag that moves it,
/// with no modifier, and a press anywhere else pans exactly as it always has -- so
/// the new gesture is a superset of the old one and every host that drives a drag
/// keeps panning. `Ctrl` is no longer needed for a cursor move and does not change
/// what a press on one does; it only means the same thing it always did (a press
/// away from a handle pans, with or without it).
///
/// One hit test runs for both, in this order, and that precedence is the whole rule:
///
/// 1. **Cursor markers first**, at the radius `hovered_cursor()` already uses, the
/// nearest one winning. A marker is drawn on top of everything and is hit-tested
/// the same way: the shaded band between two measurement cursors is grabbed by
/// dragging either of the cursors that bound it, because its edges *are* those
/// cursors -- no second path, no second tolerance.
/// 2. **Regions next**, resolved nearest-first against the geometry `Plot::layout()`
/// measured: a bound inside the grab radius beats the body (so the bound of a
/// wide region is grabbable even though the pointer is inside the fill), the
/// nearer of the two bounds wins, and with no bound in range the topmost region
/// whose interior holds the pointer is the body.
/// 3. **Nothing hot**, and the press pans.
///
/// A region narrower than a few logical pixels is a click rather than an edge grab:
/// its two bounds are under the pointer together, so an edge would always win and
/// the region could never be moved again once collapsed. Such a region is
/// grabbed by its body instead, widened to that same minimum, and it stays movable.
/// The *body* drag translates both bounds by one delta -- the width is preserved
/// exactly -- while a bound drag moves one end and leaves the other where it was,
/// so a drag that crosses one bound over the other swaps them rather than inverting
/// the region (`Plot::set_region()` orders on assignment).
///
/// A region that asks to snap is put on the sample grid once, *on release*, not on
/// every motion: a body dragged a fraction of a sample apart would otherwise snap
/// both ends back to where they started and never move at all.
///
/// The hovered and grabbed handles are published to the plot
/// (`Plot::set_hot_handles()`) after every pointer event, so the renderer draws them
/// highlighted without knowing what a pointer is; `hovered_cursor()`,
/// `grabbed_cursor()`, `hovered_region()`, `grabbed_region()` and `region_grab()` are
/// the same answer for a host that wants it directly -- a resize cursor, a tooltip,
/// a status line.
///
/// ## Zoom and pan on a live x axis (spec §12)
///
/// An x axis armed with `XFollow::Live` keeps following through every gesture
@@ -122,6 +180,22 @@ class Controller {
/// The cursor under the pointer, or `kInvalid` when the pointer is outside the
/// plot area or no cursor is within a few pixels.
u64 hovered_cursor() const;
/// The cursor a drag is moving, or `kInvalid` when no cursor drag is open. The two
/// are the same answer the plot publishes for the renderer (see HotHandles), for a
/// host that wants it directly.
u64 grabbed_cursor() const noexcept { return drag_cursor_; }
/// The region a drag is moving or resizing, or `kInvalid` when no region drag is
/// open.
u64 grabbed_region() const noexcept { return drag_region_; }
/// The region the pointer is on, or `kInvalid`. `region_grab()` says which zone.
u64 hovered_region() const;
/// Which zone of `region` the pointer is on: the bound nearest within the grab
/// radius (`Left`/`Right`), else `Body` when the pointer is inside the interval --
/// widened to the minimum a collapsed region keeps -- and `None` when it is on
/// neither, when the pointer is outside the plot area, or when no pointer has been
/// seen yet. What a host sets its resize cursor from; an id that names no region
/// reports `None`.
RegionGrab region_grab(u32 region) const;
private:
void begin_drag(const PointerEvent& e);
@@ -132,12 +206,46 @@ class Controller {
void box_zoom_update(const PointerEvent& e);
void apply_box_zoom();
void move_cursor(const PointerEvent& e);
void move_region(const PointerEvent& e);
void resize_region(const PointerEvent& e);
void axis_zoom(const PointerEvent& e);
/// The nearest visible cursor within the hit radius of (px, py), or kInvalid.
/// kInvalid when (px, py) is outside the plot axes.
u64 cursor_at(f32 px, f32 py) const;
/// The region and zone a pointer position is on. Both fields are "nothing" when
/// the pointer is outside `axes` or no region is hot; `zone` is `Body` for an
/// interior hit and never `None` for a region that is named.
struct RegionHot {
u64 region = kInvalid;
RegionGrab zone = RegionGrab::None;
};
/// One region's zone under a pointer position, with the distance to the bound the
/// zone names: 0 for a body, and meaningless for `None`. The one place the region
/// hit test lives -- `region_hot()` compares what it returns and `region_grab()`
/// reports it -- so the nearest-first rule is written once.
struct ZoneHit {
RegionGrab zone = RegionGrab::None;
f32 distance = 0.0f;
};
ZoneHit zone_hit(const RegionLayout& region, f32 px, f32 py) const;
/// Resolves that hit against the geometry `Plot::layout()` measured -- the same
/// rects the renderer paints, so what is drawn and what can be grabbed cannot
/// drift apart -- with the precedence the class comment states: nearest grabbable
/// bound first, topmost body after it.
RegionHot region_hot(f32 px, f32 py) const;
/// Opens the drag a press asks for: the cursor hit first, then the region zones.
/// False when the press is on no handle at all, i.e. when the caller should pan.
/// Records what the drag needs to keep hold of -- the bound an edge resize must
/// leave alone, the bounds and the grabbed data x a body move needs to preserve
/// the width -- so a changed pointer motion never accumulates error.
bool begin_handle_drag(const PointerEvent& e);
/// Publishes what the pointer is on to the plot, for the renderer to highlight
/// (see HotHandles). Called after every pointer event, so hover and grab are the
/// same answer the hit test gave.
void publish_hot();
/// The data x a zoom of `axis` holds fixed, for a gesture at (px, py). The
/// coordinate under the pointer while the pointer is inside `axes`, which is what
/// makes a zoom feel anchored to the data being pointed at; outside `axes` there
@@ -159,6 +267,20 @@ class Controller {
f32 origin_x_ = 0.0f;
f32 origin_y_ = 0.0f;
u64 drag_cursor_ = kInvalid;
/// The region drag's state. `drag_region_` is the region and `drag_zone_` the zone
/// the press resolved, kept for the whole gesture so the highlight a grab started
/// follows the grab rather than the pointer. The three numbers are what keeps a
/// motion from accumulating error: an edge resize holds `drag_fixed_x_`, the bound
/// it must leave alone, and a body move holds the bounds as they were at the press
/// (`drag_x0_`/`drag_x1_`) plus the data x the press grabbed (`drag_grab_x_`), so
/// each motion is a fresh function of the pointer rather than a step from the last
/// one.
u64 drag_region_ = kInvalid;
RegionGrab drag_zone_ = RegionGrab::None;
f64 drag_fixed_x_ = 0.0;
f64 drag_grab_x_ = 0.0;
f64 drag_x0_ = 0.0;
f64 drag_x1_ = 0.0;
bool has_pointer_ = false;
f32 pointer_x_ = 0.0f;
f32 pointer_y_ = 0.0f;
+1
View File
@@ -53,6 +53,7 @@
#include <plot/plot/legend.hpp>
#include <plot/plot/measure.hpp>
#include <plot/plot/plot.hpp>
#include <plot/plot/region.hpp>
#include <plot/plot/ticks.hpp>
#include <plot/plot/trace.hpp>
+138 -4
View File
@@ -10,6 +10,7 @@
#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>
@@ -59,6 +60,38 @@ struct PlotLayout {
/// 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;
};
/// 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.
@@ -124,6 +157,11 @@ class Plot {
/// 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.
u32 add_region(const Region& region);
// --- accessors --------------------------------------------------------
// An id that does not name an object yields a default-constructed one, so a
@@ -140,12 +178,16 @@ class Plot {
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;
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 --------------------------------------------
@@ -283,11 +325,60 @@ class Plot {
/// are in measurement().
MeasureSpan measure_span(u32 id) const noexcept;
/// Drops every cached measurement. Needed after a series is mutated in place
/// (data::ArraySeries::commit() with the same sample count), which a pointer
/// and size check cannot detect.
/// 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.
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
@@ -346,6 +437,21 @@ class Plot {
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.
@@ -377,8 +483,28 @@ class Plot {
void refresh_ticks();
void build_grid();
void mark_measures_dirty() noexcept;
/// `c.x` pulled onto the nearest sample when the cursor asks to snap.
/// 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;
/// 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;
@@ -397,6 +523,8 @@ class Plot {
render::Style style_{};
const render::TextBackend* text_ = nullptr;
Legend legend_{};
/// 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_;
@@ -404,6 +532,12 @@ class Plot {
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
+121
View File
@@ -0,0 +1,121 @@
#pragma once
#include <string>
#include <plot/core/color.hpp>
#include <plot/core/rect.hpp>
#include <plot/core/types.hpp>
namespace plot {
/// Where a region's bounds sit relative to the data's sample grid.
enum class RegionSnap {
Free, ///< the bounds stay exactly where they were put.
NearestSample, ///< both bounds are pulled onto the nearest sample of the x axis'
///< first visible trace, so the statistics cover whole samples.
};
/// A shaded area on an x interval, with exact statistics over the samples it
/// covers (`Plot::region_stats()`).
///
/// The bounds are **ordered**: `Plot::set_region()` stores their min and max, so
/// `x0 > x1` cannot be held however the bounds arrived -- which is what keeps a
/// drag that crosses one bound over the other a *swap* (the region stays draggable
/// and never turns inside out) rather than a degenerate interval. The interval is
/// inclusive of both ends: the samples a region covers are those whose x is at or
/// after `x0` and at or before `x1`.
///
/// A region names no x axis, unlike a cursor: it shades an interval of the plot's
/// data area as a whole -- the rect `PlotLayout::axes` and the grid are built from
/// the primary axis pair -- so its coordinates are on x axis 0.
struct Region {
f64 x0 = 0.0;
f64 x1 = 0.0;
RegionSnap snap = RegionSnap::NearestSample;
/// The shaded interior, and the line drawn at each bound. The defaults are the
/// cursor accent at a low alpha, which is what makes a region read as an area of
/// interest rather than a second set of bars over the data.
Color fill = Color::hex(0xFFB020, 0.15f);
Color edge = Color::hex(0xFFB020);
/// Optional caption, drawn on the first text line inside the fill.
std::string label;
bool visible = true;
};
/// Exact statistics over the samples a region's interval covers.
///
/// `count` is how many samples the interval holds, `mean`/`rms` are over those
/// samples, `min`/`max` their exact extremes, and `integral` their left-Riemann
/// area (`sum * dx`), the same convention `Measurement` documents -- the region and
/// the measurement read the same series the same way, so an overlay of the two
/// never shows two different "areas".
///
/// `valid` is false (and every field zero) for an id that names nothing, a trace
/// with no series, an inverted or non-finite interval, and an interval that covers
/// no sample at all.
struct RegionStats {
f64 x0 = 0.0;
f64 x1 = 0.0;
f64 width = 0.0;
f64 mean = 0.0;
f64 rms = 0.0;
f64 min = 0.0;
f64 max = 0.0;
f64 integral = 0.0;
u64 count = 0;
bool valid = false;
};
/// Which part of a region a pointer is on -- the zone `input::Controller` resolves
/// nearest-first and a host sets its resize cursor from.
///
/// It is declared here, with the primitive it describes, because two layers name
/// it: `input` reports it (and imports the name into `plot::input`, where the rest
/// of its vocabulary lives) and the renderer colours by it, since the plot
/// publishes which zone is hot (`Plot::hot_handles()`). Declaring it in either one
/// of those layers would make the other reach across a boundary to name a concept
/// neither of them owns.
enum class RegionGrab {
None, ///< the pointer is not on this region.
Left, ///< on the bound at `x0`, within the grab radius.
Right, ///< on the bound at `x1`.
Body, ///< inside the interval but not on a grabbable bound.
};
/// One region's geometry for a frame, in the same device-pixel space as
/// `PlotLayout` and filled alongside it by `Plot::layout()`. Two consumers read it
/// -- the renderer paints it and `input::Controller` hit-tests it -- and they read
/// the *same* numbers, so the rect a test asserts, the rect a frame paints and the
/// column a pointer is measured against cannot disagree.
///
/// It is declared here, beside the primitive whose geometry it is, for the same
/// reason `RegionGrab` is: two layers name it, and it belongs to the region rather
/// than to either of them.
struct RegionLayout {
/// Index into `Plot::region()`: the colours, the label text and the snap state
/// are the region's own. Only visible regions get an entry, so the layout is
/// exactly what a frame draws and what a pointer can hit.
u32 region = 0;
/// The shaded interval over the axes' full height, clipped to `axes`. A zero
/// width means the region does not intersect the visible x window (or the axes
/// have no width) and nothing of the fill is drawn.
Rect fill;
/// The grab tab at each bound: `Style::handle_width_px` wide in device pixels,
/// the axes' height tall, centred on the bound -- `handle_column()` of it is the
/// bound's screen x, which is what the controller's grab test measures against
/// and what the renderer draws the edge and its highlight in. Its height is zero
/// only when the axes have none.
Rect left_handle;
Rect right_handle;
/// The label's line box: its top-left is the pen the run is staged at and its
/// height one text line. Empty when the region has no label, nothing can measure
/// one, or the run plus its padding does not fit across `axes`.
Rect label;
};
/// The column a region's grab tab is centred on: the tab marks one bound, and its
/// centre line *is* that bound's screen x. The layout reports tabs rather than a
/// second x per bound, so this is the one derivation -- the controller measures a
/// pointer against it and the renderer draws the bound and its highlight in it.
constexpr f32 handle_column(const Rect& tab) noexcept { return tab.x + 0.5f * tab.w; }
} // namespace plot
+26 -2
View File
@@ -106,8 +106,17 @@ class Renderer {
/// physical size on a 1x panel and a 2x one.
///
/// `Plot::layout()` is computed exactly once, here, and handed to every helper:
/// it runs the auto scale, the strip fixed-point loop and the grid build, so
/// re-deriving it per helper would rebuild the ticks and the grid per helper.
/// it runs the auto scale, the strip fixed-point loop, the grid build and the
/// region geometry, so re-deriving it per helper would rebuild all of that per
/// helper.
///
/// The handles a pointer is on are drawn highlighted -- a cursor marker is widened
/// into a tab, a region bound is drawn in the style's cursor colour at the width
/// `PlotLayout` sized its handle at -- and what is hot comes from
/// `Plot::hot_handles()`, which the input layer publishes. The renderer therefore
/// never sees a pointer, a button or a gesture: it draws what the plot says is
/// being manipulated, and only when a host drives the plot with a controller is
/// anything ever hot.
///
/// A legend the plot has is part of the frame: the panel and its swatches are
/// line geometry staged with everything else, the names are glyphs on the text
@@ -115,6 +124,11 @@ class Renderer {
/// line batch, so the legend cannot be drawn under a trace, and it leaves no GL
/// state of its own -- the frame's blend and its neighbours' scissor already say
/// everything a fill and a hairline border need (see end_frame's residue list).
///
/// Regions are part of that same evidence: the fill is one instance of the line
/// pipeline (`fill_pixels()`, the primitive the legend panel uses), the bounds are
/// vertical strokes and the label is a staged glyph run, so a frame with regions
/// leaves behind exactly what a frame without them does.
void draw(Plot& plot);
/// Ends the frame, draining `glGetError()` in a loop so a stale error cannot be
@@ -316,6 +330,16 @@ class Renderer {
/// pins.
void draw_traces(const Plot& plot, const PlotLayout& layout);
void draw_cursors(const Plot& plot, const PlotLayout& layout);
/// The regions: the fill, one bound per edge, the optional label. Every rect comes
/// from `PlotLayout::regions` -- the same geometry the controller hit-tests -- so
/// a region is drawn where it can be grabbed, and neither side derives the other's
/// rects.
void draw_regions(const Plot& plot, const PlotLayout& layout);
/// One region bound: a vertical stroke of `width` down the tab's own column, in
/// `color`. The column is `handle_column()` of the tab -- the bound the layout
/// placed -- and a bound outside the axes' columns is skipped, exactly as a cursor
/// marker out of the visible window is.
void draw_region_edge(const Rect& tab, const Rect& axes, Color color, f32 width);
void draw_measures(const Plot& plot, const PlotLayout& layout);
void draw_annotations(const Plot& plot);
/// The legend: the panel and its swatches as line geometry, the names on the text
+7
View File
@@ -68,6 +68,13 @@ struct Style {
bool grid_minor_visible = true; ///< stage the minor grid at all.
f32 line_width_px = 1.5f; ///< logical width of the strokes framing the data
///< (axis lines, cursor markers).
/// Logical width of a *draggable handle* drawn highlighted: the tab a hovered or
/// grabbed cursor marker is widened to, and the tab a region's hot bound is drawn
/// as -- the latter sized by `Plot::layout()` with this same metric, so the two
/// kinds of handle are one width. Wider than `line_width_px` on purpose: the cue
/// for "this marker will follow your drag" has to be visible on a marker whose
/// own colour may already be the accent.
f32 handle_width_px = 5.0f;
f32 tick_min_px = 60.0f; ///< logical minimum spacing between major ticks, so
///< a 2x display gets the same tick *count*.