fix(input): refuse non-finite region bounds, keep narrow regions movable, republish hot handles
This commit is contained in:
1 parent
4681d7015b
commit
7cc06f07f5
7 files changed
+263
-19
No files matched your search
@@ -83,16 +83,22 @@ using plot::RegionGrab;
|
||||
/// 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
|
||||
/// measured: a bound within 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.
|
||||
/// whose interior holds the pointer is the body. The radius is the one above, cut
|
||||
/// back when a region is too narrow to hold a band at each end *and* an interior:
|
||||
/// a region narrower than twice it would otherwise have every interior point
|
||||
/// within a bound's reach, and could be resized but never moved.
|
||||
/// 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.
|
||||
/// grabbed by its body instead, widened to that same minimum, and it stays movable --
|
||||
/// and between that minimum and twice the grab radius, where the two bounds would
|
||||
/// still cover every interior point, the edge bands are cut back until the middle is
|
||||
/// left, so a region in that band of widths can be moved as well as resized.
|
||||
/// 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
|
||||
@@ -103,7 +109,8 @@ using plot::RegionGrab;
|
||||
/// 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
|
||||
/// (`Plot::set_hot_handles()`) after every event that can move the geometry under the
|
||||
/// pointer -- a pointer event, a wheel notch, a key stroke -- 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,
|
||||
@@ -190,7 +197,8 @@ class Controller {
|
||||
/// 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 --
|
||||
/// radius (`Left`/`Right`) -- cut back for a narrow region, see `zone_hit()` -- 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
|
||||
@@ -229,6 +237,10 @@ class Controller {
|
||||
RegionGrab zone = RegionGrab::None;
|
||||
f32 distance = 0.0f;
|
||||
};
|
||||
/// The band a bound answers to is the controller's grab radius, cut back so that a
|
||||
/// narrow region keeps an interior a body drag can reach (see the class comment):
|
||||
/// `min(radius, (width - minimum interior) / 2)`, and no band at all when the two
|
||||
/// bounds are closer together than the minimum width that separates them.
|
||||
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
|
||||
@@ -242,8 +254,10 @@ class Controller {
|
||||
/// 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.
|
||||
/// (see HotHandles). Called after every event that can move the geometry under the
|
||||
/// pointer -- a pointer event, a wheel notch, a key stroke -- so hover and grab are
|
||||
/// always the answer the hit test gives for the geometry of this frame rather than
|
||||
/// the geometry of the last time the pointer moved.
|
||||
void publish_hot();
|
||||
|
||||
/// The data x a zoom of `axis` holds fixed, for a gesture at (px, py). The
|
||||
|
||||
@@ -161,6 +161,12 @@ class Plot {
|
||||
/// onto the sample grid as it is added -- exactly as `add_cursor()` places a
|
||||
/// snapping cursor -- so a region handed to a plot that already holds samples is on
|
||||
/// the grid from its first frame.
|
||||
///
|
||||
/// A region whose bounds are not both finite is *refused*: nothing is added, and the
|
||||
/// returned id names no region (`region()` reads a default for it and `region_count()`
|
||||
/// does not count it). Storing one would be worse than useless, because a `Free` region
|
||||
/// holding a bound that is not a number could never be dragged back -- every motion
|
||||
/// re-derives both bounds from the stored ones.
|
||||
u32 add_region(const Region& region);
|
||||
|
||||
// --- accessors --------------------------------------------------------
|
||||
@@ -340,6 +346,11 @@ class Plot {
|
||||
/// bounds under the pointer and put them on the sample grid once, on release,
|
||||
/// instead of both ends sticking as the pointer moves. Also drops the region's
|
||||
/// cached statistics.
|
||||
///
|
||||
/// A pair of bounds that is not both finite is *refused*: `std::min`/`std::max` would
|
||||
/// propagate a NaN through both stored bounds and a finite partner would be lost with
|
||||
/// it, so a rejected call changes nothing at all -- the interval stays as it was, and
|
||||
/// so do the statistics cached for it.
|
||||
void set_region(u32 id, f64 x0, f64 x1);
|
||||
|
||||
/// Pulls a snapping region's bounds onto the sample grid: onto the nearest sample
|
||||
|
||||
@@ -20,9 +20,11 @@ enum class RegionSnap {
|
||||
/// 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`.
|
||||
/// and never turns inside out) rather than a degenerate interval. A pair that is not
|
||||
/// both finite is not a pair of bounds and is refused rather than stored (see
|
||||
/// `Plot::set_region()`); the bounds below are finite once the region is in a plot.
|
||||
/// 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
|
||||
|
||||
@@ -33,6 +33,15 @@ 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 *interior* a region's body keeps, in **logical** pixels: the edge bands are
|
||||
/// cut back until this much of the middle is left, so a region too narrow for the full grab
|
||||
/// radius on each side is still something a pointer can take hold of and *move*. Without it
|
||||
/// every interior point of a region narrower than twice the radius (10 logical px) lies
|
||||
/// within a bound's reach, and the bound branch would always win -- such a region could be
|
||||
/// resized but never moved. Half of it either side of the midpoint, which is what makes the
|
||||
/// middle of even the narrowest region with grabbable bounds (3 logical px) reachable.
|
||||
constexpr f32 kMinRegionBodyLogical = 2.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
|
||||
@@ -159,12 +168,23 @@ void Controller::on_wheel(const WheelEvent& raw) {
|
||||
zoom_y_axis(i, plot_->y_transform(i).to_data_y(static_cast<f64>(e.y)), factor);
|
||||
}
|
||||
}
|
||||
// The ranges moved under a pointer that did not, so the geometry under it changed: refresh
|
||||
// the layout -- the region rects the hit test reads were measured for the range this wheel
|
||||
// has just replaced -- and republish what is hot. A frame drawn right after a scroll then
|
||||
// emphasizes the handle the pointer is over *now*, rather than the one it was over before,
|
||||
// without waiting for the mouse to move again.
|
||||
plot_->layout();
|
||||
publish_hot();
|
||||
}
|
||||
|
||||
void Controller::on_key(const KeyEvent& e) {
|
||||
if (!e.down) return; // every action below is press-only
|
||||
|
||||
if (e.key == kKeyZoomIn || e.key == kKeyZoomOut) {
|
||||
const bool zooms = e.key == kKeyZoomIn || e.key == kKeyZoomOut;
|
||||
const bool fits = e.key == kKeyFit || e.key == kKeyFitUpper;
|
||||
if (!zooms && !fits) return; // a key this controller has no gesture for
|
||||
|
||||
if (zooms) {
|
||||
const f64 notches = e.key == kKeyZoomIn ? kKeyZoomNotches : -kKeyZoomNotches;
|
||||
const f64 factor = zoom_factor(notches);
|
||||
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
|
||||
@@ -179,13 +199,16 @@ void Controller::on_key(const KeyEvent& e) {
|
||||
const f64 hi = to_space(a.range.max, a.log);
|
||||
zoom_y_axis(i, from_space(0.5 * (lo + hi), a.log), factor);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key == kKeyFit || e.key == kKeyFitUpper) {
|
||||
} else {
|
||||
plot_->auto_fit_x();
|
||||
for (u32 i = 0; i < plot_->y_axis_count(); ++i) plot_->auto_fit_y(i);
|
||||
}
|
||||
// A key stroke that moved a range moved the geometry under a stationary pointer, exactly
|
||||
// as a wheel notch does: refresh the layout and republish what is hot, so the highlight a
|
||||
// frame draws is the handle the pointer is over now (see on_wheel). A key this controller
|
||||
// has no gesture for changes nothing and returns above.
|
||||
plot_->layout();
|
||||
publish_hot();
|
||||
}
|
||||
|
||||
u64 Controller::hovered_cursor() const {
|
||||
@@ -217,14 +240,23 @@ Controller::ZoneHit Controller::zone_hit(const RegionLayout& region, f32 px, f32
|
||||
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.
|
||||
// Grabbable bounds: the bounds are told apart only while the region is wide enough to
|
||||
// hold a band at each end and an interior between them -- below that the body is the
|
||||
// only handle it has, which is the collapsed-region rule above -- and each band is cut
|
||||
// back so that interior always exists. The radius every other handle in this controller
|
||||
// is hit-tested with is kept wherever a region can afford it: the band is that radius
|
||||
// while twice it plus the minimum interior still fits, and half the remainder after
|
||||
// that, so the middle of a 6 px region is a body rather than inside both bounds. One
|
||||
// tolerance, so what `hovered_cursor()` calls a hit and what a region bound calls a hit
|
||||
// agree wherever there is room for both.
|
||||
if (right_x - left_x >= min_w) {
|
||||
const f32 radius = plot_->style().device(kCursorHitRadiusLogical);
|
||||
const f32 band = std::min(radius, 0.5f * (right_x - left_x -
|
||||
plot_->style().device(kMinRegionBodyLogical)));
|
||||
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)) {
|
||||
if (d <= band) {
|
||||
hit.zone = dl <= dr ? RegionGrab::Left : RegionGrab::Right;
|
||||
hit.distance = d;
|
||||
return hit;
|
||||
|
||||
@@ -65,6 +65,14 @@ bool covered_samples(const data::Series& series, f64 lo, f64 hi, u64& begin, u64
|
||||
return true;
|
||||
}
|
||||
|
||||
/// True when a pair of bounds can be a region's bounds: both of them finite. The one place
|
||||
/// this is decided, so `add_region()` and `set_region()` cannot disagree about what they
|
||||
/// will hold -- and so no NaN ever reaches the `std::min`/`std::max` below, which propagate
|
||||
/// it into *both* stored bounds and would leave a region no drag could recover from.
|
||||
bool finite_bounds(f64 x0, f64 x1) noexcept {
|
||||
return std::isfinite(x0) && std::isfinite(x1);
|
||||
}
|
||||
|
||||
const Measurement& invalid_measurement() noexcept {
|
||||
static const Measurement measurement{};
|
||||
return measurement;
|
||||
@@ -175,6 +183,12 @@ u32 Plot::add_annotation(const Annotation& annotation) {
|
||||
}
|
||||
|
||||
u32 Plot::add_region(const Region& region) {
|
||||
// A region whose bounds are not numbers is not an interval this plot can hold, and a
|
||||
// `Free` one stored that way could never be dragged back (every motion re-derives both
|
||||
// bounds from the stored ones), so it is refused rather than stored: nothing is added,
|
||||
// and the id handed back names no region -- `region()` reads a default for it and
|
||||
// `region_count()` does not count it.
|
||||
if (!finite_bounds(region.x0, region.x1)) return static_cast<u32>(kInvalid);
|
||||
// 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;
|
||||
@@ -505,6 +519,12 @@ void Plot::invalidate_measurements() noexcept {
|
||||
|
||||
void Plot::set_region(u32 id, f64 x0, f64 x1) {
|
||||
if (id >= regions_.size()) return;
|
||||
// A bound that is not a number is not a bound, and `std::min`/`std::max` would carry it
|
||||
// through the whole pair: accepting one would replace the finite partner as well, so the
|
||||
// region could never be dragged back. Refused outright -- as the other bound setters in
|
||||
// this file refuse one -- which leaves the interval it already held, and with it the
|
||||
// statistics cached for that interval.
|
||||
if (!finite_bounds(x0, x1)) 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.
|
||||
|
||||
@@ -1204,3 +1204,105 @@ PLOT_TEST(region_snap_is_applied_once_on_release) {
|
||||
PLOT_CHECK_NEAR(f.plot.region(rid).x1, 22.0, 1e-9);
|
||||
PLOT_CHECK(f.plot.region(rid).x1 > f.plot.region(rid).x0); // the interval survived both ends moving
|
||||
}
|
||||
|
||||
/// A region the brief promises a pointer can still *move*: one whose bounds sit closer
|
||||
/// together than the fixed grab radius allows on both sides. With that radius kept on each
|
||||
/// bound, every point of a region narrower than twice it (10 logical px) lies within a
|
||||
/// bound's reach, so the edge branch always wins and the interior is unreachable -- such a
|
||||
/// region could be resized but never moved, and the collapsed-region rescue only engages
|
||||
/// below 3 px. The bands are therefore cut back until the middle is left, while a region
|
||||
/// wide enough for two full bands and an interior keeps the radius it always had.
|
||||
PLOT_TEST(narrow_region_stays_movable_by_its_body) {
|
||||
Fixture f;
|
||||
const f32 radius = f.plot.style().device(5.0f); // the controller's own grab radius, logical
|
||||
f32 column = 300.0f; // one strip of columns per region below
|
||||
|
||||
for (const f32 width_px : {4.0f, 6.0f, 9.0f}) {
|
||||
const f64 a = data_at(f.plot, f.ax, column);
|
||||
const f64 b = data_at(f.plot, f.ax, column + width_px);
|
||||
const u32 rid = f.plot.add_region(free_region(a, b));
|
||||
const f32 middle = column + 0.5f * width_px;
|
||||
|
||||
// Both bounds are grabbable: the edge still wins where the pointer is on it ...
|
||||
hover(f.controller, column);
|
||||
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Left);
|
||||
hover(f.controller, column + width_px);
|
||||
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Right);
|
||||
// ... and the middle is the body, so the region can be moved as well as resized.
|
||||
hover(f.controller, middle);
|
||||
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Body);
|
||||
press(f.controller, middle);
|
||||
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveRegion);
|
||||
PLOT_CHECK(f.controller.grabbed_region() == rid);
|
||||
move_to(f.controller, middle + 30.0f);
|
||||
const f64 delta = data_at(f.plot, f.ax, middle + 30.0f) - data_at(f.plot, f.ax, middle);
|
||||
PLOT_CHECK_NEAR(f.plot.region(rid).x0, a + delta, 1e-9);
|
||||
PLOT_CHECK_NEAR(f.plot.region(rid).x1, b + delta, 1e-9); // moved, width intact
|
||||
release(f.controller, middle + 30.0f);
|
||||
column += 80.0f;
|
||||
}
|
||||
|
||||
// Where there is room the radius is unchanged: a press a few pixels inside a wide
|
||||
// region's bound is that bound and not the body.
|
||||
const u32 wide = f.plot.add_region(
|
||||
free_region(data_at(f.plot, f.ax, 600.0f), data_at(f.plot, f.ax, 700.0f)));
|
||||
hover(f.controller, 600.0f + radius - 1.0f);
|
||||
PLOT_CHECK(f.controller.region_grab(wide) == plot::RegionGrab::Left);
|
||||
press(f.controller, 600.0f + radius - 1.0f);
|
||||
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::ResizeRegionLeft);
|
||||
release(f.controller, 600.0f + radius - 1.0f);
|
||||
}
|
||||
|
||||
/// `publish_hot()` is the single writer of `HotHandles`, and the wheel and the keyboard
|
||||
/// change the geometry under a *stationary* pointer. Without a publish of their own, a
|
||||
/// frame drawn right after a scroll would highlight a handle the pointer has left -- or
|
||||
/// miss one it has just landed on -- until the next mouse move, which is exactly the
|
||||
/// stale-highlight defect the publish exists to prevent. The layout has to be refreshed
|
||||
/// with it: the region rects the hit test reads were measured for the range the zoom has
|
||||
/// just replaced.
|
||||
PLOT_TEST(hot_handles_follow_a_gesture_that_moves_the_geometry) {
|
||||
Fixture f;
|
||||
const u32 cid = f.plot.add_cursor(50.0, plot::Color::hex(0x00FF00));
|
||||
const u32 off_centre = f.plot.add_cursor(75.0, plot::Color::hex(0xFF00FF));
|
||||
const u32 rid = f.plot.add_region(free_region(70.0, 90.0));
|
||||
const f32 radius = f.plot.style().device(5.0f);
|
||||
|
||||
// The pointer sits four pixels to the right of the marker: on it, and published as such.
|
||||
const f32 px = screen_x_of(f.plot, f.ax, 50.0) + 4.0f;
|
||||
hover(f.controller, px);
|
||||
PLOT_CHECK(f.plot.hot_handles().cursor == cid);
|
||||
|
||||
// A zoom *in* about the pointer grows the marker's offset from it by the zoom factor --
|
||||
// 4 px becomes 6.3 -- so the marker leaves without the pointer moving at all.
|
||||
f.controller.on_wheel({px, 200.0f, 0.0f, 3.0f, 0u});
|
||||
PLOT_CHECK(std::fabs(screen_x_of(f.plot, f.ax, 50.0) - px) > radius);
|
||||
PLOT_CHECK(f.controller.hovered_cursor() == plot::kInvalid);
|
||||
PLOT_CHECK(f.plot.hot_handles().cursor == plot::kInvalid);
|
||||
|
||||
// And the other way round: a zoom out about the same column brings it back under the
|
||||
// stationary pointer, where the published state has to be the marker again.
|
||||
f.controller.on_wheel({px, 200.0f, 0.0f, -3.0f, 0u});
|
||||
PLOT_CHECK(std::fabs(screen_x_of(f.plot, f.ax, 50.0) - px) <= radius);
|
||||
PLOT_CHECK(f.controller.hovered_cursor() == cid);
|
||||
PLOT_CHECK(f.plot.hot_handles().cursor == cid);
|
||||
|
||||
// The keyboard path: a zoom about the plot centre moves the *other* marker, which the
|
||||
// pointer is on, out from under it.
|
||||
const f32 on_marker = screen_x_of(f.plot, f.ax, 75.0);
|
||||
hover(f.controller, on_marker);
|
||||
PLOT_CHECK(f.plot.hot_handles().cursor == off_centre);
|
||||
f.controller.on_key({plot::input::kKeyZoomIn, 0u, true});
|
||||
PLOT_CHECK(std::fabs(screen_x_of(f.plot, f.ax, 75.0) - on_marker) > radius);
|
||||
PLOT_CHECK(f.plot.hot_handles().cursor == plot::kInvalid);
|
||||
|
||||
// And a region bound: the zone that is published after the gesture is the zone the
|
||||
// geometry now gives, not the one the bound had before the zoom.
|
||||
const f32 bound = bound_x(f.plot, rid, true);
|
||||
hover(f.controller, bound);
|
||||
PLOT_CHECK(f.plot.hot_handles().region == rid);
|
||||
PLOT_CHECK(f.plot.hot_handles().zone == plot::RegionGrab::Left);
|
||||
f.controller.on_key({plot::input::kKeyZoomIn, 0u, true});
|
||||
PLOT_CHECK(std::fabs(bound_x(f.plot, rid, true) - bound) > radius); // the bound moved away
|
||||
PLOT_CHECK(f.plot.hot_handles().zone == f.controller.region_grab(rid));
|
||||
PLOT_CHECK(f.plot.hot_handles().zone != plot::RegionGrab::Left);
|
||||
}
|
||||
@@ -1641,3 +1641,66 @@ PLOT_TEST(removing_a_region_shifts_the_ids_and_keeps_the_statistics) {
|
||||
PLOT_CHECK(!p.region_stats(2u, tr).valid);
|
||||
PLOT_CHECK(!p.region_stats(9u, tr).valid);
|
||||
}
|
||||
|
||||
/// A bound that is not a number is not a bound. `std::min`/`std::max` propagate a NaN
|
||||
/// through *both* ends of the pair, so accepting one would not merely invert the interval:
|
||||
/// it would replace the finite partner too, and a `Free` region stored that way could never
|
||||
/// be dragged back -- every motion re-derives both bounds from the stored ones. The other
|
||||
/// bound setters in the file refuse a non-finite value outright, and these two write paths
|
||||
/// have to as well: `set_region` leaves the region on the interval it already held, and
|
||||
/// `add_region`, which does not route through it, adds nothing at all.
|
||||
PLOT_TEST(non_finite_region_bounds_are_refused) {
|
||||
plot::data::ArraySeries s(ramp(100), 0.0, 1.0);
|
||||
plot::Plot p;
|
||||
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
||||
const u32 ax = p.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false});
|
||||
const u32 ay = p.add_y_axis({"v", {0.0, 99.0}, false, plot::AxisPlace::Left, false});
|
||||
const u32 tr = p.add_trace({&s, plot::Color::hex(0xFFFFFF), 1.0f, ax, ay});
|
||||
|
||||
plot::Region reg;
|
||||
reg.x0 = 10.0;
|
||||
reg.x1 = 20.0;
|
||||
reg.snap = plot::RegionSnap::Free;
|
||||
const u32 rid = p.add_region(reg);
|
||||
const f64 nan = std::numeric_limits<f64>::quiet_NaN();
|
||||
const f64 inf = std::numeric_limits<f64>::infinity();
|
||||
|
||||
// Each of the four ways a pair can be unusable, and the interval is the same one after
|
||||
// every one of them -- both bounds, not just the bound that arrived bad.
|
||||
p.set_region(rid, nan, 20.0);
|
||||
PLOT_CHECK_NEAR(p.region(rid).x0, 10.0, 1e-12);
|
||||
PLOT_CHECK_NEAR(p.region(rid).x1, 20.0, 1e-12);
|
||||
p.set_region(rid, 10.0, nan);
|
||||
PLOT_CHECK_NEAR(p.region(rid).x0, 10.0, 1e-12);
|
||||
PLOT_CHECK_NEAR(p.region(rid).x1, 20.0, 1e-12);
|
||||
p.set_region(rid, 30.0, inf);
|
||||
PLOT_CHECK_NEAR(p.region(rid).x0, 10.0, 1e-12);
|
||||
PLOT_CHECK_NEAR(p.region(rid).x1, 20.0, 1e-12);
|
||||
p.set_region(rid, -inf, 5.0);
|
||||
PLOT_CHECK_NEAR(p.region(rid).x0, 10.0, 1e-12);
|
||||
PLOT_CHECK_NEAR(p.region(rid).x1, 20.0, 1e-12);
|
||||
// The interval it still holds is the one its statistics are over.
|
||||
PLOT_CHECK(p.region_stats(rid, tr).valid);
|
||||
PLOT_CHECK_NEAR(p.region_stats(rid, tr).min, 10.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(p.region_stats(rid, tr).max, 20.0, 1e-9);
|
||||
|
||||
// A region whose bounds are not numbers is refused rather than stored: the count does not
|
||||
// move, no id is handed out, and the id that came back names nothing.
|
||||
const u32 before = p.region_count();
|
||||
plot::Region bad = reg;
|
||||
bad.x0 = nan; // the finite partner goes with it, which is why the pair is refused
|
||||
const u32 refused = p.add_region(bad);
|
||||
PLOT_CHECK(p.region_count() == before);
|
||||
PLOT_CHECK(refused != before);
|
||||
PLOT_CHECK_NEAR(p.region(refused).x0, 0.0, 1e-12);
|
||||
PLOT_CHECK_NEAR(p.region(refused).x1, 0.0, 1e-12);
|
||||
PLOT_CHECK(!p.region_stats(refused, tr).valid);
|
||||
// The next region a caller adds still takes the next free id, so a refusal leaves the plot
|
||||
// exactly as it was rather than a hole a later id would fall into.
|
||||
plot::Region good = reg;
|
||||
good.x0 = 30.0;
|
||||
good.x1 = 40.0;
|
||||
PLOT_CHECK(p.add_region(good) == before);
|
||||
PLOT_CHECK(p.region_count() == before + 1u);
|
||||
PLOT_CHECK_NEAR(p.region(before).x0, 30.0, 1e-12);
|
||||
}
|
||||
Reference in new issue
Block a user