wip(plot): region layout, hot handles and draggable handles (build restored)
This commit is contained in:
1 parent
7d2349555d
commit
e4d48cc6c3
9 files changed
+915
-37
No files matched your search
@@ -3,6 +3,7 @@
|
|||||||
#include <plot/core/rect.hpp>
|
#include <plot/core/rect.hpp>
|
||||||
#include <plot/core/types.hpp>
|
#include <plot/core/types.hpp>
|
||||||
#include <plot/input/events.hpp>
|
#include <plot/input/events.hpp>
|
||||||
|
#include <plot/plot/region.hpp>
|
||||||
|
|
||||||
namespace plot {
|
namespace plot {
|
||||||
class Plot;
|
class Plot;
|
||||||
@@ -15,11 +16,21 @@ enum class DragMode {
|
|||||||
None,
|
None,
|
||||||
Pan, ///< translate every range (left or right drag)
|
Pan, ///< translate every range (left or right drag)
|
||||||
BoxZoom, ///< a shift+left selection is open; the ranges change on release
|
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
|
AxisZoomX, ///< middle drag inside the x label strip; zooms x only
|
||||||
AxisZoomY, ///< middle drag inside the y label strip; zooms y 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
|
/// 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.
|
/// 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 table (grab-and-drag: the data follows the cursor):
|
||||||
/// | gesture | mode | effect |
|
/// | 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 |
|
/// | 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 x label strip | `AxisZoomX` | x-only zoom |
|
||||||
/// | middle drag on the y label strip | `AxisZoomY` | y-only zoom |
|
/// | middle drag on the y label strip | `AxisZoomY` | y-only zoom |
|
||||||
/// | wheel (shift: x only, ctrl: y only) | -- | zoom about the pointer |
|
/// | wheel (shift: x only, ctrl: y only) | -- | zoom about the pointer |
|
||||||
/// | `+` / `-` | -- | zoom about the plot centre |
|
/// | `+` / `-` | -- | zoom about the plot centre |
|
||||||
/// | `f` | -- | auto-fit every axis |
|
/// | `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
|
/// 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
|
/// 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
|
/// 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
|
/// 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.
|
/// 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)
|
/// ## Zoom and pan on a live x axis (spec §12)
|
||||||
///
|
///
|
||||||
/// An x axis armed with `XFollow::Live` keeps following through every gesture
|
/// 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
|
/// The cursor under the pointer, or `kInvalid` when the pointer is outside the
|
||||||
/// plot area or no cursor is within a few pixels.
|
/// plot area or no cursor is within a few pixels.
|
||||||
u64 hovered_cursor() const;
|
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:
|
private:
|
||||||
void begin_drag(const PointerEvent& e);
|
void begin_drag(const PointerEvent& e);
|
||||||
@@ -132,12 +206,46 @@ class Controller {
|
|||||||
void box_zoom_update(const PointerEvent& e);
|
void box_zoom_update(const PointerEvent& e);
|
||||||
void apply_box_zoom();
|
void apply_box_zoom();
|
||||||
void move_cursor(const PointerEvent& e);
|
void move_cursor(const PointerEvent& e);
|
||||||
|
void move_region(const PointerEvent& e);
|
||||||
|
void resize_region(const PointerEvent& e);
|
||||||
void axis_zoom(const PointerEvent& e);
|
void axis_zoom(const PointerEvent& e);
|
||||||
|
|
||||||
/// The nearest visible cursor within the hit radius of (px, py), or kInvalid.
|
/// The nearest visible cursor within the hit radius of (px, py), or kInvalid.
|
||||||
/// kInvalid when (px, py) is outside the plot axes.
|
/// kInvalid when (px, py) is outside the plot axes.
|
||||||
u64 cursor_at(f32 px, f32 py) const;
|
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
|
/// 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
|
/// 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
|
/// 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_x_ = 0.0f;
|
||||||
f32 origin_y_ = 0.0f;
|
f32 origin_y_ = 0.0f;
|
||||||
u64 drag_cursor_ = kInvalid;
|
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;
|
bool has_pointer_ = false;
|
||||||
f32 pointer_x_ = 0.0f;
|
f32 pointer_x_ = 0.0f;
|
||||||
f32 pointer_y_ = 0.0f;
|
f32 pointer_y_ = 0.0f;
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
#include <plot/plot/legend.hpp>
|
#include <plot/plot/legend.hpp>
|
||||||
#include <plot/plot/measure.hpp>
|
#include <plot/plot/measure.hpp>
|
||||||
#include <plot/plot/plot.hpp>
|
#include <plot/plot/plot.hpp>
|
||||||
|
#include <plot/plot/region.hpp>
|
||||||
#include <plot/plot/ticks.hpp>
|
#include <plot/plot/ticks.hpp>
|
||||||
#include <plot/plot/trace.hpp>
|
#include <plot/plot/trace.hpp>
|
||||||
|
|
||||||
|
|||||||
+138
-4
@@ -10,6 +10,7 @@
|
|||||||
#include <plot/plot/grid.hpp>
|
#include <plot/plot/grid.hpp>
|
||||||
#include <plot/plot/legend.hpp>
|
#include <plot/plot/legend.hpp>
|
||||||
#include <plot/plot/measure.hpp>
|
#include <plot/plot/measure.hpp>
|
||||||
|
#include <plot/plot/region.hpp>
|
||||||
#include <plot/plot/trace.hpp>
|
#include <plot/plot/trace.hpp>
|
||||||
#include <plot/render/style.hpp>
|
#include <plot/render/style.hpp>
|
||||||
#include <plot/render/text.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
|
/// 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).
|
/// numbers rather than two derivations of one rule (see LegendLayout).
|
||||||
LegendLayout legend;
|
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.
|
/// 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`.
|
/// 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_measure(u32 cursor_a, u32 cursor_b, u32 trace = 0);
|
||||||
u32 add_annotation(const Annotation& annotation);
|
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 --------------------------------------------------------
|
// --- accessors --------------------------------------------------------
|
||||||
// An id that does not name an object yields a default-constructed one, so a
|
// 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 cursor_count() const noexcept { return static_cast<u32>(cursors_.size()); }
|
||||||
u32 measure_count() const noexcept { return static_cast<u32>(measures_.size()); }
|
u32 measure_count() const noexcept { return static_cast<u32>(measures_.size()); }
|
||||||
u32 annotation_count() const noexcept { return static_cast<u32>(annotations_.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& x_axis(u32 axis) const;
|
||||||
const Axis& y_axis(u32 axis) const;
|
const Axis& y_axis(u32 axis) const;
|
||||||
const Trace& trace(u32 id) const;
|
const Trace& trace(u32 id) const;
|
||||||
const Cursor& cursor(u32 id) const;
|
const Cursor& cursor(u32 id) const;
|
||||||
const Annotation& annotation(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 --------------------------------------------
|
// --- ranges and transforms --------------------------------------------
|
||||||
|
|
||||||
@@ -283,11 +325,60 @@ class Plot {
|
|||||||
/// are in measurement().
|
/// are in measurement().
|
||||||
MeasureSpan measure_span(u32 id) const noexcept;
|
MeasureSpan measure_span(u32 id) const noexcept;
|
||||||
|
|
||||||
/// Drops every cached measurement. Needed after a series is mutated in place
|
/// Drops every cached measurement **and every cached region statistic**: both
|
||||||
/// (data::ArraySeries::commit() with the same sample count), which a pointer
|
/// caches key on the same series identity, so a mutation that neither of those
|
||||||
/// and size check cannot detect.
|
/// 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;
|
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 -----------------------------------------------------------
|
// --- layout -----------------------------------------------------------
|
||||||
|
|
||||||
/// Recomputes the layout, every axis' ticks and the grid, then returns the
|
/// Recomputes the layout, every axis' ticks and the grid, then returns the
|
||||||
@@ -346,6 +437,21 @@ class Plot {
|
|||||||
mutable f64 last_x0 = 0.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
|
/// 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
|
/// 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.
|
/// traces hold no positive value), in which case the range is untouched.
|
||||||
@@ -377,8 +483,28 @@ class Plot {
|
|||||||
void refresh_ticks();
|
void refresh_ticks();
|
||||||
void build_grid();
|
void build_grid();
|
||||||
void mark_measures_dirty() noexcept;
|
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;
|
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
|
/// 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.
|
/// text backend asks for. Zero with no backend or no y axis.
|
||||||
f32 y_label_width() const;
|
f32 y_label_width() const;
|
||||||
@@ -397,6 +523,8 @@ class Plot {
|
|||||||
render::Style style_{};
|
render::Style style_{};
|
||||||
const render::TextBackend* text_ = nullptr;
|
const render::TextBackend* text_ = nullptr;
|
||||||
Legend legend_{};
|
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> x_axes_;
|
||||||
std::vector<Axis> y_axes_;
|
std::vector<Axis> y_axes_;
|
||||||
@@ -404,6 +532,12 @@ class Plot {
|
|||||||
std::vector<Cursor> cursors_;
|
std::vector<Cursor> cursors_;
|
||||||
std::vector<MeasureSlot> measures_;
|
std::vector<MeasureSlot> measures_;
|
||||||
std::vector<Annotation> annotations_;
|
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
|
/// 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
|
/// 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
|
/// number of kept entries needs in O(1) instead of re-measuring the names it is
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -106,8 +106,17 @@ class Renderer {
|
|||||||
/// physical size on a 1x panel and a 2x one.
|
/// physical size on a 1x panel and a 2x one.
|
||||||
///
|
///
|
||||||
/// `Plot::layout()` is computed exactly once, here, and handed to every helper:
|
/// `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
|
/// it runs the auto scale, the strip fixed-point loop, the grid build and the
|
||||||
/// re-deriving it per helper would rebuild the ticks and the grid per helper.
|
/// 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
|
/// 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
|
/// 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
|
/// 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
|
/// 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).
|
/// 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);
|
void draw(Plot& plot);
|
||||||
|
|
||||||
/// Ends the frame, draining `glGetError()` in a loop so a stale error cannot be
|
/// Ends the frame, draining `glGetError()` in a loop so a stale error cannot be
|
||||||
@@ -316,6 +330,16 @@ class Renderer {
|
|||||||
/// pins.
|
/// pins.
|
||||||
void draw_traces(const Plot& plot, const PlotLayout& layout);
|
void draw_traces(const Plot& plot, const PlotLayout& layout);
|
||||||
void draw_cursors(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_measures(const Plot& plot, const PlotLayout& layout);
|
||||||
void draw_annotations(const Plot& plot);
|
void draw_annotations(const Plot& plot);
|
||||||
/// The legend: the panel and its swatches as line geometry, the names on the text
|
/// The legend: the panel and its swatches as line geometry, the names on the text
|
||||||
|
|||||||
@@ -68,6 +68,13 @@ struct Style {
|
|||||||
bool grid_minor_visible = true; ///< stage the minor grid at all.
|
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
|
f32 line_width_px = 1.5f; ///< logical width of the strokes framing the data
|
||||||
///< (axis lines, cursor markers).
|
///< (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
|
f32 tick_min_px = 60.0f; ///< logical minimum spacing between major ticks, so
|
||||||
///< a 2x display gets the same tick *count*.
|
///< a 2x display gets the same tick *count*.
|
||||||
|
|
||||||
|
|||||||
+199
-14
@@ -33,6 +33,14 @@ constexpr f32 kCursorHitRadiusLogical = 5.0f;
|
|||||||
/// A box-zoom selection narrower than this on either side is a click. Logical for
|
/// A box-zoom selection narrower than this on either side is a click. Logical for
|
||||||
/// the same reason: the gesture is the same hand movement on any display.
|
/// the same reason: the gesture is the same hand movement on any display.
|
||||||
constexpr f32 kMinBoxLogical = 4.0f;
|
constexpr f32 kMinBoxLogical = 4.0f;
|
||||||
|
/// The narrowest region whose bounds are grabbable, in **logical** pixels. Closer
|
||||||
|
/// together than this, the two bounds are one target the pointer cannot separate:
|
||||||
|
/// an edge would win the hit test every time and the region could never be moved
|
||||||
|
/// again, so a *narrower* region is grabbed by its body -- widened to this same
|
||||||
|
/// minimum around its bounds, which keeps a collapsed region something a pointer can
|
||||||
|
/// still take hold of rather than a line. Logical like the two tolerances above, and
|
||||||
|
/// converted through the same one call.
|
||||||
|
constexpr f32 kMinRegionEdgeGrabLogical = 3.0f;
|
||||||
|
|
||||||
/// An axis endpoint in transform space: log10 when the axis is logarithmic, the
|
/// An axis endpoint in transform space: log10 when the axis is logarithmic, the
|
||||||
/// value itself otherwise. Pan and zoom arithmetic runs in this space so a log
|
/// value itself otherwise. Pan and zoom arithmetic runs in this space so a log
|
||||||
@@ -96,6 +104,9 @@ void Controller::set_viewport(Rect px) {
|
|||||||
// transforms read.
|
// transforms read.
|
||||||
plot_->set_viewport(px);
|
plot_->set_viewport(px);
|
||||||
plot_->layout();
|
plot_->layout();
|
||||||
|
// The hover the layout is part of: a handle under the pointer may be somewhere
|
||||||
|
// else now, and the highlight the renderer draws reads what this publishes.
|
||||||
|
publish_hot();
|
||||||
}
|
}
|
||||||
|
|
||||||
void Controller::on_pointer(const PointerEvent& raw) {
|
void Controller::on_pointer(const PointerEvent& raw) {
|
||||||
@@ -107,13 +118,15 @@ void Controller::on_pointer(const PointerEvent& raw) {
|
|||||||
|
|
||||||
if (e.pressed && e.down) {
|
if (e.pressed && e.down) {
|
||||||
begin_drag(e);
|
begin_drag(e);
|
||||||
return;
|
} else if (e.released) {
|
||||||
}
|
|
||||||
if (e.released) {
|
|
||||||
end_drag(e);
|
end_drag(e);
|
||||||
return;
|
} else if (e.down && mode_ != DragMode::None) {
|
||||||
|
update_drag(e);
|
||||||
}
|
}
|
||||||
if (e.down && mode_ != DragMode::None) update_drag(e);
|
// Every exit above ends here: a hover that moved, a gesture that opened, and a
|
||||||
|
// gesture that closed all change what is under the pointer, and the publish is
|
||||||
|
// what makes the renderer's highlight (and a host's resize cursor) follow.
|
||||||
|
publish_hot();
|
||||||
}
|
}
|
||||||
|
|
||||||
void Controller::on_wheel(const WheelEvent& raw) {
|
void Controller::on_wheel(const WheelEvent& raw) {
|
||||||
@@ -180,27 +193,112 @@ u64 Controller::hovered_cursor() const {
|
|||||||
return cursor_at(pointer_x_, pointer_y_);
|
return cursor_at(pointer_x_, pointer_y_);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
u64 Controller::hovered_region() const {
|
||||||
|
if (!has_pointer_) return kInvalid;
|
||||||
|
return region_hot(pointer_x_, pointer_y_).region;
|
||||||
|
}
|
||||||
|
|
||||||
|
RegionGrab Controller::region_grab(u32 region) const {
|
||||||
|
if (!has_pointer_) return RegionGrab::None;
|
||||||
|
for (const RegionLayout& candidate : std::as_const(*plot_).layout().regions) {
|
||||||
|
if (candidate.region == region) return zone_hit(candidate, pointer_x_, pointer_y_).zone;
|
||||||
|
}
|
||||||
|
return RegionGrab::None; // an id that names no visible region
|
||||||
|
}
|
||||||
|
|
||||||
|
Controller::ZoneHit Controller::zone_hit(const RegionLayout& region, f32 px, f32 py) const {
|
||||||
|
ZoneHit hit;
|
||||||
|
// Off the data area nothing is hot, whether the pointer is over a label strip or
|
||||||
|
// off the plot entirely. A bound that has scrolled just outside `axes` is still
|
||||||
|
// within reach of a pointer inside it -- the slack the cursor hit test has too --
|
||||||
|
// but the strips are not a place a region can be grabbed from.
|
||||||
|
if (!std::as_const(*plot_).layout().axes.contains(px, py)) return hit;
|
||||||
|
|
||||||
|
const f32 left_x = handle_column(region.left_handle);
|
||||||
|
const f32 right_x = handle_column(region.right_handle);
|
||||||
|
const f32 min_w = plot_->style().device(kMinRegionEdgeGrabLogical);
|
||||||
|
// Grabbable bounds: the nearer of the two, when it is within the radius every
|
||||||
|
// other handle in this controller is hit-tested with -- one tolerance, so what
|
||||||
|
// `hovered_cursor()` calls a hit and what a region bound calls a hit agree.
|
||||||
|
if (right_x - left_x >= min_w) {
|
||||||
|
const f32 dl = std::fabs(px - left_x);
|
||||||
|
const f32 dr = std::fabs(px - right_x);
|
||||||
|
const f32 d = std::min(dl, dr);
|
||||||
|
if (d <= plot_->style().device(kCursorHitRadiusLogical)) {
|
||||||
|
hit.zone = dl <= dr ? RegionGrab::Left : RegionGrab::Right;
|
||||||
|
hit.distance = d;
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No bound in range: the body, widened by half the minimum on each side so a
|
||||||
|
// region too narrow to have grabbable bounds is still something a pointer can take
|
||||||
|
// hold of rather than a line it cannot hit.
|
||||||
|
const f32 grow = 0.5f * min_w;
|
||||||
|
if (px >= left_x - grow && px <= right_x + grow) hit.zone = RegionGrab::Body;
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
Controller::RegionHot Controller::region_hot(f32 px, f32 py) const {
|
||||||
|
RegionHot hot;
|
||||||
|
u64 body = kInvalid;
|
||||||
|
f32 nearest = 0.0f;
|
||||||
|
for (const RegionLayout& candidate : std::as_const(*plot_).layout().regions) {
|
||||||
|
const ZoneHit hit = zone_hit(candidate, px, py);
|
||||||
|
if (hit.zone == RegionGrab::None) continue;
|
||||||
|
if (hit.zone == RegionGrab::Body) {
|
||||||
|
// Remembered rather than chosen: a body only wins when no bound *anywhere* is
|
||||||
|
// in range, and the last region holding the pointer is the one on top --
|
||||||
|
// regions are drawn in order, so a later one covers an earlier one's interior.
|
||||||
|
body = candidate.region;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (hot.region == kInvalid || hit.distance < nearest) {
|
||||||
|
hot.region = candidate.region;
|
||||||
|
hot.zone = hit.zone;
|
||||||
|
nearest = hit.distance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hot.region != kInvalid) return hot;
|
||||||
|
return body == kInvalid ? RegionHot{} : RegionHot{body, RegionGrab::Body};
|
||||||
|
}
|
||||||
|
|
||||||
|
void Controller::publish_hot() {
|
||||||
|
HotHandles hot;
|
||||||
|
if (has_pointer_) {
|
||||||
|
hot.cursor = cursor_at(pointer_x_, pointer_y_);
|
||||||
|
if (drag_region_ != kInvalid) {
|
||||||
|
// An open region drag keeps its own zone hot for as long as it runs, so the
|
||||||
|
// highlight follows the grab rather than the pointer: a body dragged out from
|
||||||
|
// under the pointer still reads as the region being moved, and a bound keeps
|
||||||
|
// its emphasis after the pointer has left it.
|
||||||
|
hot.region = drag_region_;
|
||||||
|
hot.zone = drag_zone_;
|
||||||
|
} else {
|
||||||
|
const RegionHot region = region_hot(pointer_x_, pointer_y_);
|
||||||
|
hot.region = region.region;
|
||||||
|
hot.zone = region.zone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hot.grabbed_cursor = drag_cursor_;
|
||||||
|
plot_->set_hot_handles(hot);
|
||||||
|
}
|
||||||
|
|
||||||
// --- drag dispatch --------------------------------------------------------
|
// --- drag dispatch --------------------------------------------------------
|
||||||
|
|
||||||
void Controller::begin_drag(const PointerEvent& e) {
|
void Controller::begin_drag(const PointerEvent& e) {
|
||||||
origin_x_ = e.x;
|
origin_x_ = e.x;
|
||||||
origin_y_ = e.y;
|
origin_y_ = e.y;
|
||||||
drag_cursor_ = kInvalid;
|
drag_cursor_ = kInvalid;
|
||||||
|
drag_region_ = kInvalid;
|
||||||
|
drag_zone_ = RegionGrab::None;
|
||||||
|
|
||||||
|
// Shift first, and only on the left: it is a modifier gesture of its own, and a
|
||||||
|
// selection box is what it means even when it starts over a handle.
|
||||||
if (e.button == Button::Left && has_mod(e.mods, Mod::Shift)) {
|
if (e.button == Button::Left && has_mod(e.mods, Mod::Shift)) {
|
||||||
mode_ = DragMode::BoxZoom;
|
mode_ = DragMode::BoxZoom;
|
||||||
selection_ = Rect{e.x, e.y, 0.0f, 0.0f};
|
selection_ = Rect{e.x, e.y, 0.0f, 0.0f};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.button == Button::Left && has_mod(e.mods, Mod::Ctrl)) {
|
|
||||||
const u64 hit = cursor_at(e.x, e.y);
|
|
||||||
if (hit != kInvalid) {
|
|
||||||
mode_ = DragMode::MoveCursor;
|
|
||||||
drag_cursor_ = hit;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Ctrl far from any cursor falls through to pan, like an unmodified drag.
|
|
||||||
}
|
|
||||||
if (e.button == Button::Middle) {
|
if (e.button == Button::Middle) {
|
||||||
const PlotLayout& lay = std::as_const(*plot_).layout();
|
const PlotLayout& lay = std::as_const(*plot_).layout();
|
||||||
if (lay.x_label_area.contains(e.x, e.y)) {
|
if (lay.x_label_area.contains(e.x, e.y)) {
|
||||||
@@ -212,14 +310,68 @@ void Controller::begin_drag(const PointerEvent& e) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Everything else -- the left and the right button both, since a host that
|
||||||
|
// reserves the left one still drives its drags with the right -- grabs the handle
|
||||||
|
// under the press before it pans. Ctrl needs no special case: a press on a cursor
|
||||||
|
// does exactly this with or without it, and a ctrl press on nothing still pans.
|
||||||
|
if (begin_handle_drag(e)) return;
|
||||||
mode_ = DragMode::Pan; // left and right drag both pan
|
mode_ = DragMode::Pan; // left and right drag both pan
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool Controller::begin_handle_drag(const PointerEvent& e) {
|
||||||
|
// Cursor markers first: they are what a measure's band edges *are* (a band
|
||||||
|
// between two cursors is resized by dragging those cursors), and a marker is drawn
|
||||||
|
// over everything else, so it is the thing a press on it means.
|
||||||
|
const u64 cursor = cursor_at(e.x, e.y);
|
||||||
|
if (cursor != kInvalid) {
|
||||||
|
mode_ = DragMode::MoveCursor;
|
||||||
|
drag_cursor_ = cursor;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RegionHot hot = region_hot(e.x, e.y);
|
||||||
|
if (hot.region == kInvalid) return false;
|
||||||
|
drag_region_ = hot.region;
|
||||||
|
drag_zone_ = hot.zone;
|
||||||
|
const Region& region = plot_->region(static_cast<u32>(hot.region));
|
||||||
|
switch (hot.zone) {
|
||||||
|
case RegionGrab::Left:
|
||||||
|
// The other bound is held for the whole gesture: each motion is then the two
|
||||||
|
// numbers this drag is made of, not a step from the previous one, so a pointer
|
||||||
|
// that moves back and forth cannot accumulate a drift -- and when it crosses
|
||||||
|
// the held bound the store orders them, which swaps the interval instead of
|
||||||
|
// inverting it (see Plot::set_region).
|
||||||
|
mode_ = DragMode::ResizeRegionLeft;
|
||||||
|
drag_fixed_x_ = region.x1;
|
||||||
|
return true;
|
||||||
|
case RegionGrab::Right:
|
||||||
|
mode_ = DragMode::ResizeRegionRight;
|
||||||
|
drag_fixed_x_ = region.x0;
|
||||||
|
return true;
|
||||||
|
case RegionGrab::Body:
|
||||||
|
// A body drag preserves the width exactly by construction: both bounds move by
|
||||||
|
// the same delta from where they were when the gesture opened.
|
||||||
|
mode_ = DragMode::MoveRegion;
|
||||||
|
drag_x0_ = region.x0;
|
||||||
|
drag_x1_ = region.x1;
|
||||||
|
drag_grab_x_ = plot_->x_transform(0u).to_data_x(static_cast<f64>(e.x));
|
||||||
|
return true;
|
||||||
|
case RegionGrab::None:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
drag_region_ = kInvalid;
|
||||||
|
drag_zone_ = RegionGrab::None;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
void Controller::update_drag(const PointerEvent& e) {
|
void Controller::update_drag(const PointerEvent& e) {
|
||||||
switch (mode_) {
|
switch (mode_) {
|
||||||
case DragMode::Pan: pan(e); break;
|
case DragMode::Pan: pan(e); break;
|
||||||
case DragMode::BoxZoom: box_zoom_update(e); break;
|
case DragMode::BoxZoom: box_zoom_update(e); break;
|
||||||
case DragMode::MoveCursor: move_cursor(e); break;
|
case DragMode::MoveCursor: move_cursor(e); break;
|
||||||
|
case DragMode::MoveRegion: move_region(e); break;
|
||||||
|
case DragMode::ResizeRegionLeft:
|
||||||
|
case DragMode::ResizeRegionRight: resize_region(e); break;
|
||||||
case DragMode::AxisZoomX:
|
case DragMode::AxisZoomX:
|
||||||
case DragMode::AxisZoomY: axis_zoom(e); break;
|
case DragMode::AxisZoomY: axis_zoom(e); break;
|
||||||
case DragMode::None: break;
|
case DragMode::None: break;
|
||||||
@@ -235,9 +387,15 @@ void Controller::end_drag(const PointerEvent& e) {
|
|||||||
box_zoom_update(e);
|
box_zoom_update(e);
|
||||||
apply_box_zoom();
|
apply_box_zoom();
|
||||||
}
|
}
|
||||||
|
// A region's snap is applied exactly once, here, on the gesture's final bounds:
|
||||||
|
// the drag itself stores them verbatim, so they follow the pointer instead of
|
||||||
|
// sticking to the sample grid as it moves (see Plot::snap_region()).
|
||||||
|
if (drag_region_ != kInvalid) plot_->snap_region(static_cast<u32>(drag_region_));
|
||||||
mode_ = DragMode::None;
|
mode_ = DragMode::None;
|
||||||
selection_ = Rect{};
|
selection_ = Rect{};
|
||||||
drag_cursor_ = kInvalid;
|
drag_cursor_ = kInvalid;
|
||||||
|
drag_region_ = kInvalid;
|
||||||
|
drag_zone_ = RegionGrab::None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- gestures -------------------------------------------------------------
|
// --- gestures -------------------------------------------------------------
|
||||||
@@ -315,6 +473,33 @@ void Controller::move_cursor(const PointerEvent& e) {
|
|||||||
plot_->set_cursor_x(id, plot_->x_transform(cursor.axis_x).to_data_x(static_cast<f64>(e.x)));
|
plot_->set_cursor_x(id, plot_->x_transform(cursor.axis_x).to_data_x(static_cast<f64>(e.x)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Controller::move_region(const PointerEvent& e) {
|
||||||
|
if (drag_region_ == kInvalid) return;
|
||||||
|
// The pointer's own data coordinate, not the event's delta: the delta a host
|
||||||
|
// reports for one motion is not always the difference between the positions it
|
||||||
|
// reported, and a region's two bounds must move by exactly the same amount or the
|
||||||
|
// width changes. Both come from the bounds the gesture opened with plus this one
|
||||||
|
// offset, so nothing accumulates over a long drag.
|
||||||
|
const f64 grabbed = plot_->x_transform(0u).to_data_x(static_cast<f64>(e.x));
|
||||||
|
const f64 delta = grabbed - drag_grab_x_;
|
||||||
|
plot_->set_region(static_cast<u32>(drag_region_), drag_x0_ + delta, drag_x1_ + delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Controller::resize_region(const PointerEvent& e) {
|
||||||
|
if (drag_region_ == kInvalid) return;
|
||||||
|
const f64 x = plot_->x_transform(0u).to_data_x(static_cast<f64>(e.x));
|
||||||
|
// The bound the gesture did not grab stays where it was for the whole drag, and
|
||||||
|
// the grabbed one is wherever the pointer says. When the two cross, the *store*
|
||||||
|
// orders them (`set_region` keeps min and max), so the interval swaps rather than
|
||||||
|
// inverting -- and the pointer keeps hold of its bound: the next motion is still
|
||||||
|
// "the other end plus this pointer", with no state to get stuck in.
|
||||||
|
if (mode_ == DragMode::ResizeRegionLeft) {
|
||||||
|
plot_->set_region(static_cast<u32>(drag_region_), x, drag_fixed_x_);
|
||||||
|
} else {
|
||||||
|
plot_->set_region(static_cast<u32>(drag_region_), drag_fixed_x_, x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void Controller::axis_zoom(const PointerEvent& e) {
|
void Controller::axis_zoom(const PointerEvent& e) {
|
||||||
// A pointer drag reports pixels, not wheel notches, so it uses its own
|
// A pointer drag reports pixels, not wheel notches, so it uses its own
|
||||||
// sensitivity: 200 px is one decade. The gain is a physical distance the hand
|
// sensitivity: 200 px is one decade. The gain is a physical distance the hand
|
||||||
|
|||||||
+245
-12
@@ -27,6 +27,44 @@ const Axis& default_axis() noexcept {
|
|||||||
return axis;
|
return axis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The sample range a data interval covers: `[begin, end)` over the samples whose
|
||||||
|
/// x lies inside `[lo, hi]`, both ends included -- exactly the samples a region's
|
||||||
|
/// statistics are over. The nearest-sample snap seeds the walk and the two
|
||||||
|
/// corrections after it move by the one or two samples a rounded index can be off
|
||||||
|
/// by, so which side of a bound a sample falls on is decided by the same doubles
|
||||||
|
/// the series carries rather than by a division that is exact only on a grid that
|
||||||
|
/// is. Still O(1): the seed is never more than half a sample from the answer.
|
||||||
|
///
|
||||||
|
/// False when the interval holds no sample at all, which is a real case: an
|
||||||
|
/// interval between two adjacent samples on a sparse series, one entirely before or
|
||||||
|
/// after the window, a non-finite bound, and a series with no usable `dx()`.
|
||||||
|
bool covered_samples(const data::Series& series, f64 lo, f64 hi, u64& begin, u64& end) {
|
||||||
|
const u64 n = series.size();
|
||||||
|
const f64 x0 = series.x0();
|
||||||
|
const f64 dx = series.dx();
|
||||||
|
if (n == 0u || !(dx > 0.0) || !std::isfinite(lo) || !std::isfinite(hi) || !(lo <= hi)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const auto x_of = [x0, dx](u64 i) { return x0 + static_cast<f64>(i) * dx; };
|
||||||
|
|
||||||
|
// The first sample at or after `lo`: walk left while the one before is also
|
||||||
|
// inside, then right while this one is not yet.
|
||||||
|
u64 b = series.pyramid().snap(lo, x0, dx);
|
||||||
|
while (b > 0u && x_of(b - 1u) >= lo) --b;
|
||||||
|
while (b < n && x_of(b) < lo) ++b;
|
||||||
|
|
||||||
|
// One past the last sample at or before `hi`, from the same seed.
|
||||||
|
const u64 seed = series.pyramid().snap(hi, x0, dx);
|
||||||
|
u64 e = seed == n - 1u ? n : seed + 1u;
|
||||||
|
while (e < n && x_of(e) <= hi) ++e;
|
||||||
|
while (e > 0u && x_of(e - 1u) > hi) --e;
|
||||||
|
|
||||||
|
if (e <= b) return false;
|
||||||
|
begin = b;
|
||||||
|
end = e;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const Measurement& invalid_measurement() noexcept {
|
const Measurement& invalid_measurement() noexcept {
|
||||||
static const Measurement measurement{};
|
static const Measurement measurement{};
|
||||||
return measurement;
|
return measurement;
|
||||||
@@ -136,6 +174,21 @@ u32 Plot::add_annotation(const Annotation& annotation) {
|
|||||||
return static_cast<u32>(annotations_.size() - 1);
|
return static_cast<u32>(annotations_.size() - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
u32 Plot::add_region(const Region& region) {
|
||||||
|
// Ordered on the way in, so no caller -- and no drag -- can leave `x0 > x1`
|
||||||
|
// behind: the store is the one place this invariant is enforced.
|
||||||
|
Region stored = region;
|
||||||
|
stored.x0 = std::min(region.x0, region.x1);
|
||||||
|
stored.x1 = std::max(region.x0, region.x1);
|
||||||
|
regions_.push_back(stored);
|
||||||
|
const u32 id = static_cast<u32>(regions_.size() - 1);
|
||||||
|
// A region that asks to snap lands on the sample grid as it is added, exactly as
|
||||||
|
// `add_cursor()` places a snapping cursor: what a caller hands over is then on the
|
||||||
|
// samples of the traces the plot already holds.
|
||||||
|
snap_region(id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
const Axis& Plot::x_axis(u32 axis) const {
|
const Axis& Plot::x_axis(u32 axis) const {
|
||||||
return axis < x_axes_.size() ? x_axes_[axis] : default_axis();
|
return axis < x_axes_.size() ? x_axes_[axis] : default_axis();
|
||||||
}
|
}
|
||||||
@@ -159,6 +212,11 @@ const Annotation& Plot::annotation(u32 id) const {
|
|||||||
return id < annotations_.size() ? annotations_[id] : fallback;
|
return id < annotations_.size() ? annotations_[id] : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Region& Plot::region(u32 id) const {
|
||||||
|
static const Region fallback{};
|
||||||
|
return id < regions_.size() ? regions_[id] : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
Range Plot::x_range(u32 axis) const { return x_axis(axis).range; }
|
Range Plot::x_range(u32 axis) const { return x_axis(axis).range; }
|
||||||
|
|
||||||
Range Plot::y_range(u32 axis) const { return y_axis(axis).range; }
|
Range Plot::y_range(u32 axis) const { return y_axis(axis).range; }
|
||||||
@@ -438,7 +496,112 @@ const Measurement& Plot::measurement(u32 id) const {
|
|||||||
return slot.result;
|
return slot.result;
|
||||||
}
|
}
|
||||||
|
|
||||||
void Plot::invalidate_measurements() noexcept { mark_measures_dirty(); }
|
void Plot::invalidate_measurements() noexcept {
|
||||||
|
mark_measures_dirty();
|
||||||
|
// The region statistics key on the same series identity, so a mutation those keys
|
||||||
|
// cannot see has to invalidate them too (see the contract).
|
||||||
|
for (RegionStatsSlot& slot : region_stats_) slot.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Plot::set_region(u32 id, f64 x0, f64 x1) {
|
||||||
|
if (id >= regions_.size()) return;
|
||||||
|
// min/max, so an inverted pair is stored ordered rather than clamped: a drag that
|
||||||
|
// crosses one bound over the other *swaps* the interval, which is what keeps the
|
||||||
|
// bound the pointer holds movable instead of pinned at the other one.
|
||||||
|
regions_[id].x0 = std::min(x0, x1);
|
||||||
|
regions_[id].x1 = std::max(x0, x1);
|
||||||
|
mark_region_stats_dirty(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Plot::snap_region(u32 id) {
|
||||||
|
if (id >= regions_.size()) return;
|
||||||
|
const Region& region = regions_[id];
|
||||||
|
if (region.snap != RegionSnap::NearestSample) return;
|
||||||
|
// A region names no axis, so its coordinates are on the plot's primary x axis --
|
||||||
|
// the axis its fill and the grid are drawn against (see Region).
|
||||||
|
set_region(id, snapped_x(region.x0, 0u), snapped_x(region.x1, 0u));
|
||||||
|
}
|
||||||
|
|
||||||
|
void Plot::remove_region(u32 id) {
|
||||||
|
if (id >= regions_.size()) return;
|
||||||
|
regions_.erase(regions_.begin() + static_cast<std::ptrdiff_t>(id));
|
||||||
|
// The cache is keyed by region index, so the removed region's entries name nothing
|
||||||
|
// and every entry above the hole names a region one slot further down. The
|
||||||
|
// surviving regions' own intervals are untouched -- erase keeps their order -- so
|
||||||
|
// their cached statistics stay true and are only re-indexed, not dropped.
|
||||||
|
std::erase_if(region_stats_,
|
||||||
|
[id](const RegionStatsSlot& slot) { return slot.region == id; });
|
||||||
|
for (RegionStatsSlot& slot : region_stats_) {
|
||||||
|
if (slot.region > id) slot.region -= 1u;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const RegionStats& Plot::region_stats(u32 region, u32 trace) const {
|
||||||
|
static const RegionStats none{};
|
||||||
|
if (region >= regions_.size() || trace >= traces_.size()) return none;
|
||||||
|
|
||||||
|
RegionStatsSlot* slot = nullptr;
|
||||||
|
for (RegionStatsSlot& candidate : region_stats_) {
|
||||||
|
if (candidate.region == region && candidate.trace == trace) {
|
||||||
|
slot = &candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (slot == nullptr) {
|
||||||
|
// Filled on demand, one entry per pair a caller asks about: a plot with a dozen
|
||||||
|
// regions and a dozen traces never pays for the cross product it never queried.
|
||||||
|
region_stats_.push_back(RegionStatsSlot{});
|
||||||
|
slot = ®ion_stats_.back();
|
||||||
|
slot->region = region;
|
||||||
|
slot->trace = trace;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data::Series* series = traces_[trace].series;
|
||||||
|
const u64 samples = series != nullptr ? series->size() : 0;
|
||||||
|
// A saturated ring keeps `samples` constant while its window slides: `x0()` is
|
||||||
|
// what moves, and with it which samples the region's data interval covers. Caching
|
||||||
|
// on (pointer, count) alone would freeze the readout at the first saturated frame
|
||||||
|
// -- the same defect the measurement cache avoids with this same third key.
|
||||||
|
const f64 x0 = series != nullptr ? series->x0() : 0.0;
|
||||||
|
|
||||||
|
if (slot->dirty || series != slot->last_series || samples != slot->last_size ||
|
||||||
|
x0 != slot->last_x0) {
|
||||||
|
slot->result = series != nullptr ? evaluate_region_stats(regions_[region], *series)
|
||||||
|
: RegionStats{};
|
||||||
|
slot->last_series = series;
|
||||||
|
slot->last_size = samples;
|
||||||
|
slot->last_x0 = x0;
|
||||||
|
slot->dirty = false;
|
||||||
|
}
|
||||||
|
return slot->result;
|
||||||
|
}
|
||||||
|
|
||||||
|
RegionStats Plot::evaluate_region_stats(const Region& region, const data::Series& series) {
|
||||||
|
RegionStats stats;
|
||||||
|
u64 begin = 0;
|
||||||
|
u64 end = 0;
|
||||||
|
if (!covered_samples(series, region.x0, region.x1, begin, end)) return stats;
|
||||||
|
|
||||||
|
// Exact interval queries on Series, never the pyramid's *_buckets forms: this
|
||||||
|
// interval is not bucket-aligned in general (that is the whole point of a free
|
||||||
|
// region), and a bucket-grained answer would silently include samples outside it.
|
||||||
|
const Range extremes = series.reduce(begin, end);
|
||||||
|
const f64 total = series.sum(begin, end);
|
||||||
|
const f64 squared = series.sumsq(begin, end);
|
||||||
|
const f64 n = static_cast<f64>(end - begin);
|
||||||
|
|
||||||
|
stats.x0 = region.x0;
|
||||||
|
stats.x1 = region.x1;
|
||||||
|
stats.width = region.x1 - region.x0;
|
||||||
|
stats.min = extremes.min;
|
||||||
|
stats.max = extremes.max;
|
||||||
|
stats.mean = total / n;
|
||||||
|
stats.rms = std::sqrt(squared / n);
|
||||||
|
stats.integral = total * series.dx(); // left-Riemann, the measurement's convention
|
||||||
|
stats.count = end - begin;
|
||||||
|
stats.valid = true;
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
MeasureSpan Plot::measure_span(u32 id) const noexcept {
|
MeasureSpan Plot::measure_span(u32 id) const noexcept {
|
||||||
// An id that names nothing, or a measure built from cursor ids that were never
|
// An id that names nothing, or a measure built from cursor ids that were never
|
||||||
@@ -535,9 +698,12 @@ const PlotLayout& Plot::layout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
build_grid();
|
build_grid();
|
||||||
// The legend is measured last, from the finished `axes`: it lives inside the data
|
// The regions and the legend are measured last, from the finished `axes`: both
|
||||||
// area and reserves nothing, so it can neither move the strips above nor depend on
|
// live inside the data area and reserve nothing, so neither can move the strips
|
||||||
// anything but the rect they settled on.
|
// above nor depend on anything but the rect they settled on. The regions are
|
||||||
|
// measured from the *placed* live windows, which is what makes a region's data
|
||||||
|
// interval map to the pixel columns the frame will actually use.
|
||||||
|
build_regions();
|
||||||
build_legend();
|
build_legend();
|
||||||
return layout_;
|
return layout_;
|
||||||
}
|
}
|
||||||
@@ -563,15 +729,29 @@ void Plot::mark_measures_dirty() noexcept {
|
|||||||
for (MeasureSlot& slot : measures_) slot.dirty = true;
|
for (MeasureSlot& slot : measures_) slot.dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
f64 Plot::snapped_x(const Cursor& c) const {
|
void Plot::mark_region_stats_dirty(u32 region) noexcept {
|
||||||
if (c.snap != SnapMode::NearestSample) return c.x;
|
for (RegionStatsSlot& slot : region_stats_) {
|
||||||
for (const Trace& t : traces_) {
|
if (slot.region == region) slot.dirty = true;
|
||||||
if (t.axis_x != c.axis_x || !t.visible()) continue;
|
|
||||||
const data::Series& s = *t.series;
|
|
||||||
const u64 i = s.pyramid().snap(c.x, s.x0(), s.dx());
|
|
||||||
return s.x0() + static_cast<f64>(i) * s.dx();
|
|
||||||
}
|
}
|
||||||
return c.x;
|
}
|
||||||
|
|
||||||
|
const data::Series* Plot::snap_series(u32 axis_x) const {
|
||||||
|
for (const Trace& t : traces_) {
|
||||||
|
if (t.axis_x != axis_x || !t.visible()) continue; // null series: skip
|
||||||
|
return t.series;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
f64 Plot::snapped_x(f64 x, u32 axis_x) const {
|
||||||
|
const data::Series* s = snap_series(axis_x);
|
||||||
|
if (s == nullptr) return x;
|
||||||
|
const u64 i = s->pyramid().snap(x, s->x0(), s->dx());
|
||||||
|
return s->x0() + static_cast<f64>(i) * s->dx();
|
||||||
|
}
|
||||||
|
|
||||||
|
f64 Plot::snapped_x(const Cursor& c) const {
|
||||||
|
return c.snap == SnapMode::NearestSample ? snapped_x(c.x, c.axis_x) : c.x;
|
||||||
}
|
}
|
||||||
|
|
||||||
u32 Plot::titled_x_axis_count() const noexcept {
|
u32 Plot::titled_x_axis_count() const noexcept {
|
||||||
@@ -714,4 +894,57 @@ void Plot::build_legend() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Plot::build_regions() {
|
||||||
|
layout_.regions.clear();
|
||||||
|
if (regions_.empty()) return;
|
||||||
|
|
||||||
|
const Rect& axes = layout_.axes;
|
||||||
|
// A region's coordinates are on the primary x axis (see Region). The transform is
|
||||||
|
// taken *here*, after the live windows have been placed and the strips have
|
||||||
|
// settled, so the columns this produces are the ones the frame draws with -- and
|
||||||
|
// the controller hit-tests these very numbers rather than mapping the bounds
|
||||||
|
// through the transform a second time.
|
||||||
|
const Transform tx = x_transform(0u);
|
||||||
|
const f32 handle_w = style_.device(style_.handle_width_px);
|
||||||
|
const f32 pad = style_.device(style_.grid_label_pad);
|
||||||
|
const f32 line_h = text_ != nullptr ? text_->height() : 0.0f;
|
||||||
|
|
||||||
|
for (u32 i = 0; i < regions_.size(); ++i) {
|
||||||
|
const Region& region = regions_[i];
|
||||||
|
// An invisible region is not in the layout at all: the layout is exactly what a
|
||||||
|
// frame draws and what a pointer can hit, so the two cannot disagree about
|
||||||
|
// whether a region is there.
|
||||||
|
if (!region.visible) continue;
|
||||||
|
|
||||||
|
RegionLayout out;
|
||||||
|
out.region = i;
|
||||||
|
const f32 lo = static_cast<f32>(tx.to_screen_x(region.x0));
|
||||||
|
const f32 hi = static_cast<f32>(tx.to_screen_x(region.x1));
|
||||||
|
// One tab per bound, centred on it, so the bound's column is the tab's centre
|
||||||
|
// line (`handle_column()`) rather than a second number that could drift from it.
|
||||||
|
out.left_handle = Rect{lo - 0.5f * handle_w, axes.y, handle_w, axes.h};
|
||||||
|
out.right_handle = Rect{hi - 0.5f * handle_w, axes.y, handle_w, axes.h};
|
||||||
|
// The fill is the interval clipped to the axes' columns: a region that has
|
||||||
|
// scrolled out of the window has nothing to shade, and one wider than the window
|
||||||
|
// is not allowed to paint over the label strips.
|
||||||
|
const f32 first = std::clamp(lo, axes.x, axes.right());
|
||||||
|
const f32 last = std::clamp(hi, axes.x, axes.right());
|
||||||
|
out.fill = Rect{first, axes.y, last - first, axes.h};
|
||||||
|
|
||||||
|
// The label goes on the first text line inside the fill, clamped to `axes` -- the
|
||||||
|
// measurement readout's rule, and for its reason: a caption starting at a fill
|
||||||
|
// edge near the right of the plot would otherwise run over the axis and out of
|
||||||
|
// the panel. A label with no room across the axes is dropped rather than
|
||||||
|
// overhanging them, and a zero-width fill (an off-window region) draws none.
|
||||||
|
if (!region.label.empty() && line_h > 0.0f && out.fill.w > 0.0f) {
|
||||||
|
const f32 width = text_->measure(region.label.c_str());
|
||||||
|
if (width + 2.0f * pad <= axes.w) {
|
||||||
|
out.label = Rect{std::clamp(out.fill.x + pad, axes.x + pad, axes.right() - width - pad),
|
||||||
|
axes.y + pad, width, line_h};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
layout_.regions.push_back(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace plot
|
} // namespace plot
|
||||||
+52
-1
@@ -348,6 +348,9 @@ void Renderer::draw(Plot& plot) {
|
|||||||
draw_tick_labels(plot, layout);
|
draw_tick_labels(plot, layout);
|
||||||
draw_titles(plot, layout);
|
draw_titles(plot, layout);
|
||||||
draw_traces(plot, layout);
|
draw_traces(plot, layout);
|
||||||
|
// Regions over the traces and under the cursors: an area of interest the data is
|
||||||
|
// still legible through, with the markers that bound it on top of it.
|
||||||
|
draw_regions(plot, layout);
|
||||||
draw_cursors(plot, layout);
|
draw_cursors(plot, layout);
|
||||||
draw_measures(plot, layout);
|
draw_measures(plot, layout);
|
||||||
draw_annotations(plot);
|
draw_annotations(plot);
|
||||||
@@ -764,10 +767,17 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
|
|||||||
void Renderer::draw_cursors(const Plot& plot, const PlotLayout& layout) {
|
void Renderer::draw_cursors(const Plot& plot, const PlotLayout& layout) {
|
||||||
const Range y_range = plot.y_range(0);
|
const Range y_range = plot.y_range(0);
|
||||||
const bool log_y = plot.y_axis(0).log;
|
const bool log_y = plot.y_axis(0).log;
|
||||||
|
// The handles the input layer last found under the pointer: a marker hovered or
|
||||||
|
// being dragged is drawn in the style's cursor colour and as a *tab*, so the cue
|
||||||
|
// does not depend on the marker's own colour differing from the accent -- a cursor
|
||||||
|
// created in the accent colour is otherwise indistinguishable from an idle one.
|
||||||
|
const HotHandles hot = plot.hot_handles();
|
||||||
|
|
||||||
for (u32 i = 0; i < plot.cursor_count(); ++i) {
|
for (u32 i = 0; i < plot.cursor_count(); ++i) {
|
||||||
const Cursor& cursor = plot.cursor(i);
|
const Cursor& cursor = plot.cursor(i);
|
||||||
if (!cursor.visible) continue;
|
if (!cursor.visible) continue;
|
||||||
|
const u64 id = static_cast<u64>(i);
|
||||||
|
const bool on = id == hot.cursor || id == hot.grabbed_cursor;
|
||||||
const Transform transform = plot.x_transform(cursor.axis_x);
|
const Transform transform = plot.x_transform(cursor.axis_x);
|
||||||
const f32 px = static_cast<f32>(transform.to_screen_x(cursor.x));
|
const f32 px = static_cast<f32>(transform.to_screen_x(cursor.x));
|
||||||
// A cursor scrolled out of the visible x range is off the plot: drawing it
|
// A cursor scrolled out of the visible x range is off the plot: drawing it
|
||||||
@@ -776,13 +786,54 @@ void Renderer::draw_cursors(const Plot& plot, const PlotLayout& layout) {
|
|||||||
|
|
||||||
stage_segment(cursor.x, y_range.min, cursor.x, y_range.max, plot.x_axis(cursor.axis_x).log,
|
stage_segment(cursor.x, y_range.min, cursor.x, y_range.max, plot.x_axis(cursor.axis_x).log,
|
||||||
log_y);
|
log_y);
|
||||||
flush_lines(mapping(plot, cursor.axis_x, 0), cursor.color, style_.device(style_.line_width_px));
|
flush_lines(mapping(plot, cursor.axis_x, 0),
|
||||||
|
on ? style_.cursor : cursor.color,
|
||||||
|
style_.device(on ? style_.handle_width_px : style_.line_width_px));
|
||||||
if (!cursor.label.empty()) {
|
if (!cursor.label.empty()) {
|
||||||
stage_text({px + style_.device(3.0f), layout.axes.y + style_.device(2.0f)}, cursor.label.c_str());
|
stage_text({px + style_.device(3.0f), layout.axes.y + style_.device(2.0f)}, cursor.label.c_str());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Renderer::draw_regions(const Plot& plot, const PlotLayout& layout) {
|
||||||
|
const HotHandles hot = plot.hot_handles();
|
||||||
|
const f32 edge_w = style_.device(style_.line_width_px);
|
||||||
|
|
||||||
|
for (const RegionLayout& region : layout.regions) {
|
||||||
|
const Region& config = plot.region(region.region);
|
||||||
|
// The fill first, so both bounds are drawn on top of it: one instance of the
|
||||||
|
// line pipeline (see fill_pixels), which blends at the region's own alpha and
|
||||||
|
// therefore leaves the trace under it readable.
|
||||||
|
if (region.fill.w > 0.0f) fill_pixels(region.fill, config.fill);
|
||||||
|
|
||||||
|
// A bound is emphasized only when that bound is the hot zone: a body hover draws
|
||||||
|
// no edge highlight -- the fill is the body, and the host's own cursor says what
|
||||||
|
// a drag there would do (Controller::region_grab()).
|
||||||
|
const bool hot_region = static_cast<u64>(region.region) == hot.region;
|
||||||
|
const bool hot_left = hot_region && hot.zone == RegionGrab::Left;
|
||||||
|
const bool hot_right = hot_region && hot.zone == RegionGrab::Right;
|
||||||
|
// A hot bound is drawn in the cursor colour and at its tab's own width: the tab is
|
||||||
|
// the target the controller hit-tested, so the highlight a grab or a hover lights up
|
||||||
|
// is exactly the thing a drag was aimed at, and the grab column and the drawn column
|
||||||
|
// are one number rather than two that would have to be kept in step.
|
||||||
|
draw_region_edge(region.left_handle, layout.axes, hot_left ? style_.cursor : config.edge,
|
||||||
|
hot_left ? region.left_handle.w : edge_w);
|
||||||
|
draw_region_edge(region.right_handle, layout.axes, hot_right ? style_.cursor : config.edge,
|
||||||
|
hot_right ? region.right_handle.w : edge_w);
|
||||||
|
|
||||||
|
if (!config.label.empty() && region.label.w > 0.0f) {
|
||||||
|
stage_text({region.label.x, region.label.y}, config.label.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Renderer::draw_region_edge(const Rect& tab, const Rect& axes, Color color, f32 width) {
|
||||||
|
const f32 x = handle_column(tab);
|
||||||
|
if (x < axes.x || x >= axes.right()) return; // scrolled out: the strip is not ours
|
||||||
|
lines_.add_segment({x, tab.y}, {x, tab.bottom()});
|
||||||
|
flush_lines(pixel_mapping(), color, width);
|
||||||
|
}
|
||||||
|
|
||||||
void Renderer::draw_measures(const Plot& plot, const PlotLayout& layout) {
|
void Renderer::draw_measures(const Plot& plot, const PlotLayout& layout) {
|
||||||
const Rect& axes = layout.axes;
|
const Rect& axes = layout.axes;
|
||||||
const Range y_range = plot.y_range(0);
|
const Range y_range = plot.y_range(0);
|
||||||
|
|||||||
Reference in new issue
Block a user