From e4d48cc6c3703074bb03079d9c68e074cf903ef0 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 28 Sep 2026 13:28:51 +0200 Subject: [PATCH] wip(plot): region layout, hot handles and draggable handles (build restored) --- include/plot/input/controller.hpp | 130 ++++++++++++++- include/plot/plot.hpp | 1 + include/plot/plot/plot.hpp | 142 ++++++++++++++++- include/plot/plot/region.hpp | 121 ++++++++++++++ include/plot/render/renderer.hpp | 28 +++- include/plot/render/style.hpp | 7 + src/input/controller.cpp | 213 +++++++++++++++++++++++-- src/plot/plot.cpp | 257 ++++++++++++++++++++++++++++-- src/render/renderer.cpp | 53 +++++- 9 files changed, 915 insertions(+), 37 deletions(-) create mode 100644 include/plot/plot/region.hpp diff --git a/include/plot/input/controller.hpp b/include/plot/input/controller.hpp index 76e2952..afe4d6d 100644 --- a/include/plot/input/controller.hpp +++ b/include/plot/input/controller.hpp @@ -3,6 +3,7 @@ #include #include #include +#include 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; diff --git a/include/plot/plot.hpp b/include/plot/plot.hpp index b076c70..4ef48c3 100644 --- a/include/plot/plot.hpp +++ b/include/plot/plot.hpp @@ -53,6 +53,7 @@ #include #include #include +#include #include #include diff --git a/include/plot/plot/plot.hpp b/include/plot/plot/plot.hpp index af76139..1d26bc5 100644 --- a/include/plot/plot/plot.hpp +++ b/include/plot/plot/plot.hpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -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 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(cursors_.size()); } u32 measure_count() const noexcept { return static_cast(measures_.size()); } u32 annotation_count() const noexcept { return static_cast(annotations_.size()); } + u32 region_count() const noexcept { return static_cast(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 x_axes_; std::vector y_axes_; @@ -404,6 +532,12 @@ class Plot { std::vector cursors_; std::vector measures_; std::vector annotations_; + std::vector 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 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 diff --git a/include/plot/plot/region.hpp b/include/plot/plot/region.hpp new file mode 100644 index 0000000..1440806 --- /dev/null +++ b/include/plot/plot/region.hpp @@ -0,0 +1,121 @@ +#pragma once +#include + +#include +#include +#include + +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 diff --git a/include/plot/render/renderer.hpp b/include/plot/render/renderer.hpp index de14f0d..16f40d8 100644 --- a/include/plot/render/renderer.hpp +++ b/include/plot/render/renderer.hpp @@ -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 diff --git a/include/plot/render/style.hpp b/include/plot/render/style.hpp index 0ca2d32..5a74f1e 100644 --- a/include/plot/render/style.hpp +++ b/include/plot/render/style.hpp @@ -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*. diff --git a/src/input/controller.cpp b/src/input/controller.cpp index 66d345f..ac4b519 100644 --- a/src/input/controller.cpp +++ b/src/input/controller.cpp @@ -33,6 +33,14 @@ constexpr f32 kCursorHitRadiusLogical = 5.0f; /// 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. 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 /// 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. plot_->set_viewport(px); 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) { @@ -107,13 +118,15 @@ void Controller::on_pointer(const PointerEvent& raw) { if (e.pressed && e.down) { begin_drag(e); - return; - } - if (e.released) { + } else if (e.released) { 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) { @@ -180,27 +193,112 @@ u64 Controller::hovered_cursor() const { 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 -------------------------------------------------------- void Controller::begin_drag(const PointerEvent& e) { origin_x_ = e.x; origin_y_ = e.y; 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)) { mode_ = DragMode::BoxZoom; selection_ = Rect{e.x, e.y, 0.0f, 0.0f}; 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) { const PlotLayout& lay = std::as_const(*plot_).layout(); if (lay.x_label_area.contains(e.x, e.y)) { @@ -212,14 +310,68 @@ void Controller::begin_drag(const PointerEvent& e) { } 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 } +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(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(e.x)); + return true; + case RegionGrab::None: + break; + } + drag_region_ = kInvalid; + drag_zone_ = RegionGrab::None; + return false; +} + void Controller::update_drag(const PointerEvent& e) { switch (mode_) { case DragMode::Pan: pan(e); break; case DragMode::BoxZoom: box_zoom_update(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::AxisZoomY: axis_zoom(e); break; case DragMode::None: break; @@ -235,9 +387,15 @@ void Controller::end_drag(const PointerEvent& e) { box_zoom_update(e); 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(drag_region_)); mode_ = DragMode::None; selection_ = Rect{}; drag_cursor_ = kInvalid; + drag_region_ = kInvalid; + drag_zone_ = RegionGrab::None; } // --- 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(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(e.x)); + const f64 delta = grabbed - drag_grab_x_; + plot_->set_region(static_cast(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(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(drag_region_), x, drag_fixed_x_); + } else { + plot_->set_region(static_cast(drag_region_), drag_fixed_x_, x); + } +} + void Controller::axis_zoom(const PointerEvent& e) { // 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 diff --git a/src/plot/plot.cpp b/src/plot/plot.cpp index 98e70a2..34af6ff 100644 --- a/src/plot/plot.cpp +++ b/src/plot/plot.cpp @@ -27,6 +27,44 @@ const Axis& default_axis() noexcept { 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(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 { static const Measurement measurement{}; return measurement; @@ -136,6 +174,21 @@ u32 Plot::add_annotation(const Annotation& annotation) { return static_cast(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(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 { 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; } +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::y_range(u32 axis) const { return y_axis(axis).range; } @@ -438,7 +496,112 @@ const Measurement& Plot::measurement(u32 id) const { 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(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(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 { // 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(); - // The legend is measured last, from the finished `axes`: it lives inside the data - // area and reserves nothing, so it can neither move the strips above nor depend on - // anything but the rect they settled on. + // The regions and the legend are measured last, from the finished `axes`: both + // live inside the data area and reserve nothing, so neither can move the strips + // 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(); return layout_; } @@ -563,15 +729,29 @@ void Plot::mark_measures_dirty() noexcept { for (MeasureSlot& slot : measures_) slot.dirty = true; } -f64 Plot::snapped_x(const Cursor& c) const { - if (c.snap != SnapMode::NearestSample) return c.x; - for (const Trace& t : traces_) { - 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(i) * s.dx(); +void Plot::mark_region_stats_dirty(u32 region) noexcept { + for (RegionStatsSlot& slot : region_stats_) { + if (slot.region == region) slot.dirty = true; } - 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(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 { @@ -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(tx.to_screen_x(region.x0)); + const f32 hi = static_cast(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 diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index b7038f5..234e9cf 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -348,6 +348,9 @@ void Renderer::draw(Plot& plot) { draw_tick_labels(plot, layout); draw_titles(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_measures(plot, layout); 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) { const Range y_range = plot.y_range(0); 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) { const Cursor& cursor = plot.cursor(i); if (!cursor.visible) continue; + const u64 id = static_cast(i); + const bool on = id == hot.cursor || id == hot.grabbed_cursor; const Transform transform = plot.x_transform(cursor.axis_x); const f32 px = static_cast(transform.to_screen_x(cursor.x)); // 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, 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()) { 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(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) { const Rect& axes = layout.axes; const Range y_range = plot.y_range(0);